Coverage for little_loops / fsm / handoff_handler.py: 57%
35 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -0500
1"""Handoff handling for FSM loop execution.
3This module provides behavior handlers for context handoff signals,
4supporting pause, spawn, and terminate behaviors.
6The handler is Claude-specific and knows how to spawn continuation sessions
7via the Claude CLI.
8"""
10from __future__ import annotations
12import os
13import subprocess
14from dataclasses import dataclass
15from enum import Enum
17from little_loops.host_runner import resolve_host
20class HandoffBehavior(Enum):
21 """Behavior when a handoff signal is detected.
23 - TERMINATE: Stop loop execution immediately, no state preservation
24 - PAUSE: Save state with continuation prompt and exit (default)
25 - SPAWN: Save state and spawn a new Claude session to continue
26 """
28 TERMINATE = "terminate"
29 PAUSE = "pause"
30 SPAWN = "spawn"
33@dataclass
34class HandoffResult:
35 """Result from handling a handoff signal.
37 Attributes:
38 behavior: The behavior that was applied
39 continuation_prompt: The continuation prompt from the signal
40 spawned_process: Popen object if spawn behavior was used
41 """
43 behavior: HandoffBehavior
44 continuation_prompt: str | None
45 spawned_process: subprocess.Popen[str] | None = None
48class HandoffHandler:
49 """Handle context handoff signals.
51 Provides configurable behavior for when handoff signals are detected
52 in loop action output.
54 Example:
55 handler = HandoffHandler(HandoffBehavior.PAUSE)
56 result = handler.handle("fix-types", "Continue from iteration 5")
57 # result.behavior == HandoffBehavior.PAUSE
58 # State should be saved by caller
59 """
61 def __init__(self, behavior: HandoffBehavior = HandoffBehavior.PAUSE) -> None:
62 """Initialize handler with behavior.
64 Args:
65 behavior: How to handle handoff signals (default: pause)
66 """
67 self.behavior = behavior
69 def handle(self, loop_name: str, continuation: str | None) -> HandoffResult:
70 """Handle a detected handoff signal.
72 For PAUSE and SPAWN behaviors, the caller (executor) is responsible
73 for saving state with the continuation prompt.
75 Args:
76 loop_name: Name of the loop for spawn commands
77 continuation: Continuation prompt from the signal
79 Returns:
80 HandoffResult with behavior taken and any spawned process
81 """
82 if self.behavior == HandoffBehavior.TERMINATE:
83 return HandoffResult(self.behavior, continuation)
85 if self.behavior == HandoffBehavior.PAUSE:
86 # State saving handled by executor
87 return HandoffResult(self.behavior, continuation)
89 if self.behavior == HandoffBehavior.SPAWN:
90 process = self._spawn_continuation(loop_name, continuation)
91 return HandoffResult(self.behavior, continuation, process)
93 # Should never reach here due to enum exhaustiveness,
94 # but satisfy type checker
95 return HandoffResult(self.behavior, continuation)
97 def _spawn_continuation(
98 self, loop_name: str, continuation: str | None
99 ) -> subprocess.Popen[str]:
100 """Spawn new Claude session to continue loop.
102 Creates a new Claude CLI process with a prompt instructing it
103 to resume the loop execution.
105 Args:
106 loop_name: Name of the loop to resume
107 continuation: Continuation context from handoff
109 Returns:
110 Popen object for the spawned process
111 """
112 prompt_parts = [f"Continue loop execution. Run: ll-loop resume {loop_name}"]
113 if continuation:
114 prompt_parts.append(f"\n\n{continuation}")
115 prompt = "".join(prompt_parts)
117 invocation = resolve_host().build_detached(prompt=prompt)
118 # Legacy argv had no perm-skip; strip it for no-behavior-change refactor.
119 args = [a for a in invocation.args if a != "--dangerously-skip-permissions"]
120 return subprocess.Popen(
121 [invocation.binary, *args],
122 text=True,
123 start_new_session=True,
124 stdout=subprocess.DEVNULL,
125 stderr=subprocess.DEVNULL,
126 stdin=subprocess.DEVNULL,
127 env={**os.environ, **invocation.env},
128 )