From a9c79384c4a9efd06a8859e3c3b90d281a15df76 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Mon, 27 Jul 2026 11:56:36 -0400 Subject: [PATCH 1/8] feat: add CEO session resume via Claude --resume/--session-id Enable CEO sessions to be resumed after interruption by persisting Claude session IDs and using --resume on respawns. The completion guard now continues the same conversation instead of cold-starting, and `factory resume ` reconnects interactively via os.execvp. --- factory/ceo_completion.py | 153 ++++++++++++++++++++++++++++++++---- factory/cli/_main.py | 3 +- factory/cli/ceo.py | 19 ++++- factory/cli/infra.py | 72 +++++++++++++---- factory/models.py | 20 ++++- factory/runners/claude.py | 74 +++++++++++++---- factory/runners/protocol.py | 6 +- 7 files changed, 292 insertions(+), 55 deletions(-) diff --git a/factory/ceo_completion.py b/factory/ceo_completion.py index 2bad407e6..2ea073acd 100644 --- a/factory/ceo_completion.py +++ b/factory/ceo_completion.py @@ -36,6 +36,35 @@ def _cycle_state_path(project_path: Path) -> Path: return project_path / ".factory" / "state" / "cycle.json" +def _session_state_path(project_path: Path) -> Path: + """Return the path to .factory/state/session.json.""" + return project_path / ".factory" / "state" / "session.json" + + +def read_ceo_session_id(project_path: Path) -> str | None: + """Read the CEO session ID from .factory/state/session.json.""" + path = _session_state_path(project_path) + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + return data.get("session_id") + except (json.JSONDecodeError, ValueError): + return None + + +def write_ceo_session_id(project_path: Path, session_id: str) -> None: + """Write a CEO session ID to .factory/state/session.json.""" + path = _session_state_path(project_path) + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "session_id": session_id, + "created": datetime.now(timezone.utc).isoformat(), + } + path.write_text(json.dumps(data, indent=2)) + log.info("ceo_session_id_written", session_id=session_id) + + def read_cycle_state(project_path: Path) -> CycleState | None: """Read in-flight cycle state if it exists and is non-stale. @@ -81,17 +110,27 @@ def write_cycle_state(project_path: Path, state: CycleState) -> None: # Use model_dump with mode="json" for proper datetime serialization data = state.model_dump(mode="json") path.write_text(json.dumps(data, indent=2)) - log.info("cycle_state_written", cycle_id=state.cycle_id, mode=state.mode, respawns=state.respawns) + log.info( + "cycle_state_written", cycle_id=state.cycle_id, mode=state.mode, respawns=state.respawns + ) def delete_cycle_state(project_path: Path) -> bool: - """Delete cycle.json on cycle completion. Returns True if deleted.""" + """Delete cycle.json and session.json on cycle completion. Returns True if deleted.""" path = _cycle_state_path(project_path) + deleted = False if path.exists(): path.unlink() log.info("cycle_state_deleted", path=str(path)) - return True - return False + deleted = True + + session_path = _session_state_path(project_path) + if session_path.exists(): + session_path.unlink() + log.info("session_state_deleted", path=str(session_path)) + deleted = True + + return deleted def create_cycle_state( @@ -297,8 +336,7 @@ def _build_continuation_task(gap: IncompleteGap, cycle_state: CycleState | None if cycle_state: mode_directive += ( - f"Cycle ID: {cycle_state.cycle_id}\n" - f"Respawn count: {cycle_state.respawns}\n\n" + f"Cycle ID: {cycle_state.cycle_id}\nRespawn count: {cycle_state.respawns}\n\n" ) if gap.mode == "research": @@ -376,6 +414,61 @@ def _write_cycle_incomplete(project_path: Path, gap: IncompleteGap, reason: str) log.warning("cycle_incomplete", reason=reason, gap=gap) +async def _invoke_agent_core( + task: str, + project_path: Path, + *, + timeout: float = 600.0, + model: str | None = None, + runner_name: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + resume_session_id: str | None = None, + use_profile: bool = False, + tmux_persist: bool = False, + workflow_mode: str | None = None, + settings_file: str | None = None, +) -> tuple[str, int, dict[str, object]]: + """Invoke the CEO agent and return (stdout, exit_code, metadata). + + Wraps the runner directly to access AgentRunResult.metadata, + which invoke_agent does not expose. + """ + from factory.agents.runner import resolve_prompt + from factory.runners import get_runner + + prompt = resolve_prompt( + "ceo", project_path, use_profile=use_profile, workflow_mode=workflow_mode + ) + + runner = get_runner(runner_name, project_path=project_path) + agent_session_name = session_name or f"factory: {project_path.resolve().name}/ceo" + + from factory.models import AgentRunRequest + + request = AgentRunRequest( + prompt=prompt, + task=task, + cwd=project_path, + timeout=timeout, + model=model, + skip_permissions=True, + role="ceo", + session_name=agent_session_name, + session_id=session_id, + resume_session_id=resume_session_id, + project_path=project_path, + extras={ + "tmux_persist": tmux_persist, + **({"settings_file": settings_file} if settings_file else {}), + }, + ) + + result = await runner.headless(request) + metadata: dict[str, object] = dict(result.metadata) if result.metadata else {} + return result.stdout, result.return_code, metadata + + async def run_ceo_with_completion_guard( project_path: Path, initial_task: str, @@ -386,6 +479,7 @@ async def run_ceo_with_completion_guard( timeout: float = 3600.0, max_respawns: int | None = None, session_name: str | None = None, + session_id: str | None = None, use_profile: bool = False, tmux_persist: bool = False, background: bool = False, @@ -419,9 +513,15 @@ async def run_ceo_with_completion_guard( if background: log.info("ceo_background_dispatch", reason="--bg: single dispatch, no respawn loop") return await invoke_agent( - "ceo", initial_task, project_path, - timeout=timeout, model=model, runner_name=runner_name, - background=True, session_name=session_name, use_profile=use_profile, + "ceo", + initial_task, + project_path, + timeout=timeout, + model=model, + runner_name=runner_name, + background=True, + session_name=session_name, + use_profile=use_profile, workflow_mode=workflow_mode, settings_file=settings_file, ) @@ -432,8 +532,12 @@ async def run_ceo_with_completion_guard( if resolve("ceo_respawn_disabled", env_var="FACTORY_CEO_RESPAWN_DISABLED") == "1": log.info("ceo_respawn_disabled", reason="FACTORY_CEO_RESPAWN_DISABLED=1") return await invoke_agent( - "ceo", initial_task, project_path, - timeout=timeout, model=model, runner_name=runner_name, + "ceo", + initial_task, + project_path, + timeout=timeout, + model=model, + runner_name=runner_name, session_name=session_name, use_profile=use_profile, tmux_persist=tmux_persist, @@ -443,7 +547,11 @@ async def run_ceo_with_completion_guard( if max_respawns is None: max_respawns = int( - resolve("ceo_max_respawns", env_var="FACTORY_CEO_MAX_RESPAWNS", default=str(DEFAULT_MAX_RESPAWNS)) + resolve( + "ceo_max_respawns", + env_var="FACTORY_CEO_MAX_RESPAWNS", + default=str(DEFAULT_MAX_RESPAWNS), + ) or DEFAULT_MAX_RESPAWNS ) @@ -470,14 +578,23 @@ async def run_ceo_with_completion_guard( task = initial_task final_output = "" gap: IncompleteGap | None = None + captured_session_id: str | None = None for attempt in range(max_respawns + 1): log.info("ceo_spawn", attempt=attempt, task_preview=task[:100], mode=mode) - result, code = await invoke_agent( - "ceo", task, project_path, - timeout=timeout, model=model, runner_name=runner_name, + resume_sid = captured_session_id if attempt > 0 else None + spawn_sid = session_id if attempt == 0 else None + + result, code, metadata = await _invoke_agent_core( + task, + project_path, + timeout=timeout, + model=model, + runner_name=runner_name, session_name=session_name, + session_id=spawn_sid, + resume_session_id=resume_sid, use_profile=use_profile, tmux_persist=tmux_persist, workflow_mode=workflow_mode, @@ -485,6 +602,12 @@ async def run_ceo_with_completion_guard( ) final_output = result + returned_sid = metadata.get("session_id") + if isinstance(returned_sid, str) and returned_sid: + captured_session_id = returned_sid + cycle_state.claude_session_id = returned_sid + write_cycle_state(project_path, cycle_state) + # User interrupt — respect it (but don't delete cycle state for later resume) if code in (130, 143) or code > 128: log.info("ceo_user_interrupt", code=code) diff --git a/factory/cli/_main.py b/factory/cli/_main.py index 367d53f07..a7f260e83 100644 --- a/factory/cli/_main.py +++ b/factory/cli/_main.py @@ -514,8 +514,9 @@ def build_parser() -> argparse.ArgumentParser: ) # resume - p = sub.add_parser("resume", help="Load checkpoint and display resume context") + p = sub.add_parser("resume", help="Resume a CEO session via Claude --resume") p.add_argument("path", help="Path to the project") + p.add_argument("--model", help="Model override for the resumed session") # log p = sub.add_parser("log", help="Append a structured event to .factory/events.jsonl") diff --git a/factory/cli/ceo.py b/factory/cli/ceo.py index bdbd4f1f6..3d907cfe1 100644 --- a/factory/cli/ceo.py +++ b/factory/cli/ceo.py @@ -506,7 +506,9 @@ def cmd_ceo(args: argparse.Namespace) -> int: ensure_skills(wt_path, mode=mode) verification_settings = wt_path / ".factory" / "hooks" / f"settings-{mode}.json" - _verification_settings_file = str(verification_settings) if verification_settings.exists() else None + _verification_settings_file = ( + str(verification_settings) if verification_settings.exists() else None + ) interactive = ( design_existing or bool(design_idea) or bool(research_ideation) or mode == "create" @@ -586,6 +588,13 @@ def cmd_ceo(args: argparse.Namespace) -> int: is_headless=headless, ) + import uuid as _uuid + + from factory.ceo_completion import write_ceo_session_id + + ceo_session_id = str(_uuid.uuid4()) + write_ceo_session_id(wt_path, ceo_session_id) + if headless: # Non-interactive pipe mode (for scripting, cron, tmux) # Uses completion guard to auto-resume on premature exit @@ -601,6 +610,7 @@ def cmd_ceo(args: argparse.Namespace) -> int: model=model, timeout=7200.0, session_name=session_name, + session_id=ceo_session_id, use_profile=use_profile, tmux_persist=tmux_persist, background=background, @@ -664,6 +674,7 @@ def cmd_ceo(args: argparse.Namespace) -> int: role="ceo", skip_permissions=True, session_name=session_name, + session_id=ceo_session_id, extras=extras, ) ) @@ -1496,7 +1507,8 @@ def cmd_refactory(args: argparse.Namespace) -> int: session_id, "--append-system-prompt-file", prompt_file.name, - "--disallowedTools", "Agent", + "--disallowedTools", + "Agent", "--dangerously-skip-permissions", ] else: @@ -1506,7 +1518,8 @@ def cmd_refactory(args: argparse.Namespace) -> int: session_id, "--append-system-prompt-file", prompt_file.name, - "--disallowedTools", "Agent", + "--disallowedTools", + "Agent", "--dangerously-skip-permissions", ] diff --git a/factory/cli/infra.py b/factory/cli/infra.py index 571a948b2..5410324f5 100644 --- a/factory/cli/infra.py +++ b/factory/cli/infra.py @@ -1,4 +1,5 @@ """CLI infra commands.""" + from __future__ import annotations import argparse @@ -12,6 +13,7 @@ log = structlog.get_logger() + def cmd_archive(args: argparse.Namespace) -> int: from factory.obsidian.notes import ( update_memory_index, @@ -60,10 +62,14 @@ def cmd_archive(args: argparse.Namespace) -> int: from factory.obsidian.notes import vault_path as get_vault_path vp = get_vault_path() - _emit_cli_event(project_path, "archive.completed", { - "experiments": len(records), - "vault": str(vp) if vp else "none", - }) + _emit_cli_event( + project_path, + "archive.completed", + { + "experiments": len(records), + "vault": str(vp) if vp else "none", + }, + ) if vp: print(f"Archived {len(records)} experiments to {vp}") else: @@ -91,11 +97,15 @@ def cmd_checkpoint(args: argparse.Namespace) -> int: if args.save: completed_hyps: list[int] = [] if args.completed_hypotheses: - completed_hyps = [int(x.strip()) for x in args.completed_hypotheses.split(",") if x.strip()] + completed_hyps = [ + int(x.strip()) for x in args.completed_hypotheses.split(",") if x.strip() + ] state = CheckpointState( mode=args.mode or "improve", active_experiment_id=args.experiment, - completed_agents=[a.strip() for a in args.completed.split(",")] if args.completed else [], + completed_agents=[a.strip() for a in args.completed.split(",")] + if args.completed + else [], pending_agents=[a.strip() for a in args.pending.split(",")] if args.pending else [], last_eval_scores=json.loads(args.scores) if args.scores else {}, current_hypothesis=args.hypothesis, @@ -116,20 +126,49 @@ def cmd_checkpoint(args: argparse.Namespace) -> int: def cmd_resume(args: argparse.Namespace) -> int: - """Load checkpoint and display resume context for the CEO.""" - from factory.checkpoint import format_checkpoint, load_checkpoint + """Resume a CEO session via Claude --resume. + + Checks two sources for a session ID: + 1. CycleState.claude_session_id (headless run interrupted mid-cycle) + 2. .factory/state/session.json (any CEO run) + """ + import os + import shutil + + from factory.ceo_completion import read_ceo_session_id, read_cycle_state project_path = Path(args.path).resolve() - state = load_checkpoint(project_path) - if state is None: - print("No checkpoint found. Nothing to resume.") + model = getattr(args, "model", None) + + session_id: str | None = None + + cycle_state = read_cycle_state(project_path) + if cycle_state and cycle_state.claude_session_id: + session_id = cycle_state.claude_session_id + log.info("resume_from_cycle_state", session_id=session_id) + + if not session_id: + session_id = read_ceo_session_id(project_path) + if session_id: + log.info("resume_from_session_file", session_id=session_id) + + if not session_id: + print("No CEO session found to resume.", file=sys.stderr) + print("Run 'factory ceo ' first to create a session.", file=sys.stderr) + return 1 + + claude_path = shutil.which("claude") + if not claude_path: + print("Error: 'claude' CLI not found. Install Claude Code first.", file=sys.stderr) return 1 - print("=== Resume Context ===") - print(format_checkpoint(state)) - print() - print("The CEO should resume from this state, skipping completed agents") - print(f"and continuing with: {', '.join(state.pending_agents) or 'none'}") + cmd = ["claude", "--resume", session_id] + if model: + cmd.extend(["--model", model]) + + print(f"Resuming CEO session: {session_id[:12]}...") + os.chdir(project_path) + os.execvp("claude", cmd) return 0 @@ -185,4 +224,3 @@ def cmd_dashboard(args: argparse.Namespace) -> int: uvicorn.run(app, host=host, port=port, log_level="warning") return 0 - diff --git a/factory/models.py b/factory/models.py index 97be71168..34ef06b20 100644 --- a/factory/models.py +++ b/factory/models.py @@ -518,13 +518,25 @@ class CycleState(BaseModel): cycle_id: str started_at: datetime mode: Literal[ - "build", "create", "deep-qa", "design", "discover", - "founder", "improve", "meta", "parallel-improve", - "refine", "research", "review", "swebench", + "build", + "create", + "deep-qa", + "design", + "discover", + "founder", + "improve", + "meta", + "parallel-improve", + "qa", + "refine", + "research", + "review", + "swebench", ] initial_prompt: str = "" respawns: int = 0 runner_name: str | None = None + claude_session_id: str | None = None # ── ACE pipeline data ──────────────────────────────────────────── @@ -648,6 +660,8 @@ class AgentRunRequest(BaseModel): skip_permissions: bool = True role: str = "unknown" session_name: str | None = None + session_id: str | None = None + resume_session_id: str | None = None project_path: Path | None = None extras: dict[str, object] = {} diff --git a/factory/runners/claude.py b/factory/runners/claude.py index 64ea64324..331564591 100644 --- a/factory/runners/claude.py +++ b/factory/runners/claude.py @@ -81,6 +81,7 @@ class ClaudeRunner: @classmethod def metadata(cls) -> RunnerMeta: from factory.runners.protocol import RunnerMeta + return RunnerMeta( name="claude", display_name="Claude Code", @@ -88,24 +89,35 @@ def metadata(cls) -> RunnerMeta: install_hint="npm install -g @anthropic-ai/claude-code", supports_usage_telemetry=True, supports_session_name=True, + supports_session_resume=True, supports_background=True, ) - def build_command(self, request: AgentRunRequest) -> tuple[list[str], dict[str, str], list[Path]]: + def build_command( + self, request: AgentRunRequest + ) -> tuple[list[str], dict[str, str], list[Path]]: """Build the Claude CLI command, env dict, and temp files.""" prompt_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".md", prefix="factory-prompt-", delete=False, + mode="w", + suffix=".md", + prefix="factory-prompt-", + delete=False, ) prompt_file.write(request.prompt) prompt_file.close() prompt_path = Path(prompt_file.name) cmd = [ - "claude", "--append-system-prompt-file", prompt_file.name, - "-p", request.task, - "--output-format", "stream-json", + "claude", + "--append-system-prompt-file", + prompt_file.name, + "-p", + request.task, + "--output-format", + "stream-json", "--verbose", - "--disallowedTools", "Agent", + "--disallowedTools", + "Agent", ] settings_file = request.extras.get("settings_file") if settings_file: @@ -116,6 +128,10 @@ def build_command(self, request: AgentRunRequest) -> tuple[list[str], dict[str, cmd.extend(["--model", request.model]) if request.session_name: cmd.extend(["--name", request.session_name]) + if request.resume_session_id: + cmd.extend(["--resume", request.resume_session_id]) + elif request.session_id: + cmd.extend(["--session-id", request.session_id]) env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} if request.model: @@ -132,7 +148,10 @@ async def headless(self, request: AgentRunRequest) -> AgentRunResult: from factory.runners._background import run_in_background stdout, rc, usage = await run_in_background( - request.prompt, request.task, request.cwd, request.role, + request.prompt, + request.task, + request.cwd, + request.role, timeout=request.timeout, model=request.model, dangerously_skip_permissions=request.skip_permissions, @@ -145,7 +164,10 @@ async def headless(self, request: AgentRunRequest) -> AgentRunResult: if tmux_available(): stdout, rc, usage = await run_in_tmux( - request.prompt, request.task, request.cwd, request.role, + request.prompt, + request.task, + request.cwd, + request.role, find_project_path(request.cwd), model=request.model, dangerously_skip_permissions=request.skip_permissions, @@ -163,8 +185,12 @@ async def headless(self, request: AgentRunRequest) -> AgentRunResult: on_line = _make_ceo_message_emitter(request.project_path) result = await run_subprocess( - cmd, cwd=str(request.cwd), env=env, - timeout=request.timeout, runner_name="claude", role=request.role, + cmd, + cwd=str(request.cwd), + env=env, + timeout=request.timeout, + runner_name="claude", + role=request.role, on_line=on_line, ) @@ -189,8 +215,16 @@ async def headless(self, request: AgentRunRequest) -> AgentRunResult: result_value = data.get("result", result.stdout) result_text = result_value if isinstance(result_value, str) else result.stdout usage = _parse_usage(data) - for key in ("session_id", "uuid", "stop_reason", "terminal_reason", - "duration_api_ms", "ttft_ms", "is_error", "subtype"): + for key in ( + "session_id", + "uuid", + "stop_reason", + "terminal_reason", + "duration_api_ms", + "ttft_ms", + "is_error", + "subtype", + ): metadata[key] = data.get(key) metadata["model_usage"] = data.get("modelUsage") metadata["permission_denials"] = data.get("permission_denials") @@ -205,10 +239,15 @@ async def headless(self, request: AgentRunRequest) -> AgentRunResult: for f in temp_files: f.unlink(missing_ok=True) - def build_interactive_command(self, request: AgentRunRequest) -> tuple[list[str], dict[str, str], list[Path]]: + def build_interactive_command( + self, request: AgentRunRequest + ) -> tuple[list[str], dict[str, str], list[Path]]: """Build the CLI command, env dict, and temp files for an interactive invocation.""" prompt_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".md", prefix="factory-prompt-", delete=False, + mode="w", + suffix=".md", + prefix="factory-prompt-", + delete=False, ) prompt_file.write(request.prompt) prompt_file.close() @@ -242,7 +281,8 @@ def build_interactive_command(self, request: AgentRunRequest) -> tuple[list[str] cmd = [ "claude", - "--append-system-prompt-file", prompt_file.name, + "--append-system-prompt-file", + prompt_file.name, ] settings_file = request.extras.get("settings_file") if settings_file: @@ -254,6 +294,10 @@ def build_interactive_command(self, request: AgentRunRequest) -> tuple[list[str] cmd.extend(["--model", request.model]) if request.session_name: cmd.extend(["--name", request.session_name]) + if request.resume_session_id: + cmd.extend(["--resume", request.resume_session_id]) + elif request.session_id: + cmd.extend(["--session-id", request.session_id]) env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} if request.model: diff --git a/factory/runners/protocol.py b/factory/runners/protocol.py index 2cb9c6a08..9d8911001 100644 --- a/factory/runners/protocol.py +++ b/factory/runners/protocol.py @@ -25,6 +25,7 @@ class RunnerMeta: supports_streaming: bool = True supports_usage_telemetry: bool = False supports_session_name: bool = False + supports_session_resume: bool = False supports_background: bool = False custom_auth_check: Callable[[], bool] | None = None @@ -41,6 +42,7 @@ def check_auth(self) -> bool: if self.custom_auth_check is not None: return self.custom_auth_check() import os + return all(os.environ.get(v) for v in self.required_env_vars) @@ -54,7 +56,9 @@ def metadata(cls) -> RunnerMeta: """Return metadata about this runner.""" ... - def build_command(self, request: AgentRunRequest) -> tuple[list[str], dict[str, str], list[Path]]: + def build_command( + self, request: AgentRunRequest + ) -> tuple[list[str], dict[str, str], list[Path]]: """Build the CLI command, env dict, and temp files for a headless invocation.""" ... From dc9ce330de790c161e3c46df65fb61deb0dc63c9 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Mon, 27 Jul 2026 12:07:13 -0400 Subject: [PATCH 2/8] fix: use invoke_agent for session threading, add tests Thread session_id/resume_session_id through invoke_agent instead of a separate _invoke_agent_core function, preserving backward compat with existing mocks. Extract session_id from events.jsonl after each spawn. Add 30 tests covering all 7 change areas. --- factory/agents/runner.py | 101 +++++-- factory/ceo_completion.py | 69 +---- tests/test_session_resume.py | 542 +++++++++++++++++++++++++++++++++++ 3 files changed, 628 insertions(+), 84 deletions(-) create mode 100644 tests/test_session_resume.py diff --git a/factory/agents/runner.py b/factory/agents/runner.py index b3c1f4602..00e8bbe8a 100644 --- a/factory/agents/runner.py +++ b/factory/agents/runner.py @@ -14,9 +14,17 @@ logger = logging.getLogger(__name__) AgentRole = Literal[ - "researcher", "strategist", "builder", - "health_checker", "code_reviewer", "adversarial_tester", - "archivist", "ceo", "failure_analyst", "refiner", "profiler", + "researcher", + "strategist", + "builder", + "health_checker", + "code_reviewer", + "adversarial_tester", + "archivist", + "ceo", + "failure_analyst", + "refiner", + "profiler", "refactory", ] @@ -48,6 +56,7 @@ def reset_failure_counter() -> None: global _consecutive_failures _consecutive_failures = 0 + IDENTITY_REANCHOR = """\ --- @@ -104,10 +113,11 @@ def resolve_prompt( # Fall back to factory default default_path = _PROMPTS_DIR / f"{role}.md" if not default_path.exists(): - override_hint = f" or {project_path / '.factory' / 'agents' / f'{role}.md'}" if project_path else "" + override_hint = ( + f" or {project_path / '.factory' / 'agents' / f'{role}.md'}" if project_path else "" + ) raise FileNotFoundError( - f"No prompt found for agent role '{role}'. " - f"Expected at {default_path}{override_hint}" + f"No prompt found for agent role '{role}'. Expected at {default_path}{override_hint}" ) prompt = default_path.read_text() @@ -160,6 +170,8 @@ async def invoke_agent( runner_name: str | None = None, _track_failures: bool = True, session_name: str | None = None, + session_id: str | None = None, + resume_session_id: str | None = None, use_profile: bool = False, tmux_persist: bool = False, background: bool = False, @@ -177,7 +189,9 @@ async def invoke_agent( """ global _consecutive_failures - prompt = resolve_prompt(role, project_path, use_profile=use_profile, workflow_mode=workflow_mode) + prompt = resolve_prompt( + role, project_path, use_profile=use_profile, workflow_mode=workflow_mode + ) if os.environ.get("FACTORY_NO_GITHUB") == "1": prompt += ( @@ -213,6 +227,8 @@ async def invoke_agent( skip_permissions=dangerously_skip_permissions, role=role, session_name=agent_session_name, + session_id=session_id, + resume_session_id=resume_session_id, project_path=project_path, extras={ "tmux_persist": tmux_persist, @@ -242,12 +258,18 @@ async def invoke_agent( if return_code != 0: logger.warning("%s agent exited with code %d", role, return_code) _emit_safe( - project_path, "agent.failed", agent=role, + project_path, + "agent.failed", + agent=role, data={"return_code": return_code, "stderr": stdout[:200] if stdout else ""}, ) _complete_span_safe( - project_path, sid, status="failed", - usage=usage, metadata=result.metadata, output=stdout, + project_path, + sid, + status="failed", + usage=usage, + metadata=result.metadata, + output=stdout, ) if _track_failures: _consecutive_failures += 1 @@ -257,25 +279,33 @@ async def invoke_agent( if review_tag: completed_data["review_tag"] = review_tag if usage is not None: - completed_data.update({ - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_read_tokens": usage.cache_read_tokens, - "total_cost_usd": usage.total_cost_usd, - "duration_ms": usage.duration_ms, - "num_turns": usage.num_turns, - "model": usage.model, - }) + completed_data.update( + { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_read_tokens": usage.cache_read_tokens, + "total_cost_usd": usage.total_cost_usd, + "duration_ms": usage.duration_ms, + "num_turns": usage.num_turns, + "model": usage.model, + } + ) for meta_key in ("session_id", "stop_reason", "terminal_reason"): if result.metadata.get(meta_key) is not None: completed_data[meta_key] = result.metadata[meta_key] _emit_safe( - project_path, "agent.completed", agent=role, + project_path, + "agent.completed", + agent=role, data=completed_data, ) _complete_span_safe( - project_path, sid, status="completed", - usage=usage, metadata=result.metadata, output=stdout, + project_path, + sid, + status="completed", + usage=usage, + metadata=result.metadata, + output=stdout, ) if _track_failures: _consecutive_failures = 0 @@ -335,7 +365,8 @@ def _begin_span_safe( parent_span_id = os.environ.get("FACTORY_PARENT_SPAN_ID") logger.debug( "Langfuse env: FACTORY_TRACE_ID=%s FACTORY_PARENT_SPAN_ID=%s", - trace_id, parent_span_id, + trace_id, + parent_span_id, ) if not trace_id: result = begin_trace(project_path.name, cycle_id=f"standalone-{role}") @@ -375,8 +406,15 @@ def _complete_span_safe( usage_dict: dict | None = None if usage is not None: usage_dict = {} - for key in ("input_tokens", "output_tokens", "cache_read_tokens", - "total_cost_usd", "duration_ms", "num_turns", "model"): + for key in ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "total_cost_usd", + "duration_ms", + "num_turns", + "model", + ): val = getattr(usage, key, None) if val is not None: usage_dict[key] = val @@ -387,18 +425,25 @@ def _complete_span_safe( ingest_transcript_to_span(trace_id, span_id, claude_session_id, project_path) end_span( - trace_id, span_id, - status=status, usage=usage_dict, metadata=meta or None, + trace_id, + span_id, + status=status, + usage=usage_dict, + metadata=meta or None, output=output[:4000] if output else None, ) from factory.telemetry import flush as _flush + _flush() except Exception: logger.debug("Failed to complete span %s", span_id, exc_info=True) def _save_review( - project_path: Path, role: str, output: str, return_code: int, + project_path: Path, + role: str, + output: str, + return_code: int, review_tag: str | None = None, ) -> None: """Save agent output to .factory/reviews/-latest.md for CEO review. diff --git a/factory/ceo_completion.py b/factory/ceo_completion.py index 2ea073acd..8f2fda71c 100644 --- a/factory/ceo_completion.py +++ b/factory/ceo_completion.py @@ -414,59 +414,15 @@ def _write_cycle_incomplete(project_path: Path, gap: IncompleteGap, reason: str) log.warning("cycle_incomplete", reason=reason, gap=gap) -async def _invoke_agent_core( - task: str, - project_path: Path, - *, - timeout: float = 600.0, - model: str | None = None, - runner_name: str | None = None, - session_name: str | None = None, - session_id: str | None = None, - resume_session_id: str | None = None, - use_profile: bool = False, - tmux_persist: bool = False, - workflow_mode: str | None = None, - settings_file: str | None = None, -) -> tuple[str, int, dict[str, object]]: - """Invoke the CEO agent and return (stdout, exit_code, metadata). - - Wraps the runner directly to access AgentRunResult.metadata, - which invoke_agent does not expose. - """ - from factory.agents.runner import resolve_prompt - from factory.runners import get_runner - - prompt = resolve_prompt( - "ceo", project_path, use_profile=use_profile, workflow_mode=workflow_mode - ) - - runner = get_runner(runner_name, project_path=project_path) - agent_session_name = session_name or f"factory: {project_path.resolve().name}/ceo" - - from factory.models import AgentRunRequest - - request = AgentRunRequest( - prompt=prompt, - task=task, - cwd=project_path, - timeout=timeout, - model=model, - skip_permissions=True, - role="ceo", - session_name=agent_session_name, - session_id=session_id, - resume_session_id=resume_session_id, - project_path=project_path, - extras={ - "tmux_persist": tmux_persist, - **({"settings_file": settings_file} if settings_file else {}), - }, - ) - - result = await runner.headless(request) - metadata: dict[str, object] = dict(result.metadata) if result.metadata else {} - return result.stdout, result.return_code, metadata +def _extract_session_id(project_path: Path) -> str | None: + """Extract the session_id from the most recent agent.completed event.""" + events = load_events(project_path) + for event in reversed(events): + if event.get("type") == "agent.completed" and event.get("agent") == "ceo": + sid = event.get("data", {}).get("session_id") + if isinstance(sid, str) and sid: + return sid + return None async def run_ceo_with_completion_guard( @@ -586,7 +542,8 @@ async def run_ceo_with_completion_guard( resume_sid = captured_session_id if attempt > 0 else None spawn_sid = session_id if attempt == 0 else None - result, code, metadata = await _invoke_agent_core( + result, code = await invoke_agent( + "ceo", task, project_path, timeout=timeout, @@ -602,8 +559,8 @@ async def run_ceo_with_completion_guard( ) final_output = result - returned_sid = metadata.get("session_id") - if isinstance(returned_sid, str) and returned_sid: + returned_sid = _extract_session_id(project_path) + if returned_sid and returned_sid != captured_session_id: captured_session_id = returned_sid cycle_state.claude_session_id = returned_sid write_cycle_state(project_path, cycle_state) diff --git a/tests/test_session_resume.py b/tests/test_session_resume.py new file mode 100644 index 000000000..82be67a59 --- /dev/null +++ b/tests/test_session_resume.py @@ -0,0 +1,542 @@ +"""Tests for CEO session resume via Claude --resume/--session-id.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from factory.models import AgentRunRequest + + +class TestAgentRunRequestSessionFields: + """Tests for session_id and resume_session_id fields on AgentRunRequest.""" + + def test_default_none(self) -> None: + req = AgentRunRequest(prompt="p", task="t", cwd=Path("/tmp")) + assert req.session_id is None + assert req.resume_session_id is None + + def test_session_id_set(self) -> None: + req = AgentRunRequest( + prompt="p", + task="t", + cwd=Path("/tmp"), + session_id="abc-123", + ) + assert req.session_id == "abc-123" + assert req.resume_session_id is None + + def test_resume_session_id_set(self) -> None: + req = AgentRunRequest( + prompt="p", + task="t", + cwd=Path("/tmp"), + resume_session_id="xyz-789", + ) + assert req.session_id is None + assert req.resume_session_id == "xyz-789" + + +class TestCycleStateClaudeSessionId: + """Tests for claude_session_id field on CycleState.""" + + def test_default_none(self) -> None: + from factory.ceo_completion import create_cycle_state + + state = create_cycle_state("improve") + assert state.claude_session_id is None + + def test_round_trip(self, tmp_path: Path) -> None: + from factory.ceo_completion import ( + create_cycle_state, + read_cycle_state, + write_cycle_state, + ) + + state = create_cycle_state("build") + state.claude_session_id = "session-abc-123" + write_cycle_state(tmp_path, state) + + loaded = read_cycle_state(tmp_path) + assert loaded is not None + assert loaded.claude_session_id == "session-abc-123" + + def test_round_trip_none(self, tmp_path: Path) -> None: + from factory.ceo_completion import ( + create_cycle_state, + read_cycle_state, + write_cycle_state, + ) + + state = create_cycle_state("improve") + write_cycle_state(tmp_path, state) + + loaded = read_cycle_state(tmp_path) + assert loaded is not None + assert loaded.claude_session_id is None + + +class TestRunnerMetaSessionResume: + """Tests for supports_session_resume on RunnerMeta.""" + + def test_default_false(self) -> None: + from factory.runners.protocol import RunnerMeta + + meta = RunnerMeta( + name="test", + display_name="Test", + binary="test", + install_hint="test", + ) + assert meta.supports_session_resume is False + + def test_claude_supports_session_resume(self) -> None: + from factory.runners.claude import ClaudeRunner + + meta = ClaudeRunner.metadata() + assert meta.supports_session_resume is True + + def test_bob_does_not_support_session_resume(self) -> None: + from factory.runners.bob import BobRunner + + meta = BobRunner.metadata() + assert meta.supports_session_resume is False + + +class TestClaudeBuildCommandSessionFlags: + """Tests for --session-id and --resume flags in build_command.""" + + def test_session_id_flag(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + session_id="sid-001", + ) + ) + + assert "--session-id" in cmd + idx = cmd.index("--session-id") + assert cmd[idx + 1] == "sid-001" + assert "--resume" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + def test_resume_flag(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + resume_session_id="rsid-002", + ) + ) + + assert "--resume" in cmd + idx = cmd.index("--resume") + assert cmd[idx + 1] == "rsid-002" + assert "--session-id" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + def test_resume_takes_precedence(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + session_id="sid-001", + resume_session_id="rsid-002", + ) + ) + + assert "--resume" in cmd + assert "--session-id" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + def test_no_flags_when_none(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + ) + + assert "--session-id" not in cmd + assert "--resume" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + +class TestClaudeBuildInteractiveCommandSessionFlags: + """Tests for --session-id and --resume flags in build_interactive_command.""" + + def test_session_id_flag(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_interactive_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + session_id="sid-i-001", + ) + ) + + assert "--session-id" in cmd + idx = cmd.index("--session-id") + assert cmd[idx + 1] == "sid-i-001" + assert "--resume" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + def test_resume_flag(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_interactive_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + resume_session_id="rsid-i-002", + ) + ) + + assert "--resume" in cmd + idx = cmd.index("--resume") + assert cmd[idx + 1] == "rsid-i-002" + assert "--session-id" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + def test_no_flags_when_none(self, tmp_path: Path) -> None: + from factory.runners.claude import ClaudeRunner + + runner = ClaudeRunner() + cmd, _, temp_files = runner.build_interactive_command( + AgentRunRequest( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + ) + + assert "--session-id" not in cmd + assert "--resume" not in cmd + + for f in temp_files: + f.unlink(missing_ok=True) + + +class TestSessionPersistence: + """Tests for read_ceo_session_id and write_ceo_session_id.""" + + def test_write_and_read(self, tmp_path: Path) -> None: + from factory.ceo_completion import read_ceo_session_id, write_ceo_session_id + + write_ceo_session_id(tmp_path, "test-session-123") + result = read_ceo_session_id(tmp_path) + assert result == "test-session-123" + + def test_read_nonexistent(self, tmp_path: Path) -> None: + from factory.ceo_completion import read_ceo_session_id + + assert read_ceo_session_id(tmp_path) is None + + def test_read_malformed(self, tmp_path: Path) -> None: + from factory.ceo_completion import read_ceo_session_id, _session_state_path + + path = _session_state_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not valid json{{{") + + assert read_ceo_session_id(tmp_path) is None + + def test_write_creates_directory(self, tmp_path: Path) -> None: + from factory.ceo_completion import write_ceo_session_id, _session_state_path + + write_ceo_session_id(tmp_path, "sid-abc") + path = _session_state_path(tmp_path) + assert path.exists() + + data = json.loads(path.read_text()) + assert data["session_id"] == "sid-abc" + assert "created" in data + + def test_delete_cycle_state_also_deletes_session(self, tmp_path: Path) -> None: + from factory.ceo_completion import ( + create_cycle_state, + delete_cycle_state, + read_ceo_session_id, + write_ceo_session_id, + write_cycle_state, + ) + + state = create_cycle_state("improve") + write_cycle_state(tmp_path, state) + write_ceo_session_id(tmp_path, "session-to-delete") + + assert read_ceo_session_id(tmp_path) == "session-to-delete" + + deleted = delete_cycle_state(tmp_path) + assert deleted is True + assert read_ceo_session_id(tmp_path) is None + + +class TestCompletionGuardSessionThreading: + """Tests for session_id threading across respawns in the completion guard.""" + + @pytest.fixture(autouse=True) + def enable_respawn(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FACTORY_CEO_RESPAWN_DISABLED", raising=False) + + async def test_first_spawn_uses_session_id(self, tmp_path: Path) -> None: + """First spawn passes session_id, not resume_session_id.""" + from factory.ceo_completion import run_ceo_with_completion_guard + from factory.events import emit_event + + strategy_dir = tmp_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True) + (strategy_dir / "current.md").write_text("#### H1: A\n") + exp_dir = tmp_path / ".factory" / "experiments" / "001" + exp_dir.mkdir(parents=True) + (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') + + captured_kwargs: list[dict] = [] + + async def mock_invoke(role, task, path, **kwargs): + captured_kwargs.append(kwargs) + emit_event(path, "agent.completed", agent="ceo", data={"session_id": "returned-sid"}) + return "done", 0 + + with patch("factory.agents.runner.invoke_agent", mock_invoke): + await run_ceo_with_completion_guard( + tmp_path, + "Initial task", + mode="improve", + runner_name="claude", + session_id="my-session-id", + ) + + assert len(captured_kwargs) == 1 + assert captured_kwargs[0]["session_id"] == "my-session-id" + assert captured_kwargs[0].get("resume_session_id") is None + + async def test_respawn_uses_resume_session_id(self, tmp_path: Path) -> None: + """Respawns pass resume_session_id captured from first spawn's events.""" + from factory.ceo_completion import run_ceo_with_completion_guard + from factory.events import emit_event + + strategy_dir = tmp_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True) + (strategy_dir / "current.md").write_text("#### H1: A\n\n#### H2: B\n") + (tmp_path / ".factory" / "experiments").mkdir(parents=True) + + call_count = 0 + captured_kwargs: list[dict] = [] + + async def mock_invoke(role, task, path, **kwargs): + nonlocal call_count + call_count += 1 + captured_kwargs.append(kwargs) + + emit_event(path, "agent.completed", agent="ceo", data={"session_id": "captured-sid"}) + + exp_dir = path / ".factory" / "experiments" / f"00{call_count}" + exp_dir.mkdir(parents=True, exist_ok=True) + (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') + return f"run {call_count}", 0 + + with patch("factory.agents.runner.invoke_agent", mock_invoke): + await run_ceo_with_completion_guard( + tmp_path, + "Initial task", + mode="improve", + runner_name="claude", + session_id="initial-sid", + ) + + assert call_count == 2 + assert captured_kwargs[0]["session_id"] == "initial-sid" + assert captured_kwargs[0].get("resume_session_id") is None + assert captured_kwargs[1].get("session_id") is None + assert captured_kwargs[1]["resume_session_id"] == "captured-sid" + + async def test_session_id_persisted_to_cycle_state(self, tmp_path: Path) -> None: + """Session ID from events is persisted to CycleState.claude_session_id.""" + from factory.ceo_completion import read_cycle_state, run_ceo_with_completion_guard + from factory.events import emit_event + + strategy_dir = tmp_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True) + (strategy_dir / "current.md").write_text("#### H1: A\n\n#### H2: B\n") + (tmp_path / ".factory" / "experiments").mkdir(parents=True) + + call_count = 0 + + async def mock_invoke(role, task, path, **kwargs): + nonlocal call_count + call_count += 1 + + emit_event(path, "agent.completed", agent="ceo", data={"session_id": "persisted-sid"}) + + exp_dir = path / ".factory" / "experiments" / f"00{call_count}" + exp_dir.mkdir(parents=True, exist_ok=True) + (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') + + if call_count == 2: + state = read_cycle_state(path) + assert state is not None + assert state.claude_session_id == "persisted-sid" + + return f"run {call_count}", 0 + + with patch("factory.agents.runner.invoke_agent", mock_invoke): + await run_ceo_with_completion_guard( + tmp_path, + "Task", + mode="improve", + runner_name="claude", + session_id="initial", + ) + + assert call_count == 2 + + +class TestCmdResume: + """Tests for the factory resume command.""" + + def test_resume_from_cycle_state(self, tmp_path: Path) -> None: + from factory.ceo_completion import create_cycle_state, write_cycle_state + + state = create_cycle_state("improve") + state.claude_session_id = "cycle-session-id" + write_cycle_state(tmp_path, state) + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + from factory.cli.infra import cmd_resume + + cmd_resume(args) + + mock_exec.assert_called_once() + call_args = mock_exec.call_args[0] + assert call_args[0] == "claude" + assert "--resume" in call_args[1] + assert "cycle-session-id" in call_args[1] + + def test_resume_from_session_file(self, tmp_path: Path) -> None: + from factory.ceo_completion import write_ceo_session_id + + write_ceo_session_id(tmp_path, "file-session-id") + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + from factory.cli.infra import cmd_resume + + cmd_resume(args) + + mock_exec.assert_called_once() + call_args = mock_exec.call_args[0] + assert "--resume" in call_args[1] + assert "file-session-id" in call_args[1] + + def test_resume_prefers_cycle_state(self, tmp_path: Path) -> None: + """CycleState.claude_session_id takes precedence over session.json.""" + from factory.ceo_completion import ( + create_cycle_state, + write_ceo_session_id, + write_cycle_state, + ) + + state = create_cycle_state("improve") + state.claude_session_id = "cycle-sid" + write_cycle_state(tmp_path, state) + write_ceo_session_id(tmp_path, "file-sid") + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + from factory.cli.infra import cmd_resume + + cmd_resume(args) + + call_args = mock_exec.call_args[0] + assert "cycle-sid" in call_args[1] + + def test_resume_no_session_found(self, tmp_path: Path) -> None: + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + from factory.cli.infra import cmd_resume + + code = cmd_resume(args) + assert code == 1 + + def test_resume_with_model(self, tmp_path: Path) -> None: + from factory.ceo_completion import write_ceo_session_id + + write_ceo_session_id(tmp_path, "model-test-sid") + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model="claude-opus-4-7") + + with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + from factory.cli.infra import cmd_resume + + cmd_resume(args) + + call_args = mock_exec.call_args[0] + cmd_list = call_args[1] + assert "--model" in cmd_list + model_idx = cmd_list.index("--model") + assert cmd_list[model_idx + 1] == "claude-opus-4-7" + + def test_resume_no_claude_binary(self, tmp_path: Path) -> None: + from factory.ceo_completion import write_ceo_session_id + + write_ceo_session_id(tmp_path, "some-sid") + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + with patch("shutil.which", return_value=None): + from factory.cli.infra import cmd_resume + + code = cmd_resume(args) + assert code == 1 From 43901d6cb79c70b7a8fdb06e2ea8de4eefcd5204 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Mon, 27 Jul 2026 13:26:43 -0400 Subject: [PATCH 3/8] fix: update test_cli_resume_no_checkpoint for new cmd_resume behavior cmd_resume now prints to stderr with the message 'No CEO session found to resume.' instead of the old stdout message 'No checkpoint found.' --- tests/test_checkpoint.py | 78 ++++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index b09c1a0cf..65dbafad9 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -200,21 +200,32 @@ def test_cli_checkpoint_show_none(checkpoint_project: Path) -> None: assert code == 0 -def test_cli_checkpoint_save_and_show(checkpoint_project: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_cli_checkpoint_save_and_show( + checkpoint_project: Path, capsys: pytest.CaptureFixture[str] +) -> None: """factory checkpoint --save persists state, then show reads it.""" from factory.cli import main # Save - code = main([ - "checkpoint", str(checkpoint_project), - "--save", - "--mode", "improve", - "--experiment", "38", - "--completed", "researcher,strategist", - "--pending", "builder,qa", - "--hypothesis", "Test hypothesis", - "--scores", '{"tests": 0.9}', - ]) + code = main( + [ + "checkpoint", + str(checkpoint_project), + "--save", + "--mode", + "improve", + "--experiment", + "38", + "--completed", + "researcher,strategist", + "--pending", + "builder,qa", + "--hypothesis", + "Test hypothesis", + "--scores", + '{"tests": 0.9}', + ] + ) assert code == 0 capsys.readouterr() # clear output @@ -228,17 +239,21 @@ def test_cli_checkpoint_save_and_show(checkpoint_project: Path, capsys: pytest.C assert "builder" in output -def test_cli_resume_no_checkpoint(checkpoint_project: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_cli_resume_no_checkpoint( + checkpoint_project: Path, capsys: pytest.CaptureFixture[str] +) -> None: """factory resume returns 1 when no checkpoint.""" from factory.cli import main code = main(["resume", str(checkpoint_project)]) assert code == 1 - output = capsys.readouterr().out - assert "No checkpoint" in output + output = capsys.readouterr().err + assert "No CEO session found to resume." in output -def test_cli_resume_with_checkpoint(checkpoint_project: Path, sample_state: CheckpointState, capsys: pytest.CaptureFixture[str]) -> None: +def test_cli_resume_with_checkpoint( + checkpoint_project: Path, sample_state: CheckpointState, capsys: pytest.CaptureFixture[str] +) -> None: """factory resume displays resume context.""" save_checkpoint(checkpoint_project, sample_state) @@ -274,20 +289,29 @@ def test_cli_checkpoint_clear_no_file(checkpoint_project: Path) -> None: def test_cli_checkpoint_save_with_completed_hypotheses( - checkpoint_project: Path, capsys: pytest.CaptureFixture[str], + checkpoint_project: Path, + capsys: pytest.CaptureFixture[str], ) -> None: """factory checkpoint --save --completed-hypotheses persists experiment IDs.""" from factory.cli import main - code = main([ - "checkpoint", str(checkpoint_project), - "--save", - "--mode", "improve", - "--completed", "researcher,strategist", - "--pending", "builder", - "--hypothesis", "Add caching", - "--completed-hypotheses", "1,2,3", - ]) + code = main( + [ + "checkpoint", + str(checkpoint_project), + "--save", + "--mode", + "improve", + "--completed", + "researcher,strategist", + "--pending", + "builder", + "--hypothesis", + "Add caching", + "--completed-hypotheses", + "1,2,3", + ] + ) assert code == 0 loaded = load_checkpoint(checkpoint_project) @@ -307,6 +331,7 @@ def test_load_checkpoint_corrupt_json(checkpoint_project: Path) -> None: def test_load_checkpoint_invalid_schema(checkpoint_project: Path) -> None: """load_checkpoint returns None for valid JSON with invalid schema.""" import json + checkpoint_path = checkpoint_project / ".factory" / "checkpoint.json" checkpoint_path.write_text(json.dumps({"wrong_field": "bad"})) @@ -317,6 +342,7 @@ def test_load_checkpoint_invalid_schema(checkpoint_project: Path) -> None: def test_load_checkpoint_backwards_compat(checkpoint_project: Path) -> None: """load_checkpoint handles old checkpoints without completed_hypotheses.""" import json + checkpoint_path = checkpoint_project / ".factory" / "checkpoint.json" old_data = { "mode": "improve", @@ -333,5 +359,3 @@ def test_load_checkpoint_backwards_compat(checkpoint_project: Path) -> None: assert loaded is not None assert loaded.completed_hypotheses == [] assert loaded.completed_agents == ["researcher"] - - From bc32b5ef8b9f824c886c0ffd71359d7eb0930fd5 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Mon, 27 Jul 2026 13:49:14 -0400 Subject: [PATCH 4/8] fix: update test_cli_resume_with_checkpoint for new cmd_resume behavior Test now creates a CEO session ID and mocks os.execvp/shutil.which, matching the pattern in test_session_resume.py. --- tests/test_checkpoint.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index 65dbafad9..8582be7ee 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -252,20 +252,26 @@ def test_cli_resume_no_checkpoint( def test_cli_resume_with_checkpoint( - checkpoint_project: Path, sample_state: CheckpointState, capsys: pytest.CaptureFixture[str] + checkpoint_project: Path, sample_state: CheckpointState ) -> None: - """factory resume displays resume context.""" + """factory resume resumes the CEO session when a session ID exists.""" + from unittest.mock import patch + + from factory.ceo_completion import write_ceo_session_id + save_checkpoint(checkpoint_project, sample_state) + write_ceo_session_id(checkpoint_project, "ckpt-session-id") from factory.cli import main - code = main(["resume", str(checkpoint_project)]) - assert code == 0 - output = capsys.readouterr().out - assert "Resume Context" in output - assert "improve" in output - assert "builder" in output - assert "health_checker" in output + with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + main(["resume", str(checkpoint_project)]) + + mock_exec.assert_called_once() + call_args = mock_exec.call_args[0] + assert call_args[0] == "claude" + assert "--resume" in call_args[1] + assert "ckpt-session-id" in call_args[1] def test_cli_checkpoint_clear(checkpoint_project: Path, sample_state: CheckpointState) -> None: From ed9f5c8e8089223c0942dbc1f062327a6fca0257 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Mon, 27 Jul 2026 13:57:17 -0400 Subject: [PATCH 5/8] docs: update resume command description in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a45089c86..ee5d77e47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,7 +231,7 @@ factory adversarial-state /path/to/project --reset # Reset to defaults factory dashboard --projects-dir ~/factory-projects # Live web dashboard on :8420 factory export /path/to/project # Dump full project snapshot as JSON factory checkpoint /path/to/project # Save CEO state for crash recovery -factory resume /path/to/project # Resume from saved checkpoint +factory resume /path/to/project # Resume an interrupted CEO session factory precheck /path --score-before 0.7 --score-after 0.85 # Hard precheck gate factory review --verdict KEEP --pr 42 # Post structured review on GitHub PR ``` From 8b0e7444b3d24286e055d4b9d00445941f893e2f Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Mon, 27 Jul 2026 14:11:20 -0400 Subject: [PATCH 6/8] docs: update spec for session resume, parallel config, and superseded verdict Add session threading (session_id, resume_session_id) to AgentRunRequest, CycleState, RunnerMeta, and CEO completion guard. Document ParallelConfig model, CheckpointState parallel fields, superseded verdict, and cmd_resume session resolution. Fix AgentRole enumeration to match source. --- SPEC.md | 67 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/SPEC.md b/SPEC.md index 604e8f60c..2212f2a44 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Behavioral Specification — Remote Factory -> **Revision:** 2026-07-07 · **Status:** Normative · **Notation:** [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) +> **Revision:** 2026-07-27 · **Status:** Normative · **Notation:** [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) --- @@ -149,7 +149,7 @@ factory/models.py ← Foundation: all Pydantic types ├── factory/spec/ │ ├── generate.py ← Batch extraction + annotation pipeline │ └── ops.py ← Validate, scope, update, impact operations - ├── factory/ceo_completion.py ← Completion guard + respawn logic + ├── factory/ceo_completion.py ← Completion guard + respawn logic + session state ├── factory/registry.py ← Global project registry (~/.factory/registry.json) ├── factory/user_config.py ← Five-tier config resolution ├── factory/telemetry.py ← Langfuse tracing (optional) @@ -168,7 +168,7 @@ factory/models.py ← Foundation: all Pydantic types |---|---|---| | **ProjectState** | `no_repo`, `incomplete`, `no_factory`, `evals_pending_review`, `has_factory` | Five-state project lifecycle | | **VerdictType** | `proceed`, `reloop`, `halt` | Gate evaluation outcomes | -| **AgentRole** | `researcher`, `strategist`, `builder`, `qa`, `health_checker`, `code_reviewer`, `adversarial_tester`, `failure_analyst`, `ceo`, `archivist`, `refiner`, `skill_reviewer` | 12 specialist roles | +| **AgentRole** | `researcher`, `strategist`, `builder`, `qa`, `health_checker`, `code_reviewer`, `adversarial_tester`, `failure_analyst`, `ceo`, `archivist`, `refiner`, `profiler`, `refactory` | 13 specialist roles | | **FEECCategory** | `FIX=0`, `EXPLOIT=1`, `EXPLORE=2`, `COMBINE=3` | Hypothesis priority (IntEnum; lower = higher priority) | | **RunStatus** | `PASS`, `FAIL`, `ERROR`, `TIMEOUT` | Research run outcomes | | **AggregateMethod** | `mean`, `median`, `max`, `all_pass` | Multi-run metric aggregation | @@ -179,20 +179,21 @@ All models use `ConfigDict(strict=True, extra="forbid")` — extra fields MUST r | Entity | Key Fields | Invariants | |---|---|---| -| **FactoryConfig** | `goal`, `scope`, `guards`, `eval_command`, `eval_threshold`, `hypothesis_budget`, `research_target`, `mutable_surfaces`, `fixed_surfaces`, `hard_constraints`, `clean_pr`, `eval_spec`, `hygiene_weights`, `growth_weights` | `test_timeout` ≥ 1 (Field ge=1); `research_target` nullable; incomplete research target → `None` not error | +| **FactoryConfig** | `goal`, `scope`, `guards`, `eval_command`, `eval_threshold`, `hypothesis_budget`, `research_target`, `mutable_surfaces`, `fixed_surfaces`, `hard_constraints`, `clean_pr`, `eval_spec`, `hygiene_weights`, `growth_weights`, `parallel` | `test_timeout` ≥ 1 (Field ge=1); `research_target` nullable; `parallel` nullable (`ParallelConfig`); incomplete research target → `None` not error | | **EvalProfile** | `project_type`, `dimensions[]`, `tier`, `confidence`, `human_reviewed` | `human_reviewed` defaults `false`; tier ∈ {explicit, discovered, researched, fallback}; weights MUST sum to 1.0 | | **HypothesisBudget** | `min_growth`, `max_new` | Defaults: `min_growth=2`, `max_new=2` | | **ResearchTarget** | `objective`, `metric`, `target`, `run_command`, `result_path`, `timeout` | `result_parser` MUST be `"json"`; all 4 required fields or `None` | | **InnerLoopConfig** | `runs_per_cycle`, `aggregate`, `plateau_threshold` | `runs_per_cycle` ≥ 1; `aggregate` coerced from string via `@field_validator` | | **HardConstraint** | `name`, `check`, `description` | Shell command; exit 0 = pass; non-zero = mandatory revert | | **EvalWeights** | `hygiene`, `growth`, `project` | Defaults: 0.50, 0.50, 0.0; normalized to sum 1.0 | +| **ParallelConfig** | `parallel_hypotheses`, `selection_strategy` | `parallel_hypotheses` ∈ [1, 8] (Field ge=1, le=8), defaults 1; `selection_strategy` = `"best_score"` | | **TierWeights** | per-dimension weight overrides | Sparse — `None` fields keep defaults | ### §6.3 Experiment Models | Entity | Key Fields | Invariants | |---|---|---| -| **ExperimentRecord** | `id`, `timestamp`, `hypothesis`, `verdict`, `score_before`, `score_after`, `delta`, `cost_usd`, `research_citations` | `verdict` ∈ {keep, revert, error}; `delta` auto-computed on finalize; `research_citations` defaults to `[]` (backward compat) | +| **ExperimentRecord** | `id`, `timestamp`, `hypothesis`, `verdict`, `score_before`, `score_after`, `delta`, `cost_usd`, `research_citations` | `verdict` ∈ {keep, revert, error, superseded}; `delta` auto-computed on finalize; `research_citations` defaults to `[]` (backward compat) | | **CompositeScore** | `total`, `results[]`, `guard_violations`, `passed` | `passed = (no guard_violations) ∧ (total ≥ threshold)` | | **EvalResult** | `name`, `score`, `weight`, `passed`, `details` | Score clamped to [0.0, 1.0] at construction (via `EvalFragment`) | | **CheckResult** | `name`, `passed`, `detail` | Dataclass — outcome of a single precheck | @@ -217,13 +218,13 @@ All models use `ConfigDict(strict=True, extra="forbid")` — extra fields MUST r | Entity | Key Fields | Invariants | |---|---|---| -| **AgentRunRequest** | `prompt`, `task`, `cwd`, `timeout`, `model`, `skip_permissions`, `role`, `extras` | `timeout` defaults 600.0; `extras` carries `tmux_persist`, `background` | +| **AgentRunRequest** | `prompt`, `task`, `cwd`, `timeout`, `model`, `skip_permissions`, `role`, `session_name`, `session_id`, `resume_session_id`, `extras` | `timeout` defaults 600.0; `session_id` and `resume_session_id` nullable (session threading); `extras` carries `tmux_persist`, `background`, `settings_file` | | **AgentRunResult** | `stdout`, `return_code`, `usage`, `metadata` | `usage` nullable (only Claude returns telemetry) | | **AgentUsage** | `input_tokens`, `output_tokens`, `cache_read_tokens`, `total_cost_usd`, `duration_ms`, `num_turns`, `model` | All default 0 | -| **CycleState** | `cycle_id`, `started_at`, `mode`, `initial_prompt`, `respawns`, `runner_name` | `initial_prompt` truncated to ≤1000 chars; staleness at 24h | -| **CheckpointState** | `mode`, `active_experiment_id`, `completed_agents`, `pending_agents`, `last_eval_scores`, `current_hypothesis`, `completed_hypotheses` | `completed_hypotheses` defaults `[]` (backward compat) | +| **CycleState** | `cycle_id`, `started_at`, `mode`, `initial_prompt`, `respawns`, `runner_name`, `claude_session_id` | `initial_prompt` truncated to ≤1000 chars; staleness at 24h; `claude_session_id` nullable (captured from `agent.completed` events for session resume) | +| **CheckpointState** | `mode`, `active_experiment_id`, `active_experiment_ids`, `completed_agents`, `pending_agents`, `last_eval_scores`, `current_hypothesis`, `completed_hypotheses`, `parallel_branch_status`, `plateau_count`, `loop_level` | `completed_hypotheses` defaults `[]` (backward compat); `active_experiment_ids` and `parallel_branch_status` support parallel experiment tracking; `loop_level` ∈ {inner, outer} defaults `"inner"` | | **SessionSummary** | `project_name`, `mode`, `experiments_kept`, `experiments_reverted`, `score_start`, `score_end`, `total_cost_usd` | Strict model — rejects extra fields | -| **RunnerMeta** | `name`, `display_name`, `binary`, `install_hint`, `required_env_vars`, `custom_auth_check` | `is_available()` checks `shutil.which(binary)` | +| **RunnerMeta** | `name`, `display_name`, `binary`, `install_hint`, `required_env_vars`, `supports_session_resume`, `custom_auth_check` | `is_available()` checks `shutil.which(binary)`; `supports_session_resume` defaults `False` (only Claude returns `True`) | ### §6.6 Cross-Project Models @@ -270,6 +271,7 @@ store.init() → store.begin(hypothesis) → [exp_id allocated, FileLock] - `finalize()` MUST auto-create experiment dir if deleted (crash resilience) - `finalize()` MUST compute `delta = score_after - score_before` when `delta is None` - `load_history()` MUST handle missing `research_citations` column (backward compat) +- Valid verdict values: `keep`, `revert`, `error`, `superseded` - Invalid verdict values MUST be coerced to `"error"` ### §7.3 Workflow Execution @@ -302,14 +304,16 @@ WorkflowExecutor.execute() → run_with_completion_guard() → check existing cycle_state → restore mode + runner OR create new CycleState → persist to cycle.json - → invoke CEO → check exit code + → invoke CEO (with session_id on first spawn) → check exit code + → _extract_session_id() → capture claude session_id from agent.completed event + → persist session_id to CycleState.claude_session_id → user interrupt (signal >128) → preserve cycle state, return - → explicit ABORT event → delete cycle state, return + → explicit ABORT event → delete cycle state + session state, return → _detect_incomplete(): improve/research/meta: verdict_count < hypothesis_count → incomplete build: phase_count < total_phases → incomplete discover: no eval_profile.json → incomplete - → if incomplete: _build_continuation_task → respawn (max 5) + → if incomplete: _build_continuation_task → respawn with resume_session_id (max 5) → if cap hit: write cycle-incomplete.md, return error ``` @@ -320,6 +324,29 @@ run_with_completion_guard() → - Continuation tasks MUST include `## CRITICAL: Mode Override` section with `cycle_id` - Each respawn MUST emit `ceo.respawn` event with `cycle_id` and `mode` - `_count_verdicts` MUST use `since_ts` parameter to scope to current cycle only +- Session ID MUST be captured from `agent.completed` events after each CEO spawn | MUST | +- Respawns MUST use `resume_session_id` (not `session_id`) to continue the Claude session | MUST | +- `delete_cycle_state` MUST also delete `.factory/state/session.json` | MUST | + +#### §7.4.1 CEO Session State Persistence + +``` +write_ceo_session_id(project_path, session_id) → + persist to .factory/state/session.json + {session_id, created: ISO timestamp} + +read_ceo_session_id(project_path) → + read .factory/state/session.json → return session_id or None + missing/corrupt → None + +_extract_session_id(project_path) → + scan events.jsonl backwards for agent.completed where agent=ceo + return data.session_id from first match, or None +``` + +- `cmd_ceo` MUST generate a UUID session ID and write it via `write_ceo_session_id` before spawning the CEO | MUST | +- `cmd_resume` MUST check `CycleState.claude_session_id` first, then fall back to `read_ceo_session_id` | MUST | +- `cmd_resume` MUST use `claude --resume ` to resume the session | MUST | ### §7.5 Precheck Gate (Non-Overridable) @@ -472,6 +499,7 @@ check_ceilings(project_path, cycle_start): | `finalize` computes delta when not pre-set | MUST | | `finalize` auto-creates experiment dir if deleted | MUST | | `load_history` handles missing `research_citations` column | MUST | +| `load_history` MUST accept `"superseded"` as a valid verdict value | MUST | | `read_config` uses `strict=False` for enum coercion from JSON | MUST | | `reparse_config` parses `factory.md` sections, HTML comments, code blocks, list continuations | MUST | | `reparse_config`: incomplete research target → `None` (not crash) | MUST | @@ -543,6 +571,8 @@ check_ceilings(project_path, cycle_start): | Auto-generate numeric review tags for duplicate roles in parallel invocations | MUST | | Event emissions MUST be swallowed on error (never block agent invocation) | MUST | | Telemetry spans MUST be swallowed on error | MUST | +| Pass `session_id` and `resume_session_id` through to `AgentRunRequest` for session threading | MUST | +| Emit `session_id` from agent metadata in `agent.completed` event data | SHOULD | ### §8.8 `factory/workflow/primitives.py` — Workflow Primitives @@ -596,7 +626,7 @@ check_ceilings(project_path, cycle_start): | Resolution order: explicit name → `FACTORY_RUNNER` env var → `"claude"` | MUST | | Each runner implements `headless() → AgentRunResult` | MUST | | Only Claude returns `usage` telemetry; others `usage=None` | MUST | -| Only Claude has `supports_background=True` | MUST | +| Only Claude has `supports_background=True` and `supports_session_resume=True` | MUST | | Bob Shell ceiling enforcement via `check_ceilings()` using cycle `started_at` | MUST | | Bob ceiling uses `started_at` from `cycle.json`, not `now()` | MUST | | Bob `sanitize=True` (strips ANSI from dest, keeps raw in buffer) | MUST | @@ -605,6 +635,8 @@ check_ceilings(project_path, cycle_start): | Dry-run modes: `FACTORY_BOB_DRY_RUN`, `FACTORY_CODEX_DRY_RUN`, `FACTORY_OPENCODE_DRY_RUN` | MUST | | Inactivity watchdog kills silent processes; genuine blank lines preserved | MUST | | 1MB readline limit on subprocess output | SHOULD | +| Claude `build_command`: `--resume` flag when `resume_session_id` set; `--session-id` when `session_id` set (mutually exclusive, resume takes precedence) | MUST | +| Claude `build_interactive_command`: same `--resume`/`--session-id` flag logic; persists CEO prompt to `.claude/CLAUDE.md` and `disallowedTools` to `.claude/settings.local.json` for session resilience | MUST | | Plugin discovery via `entry_points("factory.runners")` — lazy, once-per-process | SHOULD | ### §8.12 `factory/registry.py` — Global Project Registry @@ -674,7 +706,7 @@ async def headless(request: AgentRunRequest) -> AgentRunResult def interactive_run(request: AgentRunRequest) -> int ``` -`RunnerMeta` describes capabilities: `is_available()` checks `shutil.which(binary)`; `check_auth()` validates credentials. +`RunnerMeta` describes capabilities: `is_available()` checks `shutil.which(binary)`; `check_auth()` validates credentials; `supports_session_resume` declares whether `--resume` flag is supported. ### §9.5 Notifier Protocol @@ -750,6 +782,8 @@ ANTHROPIC_API_KEY = "sk-ant-..." | Configuration | `config show`, `config edit`, `config migrate` | | Validation & Recovery | `checkpoint`, `resume`, `baseline`, `precheck`, `guard`, `review`, `spec` | +`resume` checks `CycleState.claude_session_id` (headless mid-cycle interrupt) then `.factory/state/session.json` (any CEO run), and invokes `claude --resume `. Accepts optional `--model` override. + ### §11.2 Mode Dispatch Rules | Mode | Preconditions | Rejects | @@ -762,6 +796,7 @@ ANTHROPIC_API_KEY = "sk-ant-..." | `qa`/`deep-qa` | Existing directory + `--pr` | Missing `--pr` | | `refine` | Existing directory | `--mode`, `--prompt`, `--focus` (mutually exclusive) | | `create` | Any + `--focus` (mode description) | — | +| `parallel-improve` | `HAS_FACTORY` + `parallel` config | — | | `auto` | Default; auto-detects | — | --- @@ -848,6 +883,10 @@ ANTHROPIC_API_KEY = "sk-ant-..." | 18 | ANSI sanitization: genuine blank lines preserved; redraw-only lines dropped | `_stream.py` | | 19 | Review file convention: `[-]-latest.md`; parallel auto-tags | `_save_review` | | 20 | Config parsing: incomplete research target → `None` (not crash) | `reparse_config` | +| 21 | Session resume: `CycleState.claude_session_id` captured from events, used for `--resume` on respawn | `ceo_completion.py` | +| 22 | `delete_cycle_state` cleans both `cycle.json` and `session.json` | `ceo_completion.py` | +| 23 | `cmd_resume` checks cycle state first, then session file, then errors | `cli/infra.py` | +| 24 | Checkpoint backward compat: missing `active_experiment_ids`, `parallel_branch_status` → `[]`/`{}` | `load_checkpoint` | ### §14.2 Test Infrastructure From 5f5ae6b1380d7040057e47767d1efde55305de14 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Tue, 28 Jul 2026 09:56:47 -0400 Subject: [PATCH 7/8] feat: auto-continue factory resume based on session mode Store interactive/mode metadata in session.json at CEO launch so cmd_resume can inject a continuation prompt for headless sessions while leaving interactive sessions with a bare --resume. --- factory/ceo_completion.py | 29 ++++++- factory/cli/ceo.py | 2 +- factory/cli/infra.py | 61 ++++++++++++-- tests/test_session_resume.py | 150 +++++++++++++++++++++++++++++++---- 4 files changed, 216 insertions(+), 26 deletions(-) diff --git a/factory/ceo_completion.py b/factory/ceo_completion.py index 8f2fda71c..7436e7130 100644 --- a/factory/ceo_completion.py +++ b/factory/ceo_completion.py @@ -53,16 +53,39 @@ def read_ceo_session_id(project_path: Path) -> str | None: return None -def write_ceo_session_id(project_path: Path, session_id: str) -> None: - """Write a CEO session ID to .factory/state/session.json.""" +def read_ceo_session(project_path: Path) -> dict | None: + """Read the full CEO session metadata from .factory/state/session.json. + + Returns dict with keys: session_id, created, interactive, mode. + Returns None if the file doesn't exist or is malformed. + """ + path = _session_state_path(project_path) + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, ValueError): + return None + + +def write_ceo_session_id( + project_path: Path, + session_id: str, + *, + interactive: bool = False, + mode: str = "", +) -> None: + """Write a CEO session ID and metadata to .factory/state/session.json.""" path = _session_state_path(project_path) path.parent.mkdir(parents=True, exist_ok=True) data = { "session_id": session_id, "created": datetime.now(timezone.utc).isoformat(), + "interactive": interactive, + "mode": mode, } path.write_text(json.dumps(data, indent=2)) - log.info("ceo_session_id_written", session_id=session_id) + log.info("ceo_session_id_written", session_id=session_id, interactive=interactive, mode=mode) def read_cycle_state(project_path: Path) -> CycleState | None: diff --git a/factory/cli/ceo.py b/factory/cli/ceo.py index 3d907cfe1..aef5a1f2b 100644 --- a/factory/cli/ceo.py +++ b/factory/cli/ceo.py @@ -593,7 +593,7 @@ def cmd_ceo(args: argparse.Namespace) -> int: from factory.ceo_completion import write_ceo_session_id ceo_session_id = str(_uuid.uuid4()) - write_ceo_session_id(wt_path, ceo_session_id) + write_ceo_session_id(wt_path, ceo_session_id, interactive=interactive, mode=mode) if headless: # Non-interactive pipe mode (for scripting, cron, tmux) diff --git a/factory/cli/infra.py b/factory/cli/infra.py index 5410324f5..16fbde990 100644 --- a/factory/cli/infra.py +++ b/factory/cli/infra.py @@ -131,16 +131,22 @@ def cmd_resume(args: argparse.Namespace) -> int: Checks two sources for a session ID: 1. CycleState.claude_session_id (headless run interrupted mid-cycle) 2. .factory/state/session.json (any CEO run) + + For headless sessions, injects a continuation prompt so the CEO + auto-continues from where it left off. Interactive sessions get a bare + resume (the user drives the conversation). """ import os import shutil + import tempfile - from factory.ceo_completion import read_ceo_session_id, read_cycle_state + from factory.ceo_completion import read_ceo_session, read_cycle_state project_path = Path(args.path).resolve() model = getattr(args, "model", None) session_id: str | None = None + session_meta: dict | None = None cycle_state = read_cycle_state(project_path) if cycle_state and cycle_state.claude_session_id: @@ -148,9 +154,11 @@ def cmd_resume(args: argparse.Namespace) -> int: log.info("resume_from_cycle_state", session_id=session_id) if not session_id: - session_id = read_ceo_session_id(project_path) - if session_id: - log.info("resume_from_session_file", session_id=session_id) + session_meta = read_ceo_session(project_path) + if session_meta: + session_id = session_meta.get("session_id") + if session_id: + log.info("resume_from_session_file", session_id=session_id) if not session_id: print("No CEO session found to resume.", file=sys.stderr) @@ -162,11 +170,54 @@ def cmd_resume(args: argparse.Namespace) -> int: print("Error: 'claude' CLI not found. Install Claude Code first.", file=sys.stderr) return 1 + interactive = True + resume_mode = "" + if session_meta: + interactive = session_meta.get("interactive", True) + resume_mode = session_meta.get("mode", "") + if cycle_state: + interactive = False + resume_mode = cycle_state.mode + cmd = ["claude", "--resume", session_id] if model: cmd.extend(["--model", model]) - print(f"Resuming CEO session: {session_id[:12]}...") + if not interactive: + from factory.agents.runner import resolve_prompt + + prompt_text = resolve_prompt("ceo", project_path, workflow_mode=resume_mode or None) + prompt_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".md", prefix="factory-resume-prompt-", delete=False + ) + prompt_file.write(prompt_text) + prompt_file.close() + + continuation = ( + "You were interrupted mid-cycle. Resume from where you left off.\n" + "Read .factory/strategy/current.md and .factory/state/cycle.json " + "to determine your current phase.\n" + "Continue executing the remaining planned work. " + "Do not restart completed phases." + ) + cmd.extend( + [ + "-p", + continuation, + "--append-system-prompt-file", + prompt_file.name, + "--output-format", + "stream-json", + "--verbose", + "--disallowedTools", + "Agent", + ] + ) + log.info("resume_headless", mode=resume_mode, prompt_file=prompt_file.name) + print(f"Resuming headless CEO session ({resume_mode}): {session_id[:12]}...") + else: + print(f"Resuming interactive CEO session: {session_id[:12]}...") + os.chdir(project_path) os.execvp("claude", cmd) return 0 diff --git a/tests/test_session_resume.py b/tests/test_session_resume.py index 82be67a59..557aa8cb7 100644 --- a/tests/test_session_resume.py +++ b/tests/test_session_resume.py @@ -253,7 +253,7 @@ def test_no_flags_when_none(self, tmp_path: Path) -> None: class TestSessionPersistence: - """Tests for read_ceo_session_id and write_ceo_session_id.""" + """Tests for read_ceo_session_id, read_ceo_session, and write_ceo_session_id.""" def test_write_and_read(self, tmp_path: Path) -> None: from factory.ceo_completion import read_ceo_session_id, write_ceo_session_id @@ -268,7 +268,7 @@ def test_read_nonexistent(self, tmp_path: Path) -> None: assert read_ceo_session_id(tmp_path) is None def test_read_malformed(self, tmp_path: Path) -> None: - from factory.ceo_completion import read_ceo_session_id, _session_state_path + from factory.ceo_completion import _session_state_path, read_ceo_session_id path = _session_state_path(tmp_path) path.parent.mkdir(parents=True, exist_ok=True) @@ -277,7 +277,7 @@ def test_read_malformed(self, tmp_path: Path) -> None: assert read_ceo_session_id(tmp_path) is None def test_write_creates_directory(self, tmp_path: Path) -> None: - from factory.ceo_completion import write_ceo_session_id, _session_state_path + from factory.ceo_completion import _session_state_path, write_ceo_session_id write_ceo_session_id(tmp_path, "sid-abc") path = _session_state_path(tmp_path) @@ -287,6 +287,49 @@ def test_write_creates_directory(self, tmp_path: Path) -> None: assert data["session_id"] == "sid-abc" assert "created" in data + def test_write_stores_metadata(self, tmp_path: Path) -> None: + from factory.ceo_completion import _session_state_path, write_ceo_session_id + + write_ceo_session_id(tmp_path, "sid-meta", interactive=True, mode="design") + path = _session_state_path(tmp_path) + data = json.loads(path.read_text()) + assert data["session_id"] == "sid-meta" + assert data["interactive"] is True + assert data["mode"] == "design" + + def test_write_defaults_metadata(self, tmp_path: Path) -> None: + from factory.ceo_completion import _session_state_path, write_ceo_session_id + + write_ceo_session_id(tmp_path, "sid-defaults") + path = _session_state_path(tmp_path) + data = json.loads(path.read_text()) + assert data["interactive"] is False + assert data["mode"] == "" + + def test_read_ceo_session_full(self, tmp_path: Path) -> None: + from factory.ceo_completion import read_ceo_session, write_ceo_session_id + + write_ceo_session_id(tmp_path, "sid-full", interactive=False, mode="improve") + result = read_ceo_session(tmp_path) + assert result is not None + assert result["session_id"] == "sid-full" + assert result["interactive"] is False + assert result["mode"] == "improve" + assert "created" in result + + def test_read_ceo_session_nonexistent(self, tmp_path: Path) -> None: + from factory.ceo_completion import read_ceo_session + + assert read_ceo_session(tmp_path) is None + + def test_read_ceo_session_malformed(self, tmp_path: Path) -> None: + from factory.ceo_completion import _session_state_path, read_ceo_session + + path = _session_state_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not json") + assert read_ceo_session(tmp_path) is None + def test_delete_cycle_state_also_deletes_session(self, tmp_path: Path) -> None: from factory.ceo_completion import ( create_cycle_state, @@ -430,7 +473,8 @@ async def mock_invoke(role, task, path, **kwargs): class TestCmdResume: """Tests for the factory resume command.""" - def test_resume_from_cycle_state(self, tmp_path: Path) -> None: + def test_resume_from_cycle_state_is_headless(self, tmp_path: Path) -> None: + """CycleState presence means headless — should include -p and continuation prompt.""" from factory.ceo_completion import create_cycle_state, write_cycle_state state = create_cycle_state("improve") @@ -441,7 +485,11 @@ def test_resume_from_cycle_state(self, tmp_path: Path) -> None: args = argparse.Namespace(path=str(tmp_path), model=None) - with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + with ( + patch("os.execvp") as mock_exec, + patch("shutil.which", return_value="/usr/bin/claude"), + patch("factory.agents.runner.resolve_prompt", return_value="# CEO prompt"), + ): from factory.cli.infra import cmd_resume cmd_resume(args) @@ -449,27 +497,65 @@ def test_resume_from_cycle_state(self, tmp_path: Path) -> None: mock_exec.assert_called_once() call_args = mock_exec.call_args[0] assert call_args[0] == "claude" - assert "--resume" in call_args[1] - assert "cycle-session-id" in call_args[1] + cmd_list = call_args[1] + assert "--resume" in cmd_list + assert "cycle-session-id" in cmd_list + assert "-p" in cmd_list + assert "--disallowedTools" in cmd_list + + def test_resume_interactive_session_no_continuation(self, tmp_path: Path) -> None: + """Interactive sessions get a bare resume — no -p flag.""" + from factory.ceo_completion import write_ceo_session_id + + write_ceo_session_id(tmp_path, "interactive-sid", interactive=True, mode="design") + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + with ( + patch("os.execvp") as mock_exec, + patch("shutil.which", return_value="/usr/bin/claude"), + ): + from factory.cli.infra import cmd_resume + + cmd_resume(args) + + mock_exec.assert_called_once() + call_args = mock_exec.call_args[0] + cmd_list = call_args[1] + assert "--resume" in cmd_list + assert "interactive-sid" in cmd_list + assert "-p" not in cmd_list + assert "--disallowedTools" not in cmd_list - def test_resume_from_session_file(self, tmp_path: Path) -> None: + def test_resume_headless_session_has_continuation(self, tmp_path: Path) -> None: + """Headless sessions from session.json get a continuation prompt.""" from factory.ceo_completion import write_ceo_session_id - write_ceo_session_id(tmp_path, "file-session-id") + write_ceo_session_id(tmp_path, "headless-sid", interactive=False, mode="improve") import argparse args = argparse.Namespace(path=str(tmp_path), model=None) - with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + with ( + patch("os.execvp") as mock_exec, + patch("shutil.which", return_value="/usr/bin/claude"), + patch("factory.agents.runner.resolve_prompt", return_value="# CEO prompt"), + ): from factory.cli.infra import cmd_resume cmd_resume(args) mock_exec.assert_called_once() call_args = mock_exec.call_args[0] - assert "--resume" in call_args[1] - assert "file-session-id" in call_args[1] + cmd_list = call_args[1] + assert "-p" in cmd_list + p_idx = cmd_list.index("-p") + assert "Resume from where you left off" in cmd_list[p_idx + 1] + assert "--append-system-prompt-file" in cmd_list + assert "--disallowedTools" in cmd_list def test_resume_prefers_cycle_state(self, tmp_path: Path) -> None: """CycleState.claude_session_id takes precedence over session.json.""" @@ -482,19 +568,25 @@ def test_resume_prefers_cycle_state(self, tmp_path: Path) -> None: state = create_cycle_state("improve") state.claude_session_id = "cycle-sid" write_cycle_state(tmp_path, state) - write_ceo_session_id(tmp_path, "file-sid") + write_ceo_session_id(tmp_path, "file-sid", interactive=True, mode="design") import argparse args = argparse.Namespace(path=str(tmp_path), model=None) - with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + with ( + patch("os.execvp") as mock_exec, + patch("shutil.which", return_value="/usr/bin/claude"), + patch("factory.agents.runner.resolve_prompt", return_value="# CEO prompt"), + ): from factory.cli.infra import cmd_resume cmd_resume(args) call_args = mock_exec.call_args[0] - assert "cycle-sid" in call_args[1] + cmd_list = call_args[1] + assert "cycle-sid" in cmd_list + assert "-p" in cmd_list def test_resume_no_session_found(self, tmp_path: Path) -> None: import argparse @@ -509,13 +601,16 @@ def test_resume_no_session_found(self, tmp_path: Path) -> None: def test_resume_with_model(self, tmp_path: Path) -> None: from factory.ceo_completion import write_ceo_session_id - write_ceo_session_id(tmp_path, "model-test-sid") + write_ceo_session_id(tmp_path, "model-test-sid", interactive=True, mode="design") import argparse args = argparse.Namespace(path=str(tmp_path), model="claude-opus-4-7") - with patch("os.execvp") as mock_exec, patch("shutil.which", return_value="/usr/bin/claude"): + with ( + patch("os.execvp") as mock_exec, + patch("shutil.which", return_value="/usr/bin/claude"), + ): from factory.cli.infra import cmd_resume cmd_resume(args) @@ -540,3 +635,24 @@ def test_resume_no_claude_binary(self, tmp_path: Path) -> None: code = cmd_resume(args) assert code == 1 + + def test_resume_resolve_prompt_called_with_mode(self, tmp_path: Path) -> None: + """Headless resume passes the correct workflow_mode to resolve_prompt.""" + from factory.ceo_completion import write_ceo_session_id + + write_ceo_session_id(tmp_path, "mode-sid", interactive=False, mode="research") + + import argparse + + args = argparse.Namespace(path=str(tmp_path), model=None) + + with ( + patch("os.execvp"), + patch("shutil.which", return_value="/usr/bin/claude"), + patch("factory.agents.runner.resolve_prompt", return_value="# prompt") as mock_resolve, + ): + from factory.cli.infra import cmd_resume + + cmd_resume(args) + + mock_resolve.assert_called_once_with("ceo", tmp_path, workflow_mode="research") From 094929de4ca7df18fa8ca97f6f7dece41e01f5c8 Mon Sep 17 00:00:00 2001 From: Mihir Athale Date: Tue, 28 Jul 2026 10:07:43 -0400 Subject: [PATCH 8/8] feat: print session ID and resume instructions on CEO exit When a CEO session exits without completing (crash, Ctrl+C, respawn cap hit), print the session ID and resume command to stderr so users can manually resume. Skips printing when the cycle completed cleanly since the session state is already cleaned up. --- factory/ceo_completion.py | 17 +++ factory/cli/ceo.py | 6 + tests/test_ceo_completion.py | 279 ++++++++++++++++++++++++++++------- 3 files changed, 249 insertions(+), 53 deletions(-) diff --git a/factory/ceo_completion.py b/factory/ceo_completion.py index 7436e7130..fe6fd5aaf 100644 --- a/factory/ceo_completion.py +++ b/factory/ceo_completion.py @@ -156,6 +156,20 @@ def delete_cycle_state(project_path: Path) -> bool: return deleted +def print_resume_hint(project_path: Path) -> None: + """Print session ID and resume instructions to stderr if the session is still active. + + Only prints when session.json still exists — if delete_cycle_state() already + cleaned it up (cycle completed normally), this is a no-op. + """ + import sys + + sid = read_ceo_session_id(project_path) + if sid: + print(f"\nSession: {sid}", file=sys.stderr) + print(f"Resume with: factory resume {project_path}", file=sys.stderr) + + def create_cycle_state( mode: str, initial_prompt: str = "", runner_name: str | None = None ) -> CycleState: @@ -591,6 +605,7 @@ async def run_ceo_with_completion_guard( # User interrupt — respect it (but don't delete cycle state for later resume) if code in (130, 143) or code > 128: log.info("ceo_user_interrupt", code=code) + print_resume_hint(project_path) return result, code # Explicit ABORT — respect it and clean up cycle state @@ -612,6 +627,7 @@ async def run_ceo_with_completion_guard( if not _budget_allows_respawn(runner_name, project_path): log.warning("ceo_budget_exceeded", gap=gap) _write_cycle_incomplete(project_path, gap, "budget_exceeded") + print_resume_hint(project_path) return result, 1 # Update cycle state with incremented respawn count @@ -643,4 +659,5 @@ async def run_ceo_with_completion_guard( log.warning("ceo_respawn_cap_hit", attempts=max_respawns + 1, gap=gap) _write_cycle_incomplete(project_path, gap, "respawn_cap_hit") + print_resume_hint(project_path) return final_output, 1 diff --git a/factory/cli/ceo.py b/factory/cli/ceo.py index aef5a1f2b..441ee57eb 100644 --- a/factory/cli/ceo.py +++ b/factory/cli/ceo.py @@ -642,6 +642,9 @@ def cmd_ceo(args: argparse.Namespace) -> int: finally: _stop_ceo_tailer(ceo_tailer) complete_cycle_session(project_path, cycle_span_id) + from factory.ceo_completion import print_resume_hint + + print_resume_hint(project_path) if not no_worktree: assert wt_branch is not None remove_worktree(project_path, wt_path, wt_branch) @@ -681,6 +684,9 @@ def cmd_ceo(args: argparse.Namespace) -> int: finally: _stop_ceo_tailer(ceo_tailer) complete_cycle_session(project_path, cycle_span_id) + from factory.ceo_completion import print_resume_hint + + print_resume_hint(project_path) if not no_worktree: assert wt_branch is not None remove_worktree(project_path, wt_path, wt_branch) diff --git a/tests/test_ceo_completion.py b/tests/test_ceo_completion.py index 2b70e83d6..876a1359b 100644 --- a/tests/test_ceo_completion.py +++ b/tests/test_ceo_completion.py @@ -247,11 +247,14 @@ def test_counts_all_verdicts_when_no_since_ts(self, tmp_path: Path) -> None: """Without since_ts, all verdicts are counted.""" from factory.ceo_completion import _count_verdicts - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "revert"}, - {"id": "3", "timestamp": "2026-04-28T12:00:00+00:00", "verdict": "keep"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "revert"}, + {"id": "3", "timestamp": "2026-04-28T12:00:00+00:00", "verdict": "keep"}, + ], + ) count = _count_verdicts(tmp_path) assert count == 3 @@ -261,11 +264,14 @@ def test_filters_by_since_ts(self, tmp_path: Path) -> None: from factory.ceo_completion import _count_verdicts # Two old rows from a previous cycle, one new row from current cycle - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "revert"}, - {"id": "3", "timestamp": "2026-04-29T14:00:00+00:00", "verdict": "keep"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "revert"}, + {"id": "3", "timestamp": "2026-04-29T14:00:00+00:00", "verdict": "keep"}, + ], + ) # Filter to only count after noon on Apr 29 since = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc) @@ -276,11 +282,14 @@ def test_ignores_pending_verdicts(self, tmp_path: Path) -> None: """Rows without keep/revert/error verdict are not counted.""" from factory.ceo_completion import _count_verdicts - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "pending"}, - {"id": "3", "timestamp": "2026-04-28T12:00:00+00:00", "verdict": ""}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "pending"}, + {"id": "3", "timestamp": "2026-04-28T12:00:00+00:00", "verdict": ""}, + ], + ) count = _count_verdicts(tmp_path) assert count == 1 @@ -289,10 +298,13 @@ def test_handles_error_verdict(self, tmp_path: Path) -> None: """Error verdicts are counted (they are finalized experiments).""" from factory.ceo_completion import _count_verdicts - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "error"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "error"}, + ], + ) count = _count_verdicts(tmp_path) assert count == 2 @@ -301,10 +313,13 @@ def test_handles_naive_timestamps(self, tmp_path: Path) -> None: """Timestamps without timezone are treated as UTC.""" from factory.ceo_completion import _count_verdicts - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T10:00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-29T14:00:00", "verdict": "keep"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T10:00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-29T14:00:00", "verdict": "keep"}, + ], + ) since = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc) count = _count_verdicts(tmp_path, since_ts=since) @@ -316,15 +331,18 @@ def test_cross_cycle_scenario(self, tmp_path: Path) -> None: # Old cycle started at 2026-04-28T08:00:00 # Current cycle started at 2026-04-29T10:00:00 - self._write_results_tsv(tmp_path, [ - # Old cycle experiments - {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "revert"}, - {"id": "3", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "keep"}, - # Current cycle experiments - {"id": "4", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, - {"id": "5", "timestamp": "2026-04-29T12:00:00+00:00", "verdict": "revert"}, - ]) + self._write_results_tsv( + tmp_path, + [ + # Old cycle experiments + {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "revert"}, + {"id": "3", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "keep"}, + # Current cycle experiments + {"id": "4", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, + {"id": "5", "timestamp": "2026-04-29T12:00:00+00:00", "verdict": "revert"}, + ], + ) # Current cycle started at 10:00 on Apr 29 current_cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) @@ -368,12 +386,15 @@ def test_improve_filters_by_cycle_start(self, tmp_path: Path) -> None: ) # 3 old verdicts from previous cycle, 1 from current cycle - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, - {"id": "3", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "keep"}, - {"id": "4", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, + {"id": "3", "timestamp": "2026-04-28T11:00:00+00:00", "verdict": "keep"}, + {"id": "4", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, + ], + ) # Current cycle started at 10:00 on Apr 29 — only 1 verdict should count cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) @@ -397,11 +418,14 @@ def test_improve_complete_with_cycle_filtering(self, tmp_path: Path) -> None: ) # 1 old verdict, 2 current-cycle verdicts - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, - {"id": "3", "timestamp": "2026-04-29T12:00:00+00:00", "verdict": "revert"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, + {"id": "3", "timestamp": "2026-04-29T12:00:00+00:00", "verdict": "revert"}, + ], + ) cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) gap = _detect_incomplete(tmp_path, "improve", cycle_started_at=cycle_start) @@ -421,11 +445,14 @@ def test_build_filters_by_cycle_start(self, tmp_path: Path) -> None: ) # 2 old verdicts, 1 current - self._write_results_tsv(tmp_path, [ - {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, - {"id": "2", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, - {"id": "3", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, - ]) + self._write_results_tsv( + tmp_path, + [ + {"id": "1", "timestamp": "2026-04-28T09:00:00+00:00", "verdict": "keep"}, + {"id": "2", "timestamp": "2026-04-28T10:00:00+00:00", "verdict": "keep"}, + {"id": "3", "timestamp": "2026-04-29T11:00:00+00:00", "verdict": "keep"}, + ], + ) cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) gap = _detect_incomplete(tmp_path, "build", cycle_started_at=cycle_start) @@ -563,7 +590,11 @@ def test_research_continuation(self) -> None: def test_continuation_includes_mode_directive(self) -> None: """Continuation task includes explicit mode directive to prevent flip.""" - from factory.ceo_completion import _build_continuation_task, IncompleteGap, create_cycle_state + from factory.ceo_completion import ( + _build_continuation_task, + IncompleteGap, + create_cycle_state, + ) gap = IncompleteGap( mode="build", @@ -1117,10 +1148,12 @@ def test_eval_weight_split(self, ceo_prompt: str) -> None: def test_monotonic_improvement_policy(self, research_skill: str) -> None: """Research skill or definitions reference monotonic improvement.""" from factory.workflow.definitions import register_all + wfs = register_all() research_wf = wfs["research"] node_prompts = " ".join( - n.prompt_template for n in research_wf.nodes.values() + n.prompt_template + for n in research_wf.nodes.values() if hasattr(n, "prompt_template") and n.prompt_template ) assert "previous" in node_prompts.lower() or "baseline" in node_prompts.lower() @@ -1128,6 +1161,7 @@ def test_monotonic_improvement_policy(self, research_skill: str) -> None: def test_termination_conditions(self, research_skill: str) -> None: """Research workflow has evaluator and gate nodes for verdict.""" from factory.workflow.definitions import register_all + wfs = register_all() research_wf = wfs["research"] gate_ids = [nid for nid, n in research_wf.nodes.items() if hasattr(n, "evaluator_type")] @@ -1149,14 +1183,17 @@ def test_research_mode_in_cycle_completion(self, ceo_prompt: str) -> None: def test_leakage_guards_in_research_mode(self, research_skill: str) -> None: """Research workflow includes leakage-related concepts.""" from factory.workflow.definitions import register_all + wfs = register_all() research_wf = wfs["research"] gate_prompts = " ".join( - n.gate_prompt for n in research_wf.nodes.values() + n.gate_prompt + for n in research_wf.nodes.values() if hasattr(n, "gate_prompt") and n.gate_prompt ) node_prompts = " ".join( - n.prompt_template for n in research_wf.nodes.values() + n.prompt_template + for n in research_wf.nodes.values() if hasattr(n, "prompt_template") and n.prompt_template ) combined = gate_prompts + node_prompts @@ -1194,7 +1231,9 @@ async def test_background_bypasses_respawn_loop(self, tmp_path: Path) -> None: return_value=("bg output", 0), ) as mock_invoke: stdout, code = await run_ceo_with_completion_guard( - tmp_path, "initial task", mode="improve", + tmp_path, + "initial task", + mode="improve", background=True, ) @@ -1203,3 +1242,137 @@ async def test_background_bypasses_respawn_loop(self, tmp_path: Path) -> None: mock_invoke.assert_called_once() call_kwargs = mock_invoke.call_args.kwargs assert call_kwargs["background"] is True + + +class TestPrintResumeHint: + """Tests for print_resume_hint().""" + + def test_prints_hint_when_session_exists( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """Resume hint is printed to stderr when session.json exists.""" + from factory.ceo_completion import print_resume_hint, write_ceo_session_id + + write_ceo_session_id(tmp_path, "abc-123", mode="improve") + print_resume_hint(tmp_path) + + captured = capsys.readouterr() + assert "Session: abc-123" in captured.err + assert f"Resume with: factory resume {tmp_path}" in captured.err + + def test_no_hint_when_session_cleaned_up( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """No resume hint when session.json was deleted (cycle completed).""" + from factory.ceo_completion import ( + delete_cycle_state, + print_resume_hint, + write_ceo_session_id, + ) + + write_ceo_session_id(tmp_path, "abc-123", mode="improve") + delete_cycle_state(tmp_path) + print_resume_hint(tmp_path) + + captured = capsys.readouterr() + assert "Session:" not in captured.err + assert "Resume with:" not in captured.err + + def test_no_hint_when_no_session_file( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """No resume hint when session.json never existed.""" + from factory.ceo_completion import print_resume_hint + + print_resume_hint(tmp_path) + + captured = capsys.readouterr() + assert captured.err == "" + + +class TestResumeHintInCompletionGuard: + """Tests for resume hint printing in run_ceo_with_completion_guard.""" + + @pytest.fixture(autouse=True) + def enable_respawn(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FACTORY_CEO_RESPAWN_DISABLED", raising=False) + + async def test_hint_printed_on_respawn_cap_hit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ) -> None: + """Resume hint is printed when respawn cap is exhausted.""" + from factory.ceo_completion import run_ceo_with_completion_guard, write_ceo_session_id + + strategy_dir = tmp_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True) + (strategy_dir / "current.md").write_text("#### H1: A\n") + (tmp_path / ".factory" / "experiments").mkdir() + + write_ceo_session_id(tmp_path, "test-session-id", mode="improve") + mock_invoke = AsyncMock(return_value=("Incomplete", 0)) + + with patch("factory.agents.runner.invoke_agent", mock_invoke): + await run_ceo_with_completion_guard( + tmp_path, + "Initial task", + mode="improve", + runner_name="claude", + max_respawns=0, + ) + + captured = capsys.readouterr() + assert "Session: test-session-id" in captured.err + assert f"Resume with: factory resume {tmp_path}" in captured.err + + async def test_no_hint_on_clean_completion( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ) -> None: + """No resume hint when cycle completes successfully.""" + from factory.ceo_completion import run_ceo_with_completion_guard, write_ceo_session_id + + strategy_dir = tmp_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True) + (strategy_dir / "current.md").write_text("#### H1: A\n") + exp_dir = tmp_path / ".factory" / "experiments" / "001" + exp_dir.mkdir(parents=True) + (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') + + write_ceo_session_id(tmp_path, "test-session-id", mode="improve") + mock_invoke = AsyncMock(return_value=("Done", 0)) + + with patch("factory.agents.runner.invoke_agent", mock_invoke): + await run_ceo_with_completion_guard( + tmp_path, + "Initial task", + mode="improve", + runner_name="claude", + ) + + captured = capsys.readouterr() + assert "Session:" not in captured.err + + async def test_hint_printed_on_user_interrupt( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ) -> None: + """Resume hint is printed when user interrupts with Ctrl+C.""" + from factory.ceo_completion import run_ceo_with_completion_guard, write_ceo_session_id + + strategy_dir = tmp_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True) + (strategy_dir / "current.md").write_text("#### H1: A\n") + (tmp_path / ".factory" / "experiments").mkdir() + + write_ceo_session_id(tmp_path, "interrupt-session", mode="improve") + mock_invoke = AsyncMock(return_value=("Interrupted", 130)) + + with patch("factory.agents.runner.invoke_agent", mock_invoke): + await run_ceo_with_completion_guard( + tmp_path, + "Initial task", + mode="improve", + runner_name="claude", + ) + + captured = capsys.readouterr() + assert "Session: interrupt-session" in captured.err + assert f"Resume with: factory resume {tmp_path}" in captured.err