personal memory agent
0
fork

Configure Feed

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

fix(setup): recognize persisted re-run + add prior-run preface and --force flag

step_journal previously ignored the persisted-config match before checking whether a journal was non-empty, so non-interactive re-runs against an already configured journal dead-ended unnecessarily. Setup now summarizes the prior manifest state at the top of run_setup for clean, partial, or absent prior runs. The new --force flag only changes the clean-rerun preface wording today and does not change step execution behavior; tests/test_setup.py adds seven focused tests for the rerun and preface cases.

+292 -12
+230 -9
tests/test_setup.py
··· 12 12 import pytest 13 13 14 14 from think import health_cli, service, setup 15 + from think.user_config import write_user_config 15 16 16 17 17 18 def patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: ··· 264 265 assert {step["status"] for step in manifest["steps"]} <= {"ok", "skipped", "failed"} 265 266 266 267 267 - def test_idempotent_rerun_short_circuits( 268 + def test_persisted_journal_skips_existing_journal_check_non_interactive( 269 + tmp_path: Path, 270 + monkeypatch: pytest.MonkeyPatch, 271 + capsys: pytest.CaptureFixture[str], 272 + ) -> None: 273 + patch_home(monkeypatch, tmp_path) 274 + patch_source_checkout(monkeypatch, tmp_path) 275 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 276 + journal = tmp_path / "journal" 277 + (journal / "config").mkdir(parents=True) 278 + write_user_config(journal=str(journal)) 279 + patch_subprocess(monkeypatch) 280 + patch_service_health(monkeypatch) 281 + 282 + rc = setup.main(["--yes"]) 283 + 284 + assert rc == 0 285 + assert "already contains journal data" not in capsys.readouterr().err 286 + journal_step = next( 287 + step for step in read_manifest(journal)["steps"] if step["name"] == "journal" 288 + ) 289 + assert journal_step["status"] == "ok" 290 + 291 + 292 + def test_persisted_journal_skips_existing_journal_check_interactive( 293 + tmp_path: Path, 294 + monkeypatch: pytest.MonkeyPatch, 295 + capsys: pytest.CaptureFixture[str], 296 + ) -> None: 297 + patch_home(monkeypatch, tmp_path) 298 + patch_source_checkout(monkeypatch, tmp_path) 299 + patch_tty(monkeypatch) 300 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 301 + journal = tmp_path / "journal" 302 + (journal / "config").mkdir(parents=True) 303 + write_user_config(journal=str(journal)) 304 + patch_subprocess(monkeypatch) 305 + patch_service_health(monkeypatch) 306 + 307 + def fail_on_prompt(path: Path) -> bool: 308 + raise AssertionError(f"unexpected existing-journal prompt for {path}") 309 + 310 + monkeypatch.setattr(setup, "prompt_accept_existing_journal", fail_on_prompt) 311 + 312 + rc = setup.main([]) 313 + 314 + assert rc == 0 315 + assert "Use existing journal" not in capsys.readouterr().out 316 + 317 + 318 + def test_existing_journal_dead_end_still_fires_when_path_not_persisted( 319 + tmp_path: Path, 320 + monkeypatch: pytest.MonkeyPatch, 321 + capsys: pytest.CaptureFixture[str], 322 + ) -> None: 323 + patch_home(monkeypatch, tmp_path) 324 + patch_source_checkout(monkeypatch, tmp_path) 325 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 326 + patch_subprocess(monkeypatch) 327 + patch_service_health(monkeypatch) 328 + journal = tmp_path / "journal" 329 + (journal / "config").mkdir(parents=True) 330 + 331 + rc = setup.main(["--yes", "--journal", str(journal)]) 332 + 333 + assert rc == 2 334 + assert "already contains journal data" in capsys.readouterr().err 335 + 336 + 337 + def test_clean_rerun_preface_when_manifest_complete( 268 338 tmp_path: Path, 269 339 monkeypatch: pytest.MonkeyPatch, 340 + capsys: pytest.CaptureFixture[str], 270 341 ) -> None: 271 - home = patch_home(monkeypatch, tmp_path) 342 + patch_home(monkeypatch, tmp_path) 272 343 patch_source_checkout(monkeypatch, tmp_path) 273 344 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 274 - (home / ".claude").mkdir() 345 + patch_subprocess(monkeypatch) 346 + patch_service_health(monkeypatch) 275 347 journal = tmp_path / "journal" 276 348 journal.mkdir() 349 + completed_at = "2026-05-02T21:30:42Z" 350 + started_at = "2026-05-02T21:29:42Z" 351 + steps = [ 352 + { 353 + "name": name, 354 + "status": "ok", 355 + "paths": [], 356 + "started_at": started_at, 357 + "finished_at": completed_at, 358 + "error": None, 359 + } 360 + for name in ( 361 + "doctor", 362 + "journal", 363 + "install_models", 364 + "skills", 365 + "wrapper", 366 + "service", 367 + ) 368 + ] 277 369 (journal / ".setup-state.json").write_text( 278 - json.dumps({"schema_version": 1, "completed_at": "2026-05-02T21:30:42Z"}), 370 + json.dumps( 371 + { 372 + "schema_version": 1, 373 + "started_at": started_at, 374 + "completed_at": completed_at, 375 + "mode": "non_interactive", 376 + "args_resolved": {}, 377 + "steps": steps, 378 + } 379 + ), 279 380 encoding="utf-8", 280 381 ) 281 - calls = patch_subprocess(monkeypatch) 382 + 383 + rc = setup.main(["--yes", "--journal", str(journal)]) 384 + 385 + assert rc == 0 386 + lines = capsys.readouterr().out.splitlines() 387 + assert lines[0] == ( 388 + f"sol setup last ran cleanly on {completed_at}; verifying current state." 389 + ) 390 + assert lines[1] == "Use --force to re-run all steps unconditionally." 391 + 392 + 393 + def test_partial_rerun_preface_when_steps_failed( 394 + tmp_path: Path, 395 + monkeypatch: pytest.MonkeyPatch, 396 + capsys: pytest.CaptureFixture[str], 397 + ) -> None: 398 + patch_home(monkeypatch, tmp_path) 399 + patch_source_checkout(monkeypatch, tmp_path) 400 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 401 + patch_subprocess(monkeypatch) 282 402 patch_service_health(monkeypatch) 403 + journal = tmp_path / "journal" 404 + journal.mkdir() 405 + started_at = "2026-05-02T21:29:42Z" 406 + (journal / ".setup-state.json").write_text( 407 + json.dumps( 408 + { 409 + "schema_version": 1, 410 + "started_at": started_at, 411 + "completed_at": None, 412 + "mode": "non_interactive", 413 + "args_resolved": {}, 414 + "steps": [ 415 + { 416 + "name": "install_models", 417 + "status": "failed", 418 + "paths": [], 419 + "started_at": started_at, 420 + "finished_at": "2026-05-02T21:30:42Z", 421 + "error": {"message": "install failed", "exit_code": 1}, 422 + } 423 + ], 424 + } 425 + ), 426 + encoding="utf-8", 427 + ) 283 428 284 429 rc = setup.main(["--yes", "--journal", str(journal)]) 285 430 286 431 assert rc == 0 287 - assert command_contains(calls, "doctor") 288 - assert command_contains(calls, "install-models") 289 - assert command_contains(calls, "think.install_guard") 290 - assert read_manifest(journal)["completed_at"] is not None 432 + assert ( 433 + f"sol setup last run on {started_at} left these steps incomplete:\n" 434 + " - install_models (failed)\n" 435 + "Re-running will pick up where the previous run left off." 436 + ) in capsys.readouterr().out 437 + 438 + 439 + def test_no_preface_without_manifest( 440 + tmp_path: Path, 441 + monkeypatch: pytest.MonkeyPatch, 442 + capsys: pytest.CaptureFixture[str], 443 + ) -> None: 444 + patch_home(monkeypatch, tmp_path) 445 + patch_source_checkout(monkeypatch, tmp_path) 446 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 447 + patch_subprocess(monkeypatch) 448 + patch_service_health(monkeypatch) 449 + journal = tmp_path / "journal" 450 + 451 + rc = setup.main(["--yes", "--journal", str(journal)]) 452 + 453 + assert rc == 0 454 + out = capsys.readouterr().out 455 + assert "last ran cleanly" not in out and "left these steps incomplete" not in out 456 + 457 + 458 + def test_force_flag_changes_preface_text( 459 + tmp_path: Path, 460 + monkeypatch: pytest.MonkeyPatch, 461 + capsys: pytest.CaptureFixture[str], 462 + ) -> None: 463 + patch_home(monkeypatch, tmp_path) 464 + patch_source_checkout(monkeypatch, tmp_path) 465 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 466 + patch_subprocess(monkeypatch) 467 + patch_service_health(monkeypatch) 468 + journal = tmp_path / "journal" 469 + journal.mkdir() 470 + completed_at = "2026-05-02T21:30:42Z" 471 + started_at = "2026-05-02T21:29:42Z" 472 + steps = [ 473 + { 474 + "name": name, 475 + "status": "ok", 476 + "paths": [], 477 + "started_at": started_at, 478 + "finished_at": completed_at, 479 + "error": None, 480 + } 481 + for name in ( 482 + "doctor", 483 + "journal", 484 + "install_models", 485 + "skills", 486 + "wrapper", 487 + "service", 488 + ) 489 + ] 490 + (journal / ".setup-state.json").write_text( 491 + json.dumps( 492 + { 493 + "schema_version": 1, 494 + "started_at": started_at, 495 + "completed_at": completed_at, 496 + "mode": "non_interactive", 497 + "args_resolved": {}, 498 + "steps": steps, 499 + } 500 + ), 501 + encoding="utf-8", 502 + ) 503 + 504 + rc = setup.main(["--yes", "--force", "--journal", str(journal)]) 505 + 506 + assert rc == 0 507 + out = capsys.readouterr().out 508 + assert out.startswith( 509 + f"sol setup last ran cleanly on {completed_at}; re-running all steps (--force)." 510 + ) 511 + assert "Use --force to re-run all steps unconditionally." not in out 291 512 292 513 293 514 def test_partial_completion_resumption(
+62 -3
think/setup.py
··· 61 61 skip_skills: bool 62 62 skip_service: bool 63 63 accept_existing_journal: bool 64 + force: bool 64 65 stdin_is_tty: bool 65 66 stdout_is_tty: bool 66 67 args_resolved: dict[str, object] ··· 148 149 action="store_true", 149 150 help="allow setup to use a non-empty existing journal directory", 150 151 ) 152 + parser.add_argument( 153 + "--force", 154 + action="store_true", 155 + help="re-run all steps unconditionally (today: only changes the preface wording)", 156 + ) 151 157 return parser 152 158 153 159 ··· 192 198 "source": "cli" if variant_supplied else "default", 193 199 }, 194 200 "yes": {"value": bool(args.yes), "source": "cli" if args.yes else "default"}, 201 + "force": { 202 + "value": bool(args.force), 203 + "source": "cli" if args.force else "default", 204 + }, 195 205 "dry_run": { 196 206 "value": bool(args.dry_run), 197 207 "source": "cli" if args.dry_run else "default", ··· 244 254 skip_skills=bool(args.skip_skills), 245 255 skip_service=bool(args.skip_service), 246 256 accept_existing_journal=bool(args.accept_existing_journal), 257 + force=bool(args.force), 247 258 stdin_is_tty=sys.stdin.isatty(), 248 259 stdout_is_tty=sys.stdout.isatty(), 249 260 args_resolved=args_resolved, ··· 300 311 return None 301 312 302 313 314 + @dataclass(frozen=True) 315 + class PriorRunStatus: 316 + state: str # "none" | "clean" | "partial" 317 + timestamp: str | None 318 + failed_steps: tuple[str, ...] 319 + 320 + 321 + def prior_run_status(ctx: SetupContext) -> PriorRunStatus: 322 + manifest = read_manifest(ctx) 323 + if manifest is None: 324 + return PriorRunStatus("none", None, ()) 325 + steps = manifest.get("steps") or [] 326 + failed = tuple( 327 + s.get("name", "<unknown>") 328 + for s in steps 329 + if s.get("status") not in ("ok", "skipped") 330 + ) 331 + completed_at = manifest.get("completed_at") 332 + if completed_at and not failed: 333 + return PriorRunStatus("clean", completed_at, ()) 334 + return PriorRunStatus("partial", manifest.get("started_at"), failed) 335 + 336 + 303 337 def write_manifest(ctx: SetupContext, manifest: dict[str, Any]) -> None: 304 338 try: 305 339 ctx.manifest_path.parent.mkdir(parents=True, exist_ok=True) ··· 505 539 def step_journal(ctx: SetupContext, step_index: int) -> StepResult: 506 540 started_at = utc_now() 507 541 print_step_header(step_index, "journal config") 508 - if non_empty_journal(ctx.journal_path) and not ctx.accept_existing_journal: 542 + persisted = read_user_config().get("journal", "").strip() 543 + persisted_matches = bool(persisted) and expand_path(persisted) == ctx.journal_path 544 + if ( 545 + non_empty_journal(ctx.journal_path) 546 + and not ctx.accept_existing_journal 547 + and not persisted_matches 548 + ): 509 549 if ctx.mode is SetupMode.NON_INTERACTIVE: 510 550 dead_end_existing_journal(ctx) 511 551 if not prompt_accept_existing_journal(ctx.journal_path): 512 552 raise SetupDeadEnd("setup aborted by user", 2) 513 553 514 - persisted = read_user_config().get("journal", "").strip() 515 - persisted_matches = bool(persisted) and expand_path(persisted) == ctx.journal_path 516 554 if not persisted_matches: 517 555 write_user_config(journal=str(ctx.journal_path)) 518 556 print(f"[step {step_index}/{TOTAL_STEPS}] wrote {ctx.config_path}") ··· 830 868 return paths 831 869 832 870 871 + def print_prior_run_preface(ctx: SetupContext) -> None: 872 + status = prior_run_status(ctx) 873 + if status.state == "none": 874 + return 875 + if status.state == "clean": 876 + suffix = ( 877 + "re-running all steps (--force)." 878 + if ctx.force 879 + else "verifying current state." 880 + ) 881 + print(f"sol setup last ran cleanly on {status.timestamp}; {suffix}") 882 + if not ctx.force: 883 + print("Use --force to re-run all steps unconditionally.") 884 + return 885 + print(f"sol setup last run on {status.timestamp} left these steps incomplete:") 886 + for name in status.failed_steps: 887 + print(f" - {name} (failed)") 888 + print("Re-running will pick up where the previous run left off.") 889 + 890 + 833 891 def run_setup(ctx: SetupContext) -> int: 834 892 if ctx.mode is SetupMode.EXPLAIN: 835 893 print_plan(ctx, dry_run=False) ··· 838 896 print_plan(ctx, dry_run=True) 839 897 return 0 840 898 899 + print_prior_run_preface(ctx) 841 900 manifest = initial_manifest(ctx) 842 901 steps = [ 843 902 step_doctor,