Coverage for little_loops / fsm / handoff_handler.py: 97%

34 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""Handoff handling for FSM loop execution. 

2 

3This module provides behavior handlers for context handoff signals, 

4supporting pause, spawn, and terminate behaviors. 

5 

6The handler is Claude-specific and knows how to spawn continuation sessions 

7via the Claude CLI. 

8""" 

9 

10from __future__ import annotations 

11 

12import subprocess 

13from dataclasses import dataclass 

14from enum import Enum 

15 

16from little_loops.host_runner import resolve_host 

17 

18 

19class HandoffBehavior(Enum): 

20 """Behavior when a handoff signal is detected. 

21 

22 - TERMINATE: Stop loop execution immediately, no state preservation 

23 - PAUSE: Save state with continuation prompt and exit (default) 

24 - SPAWN: Save state and spawn a new Claude session to continue 

25 """ 

26 

27 TERMINATE = "terminate" 

28 PAUSE = "pause" 

29 SPAWN = "spawn" 

30 

31 

32@dataclass 

33class HandoffResult: 

34 """Result from handling a handoff signal. 

35 

36 Attributes: 

37 behavior: The behavior that was applied 

38 continuation_prompt: The continuation prompt from the signal 

39 spawned_process: Popen object if spawn behavior was used 

40 """ 

41 

42 behavior: HandoffBehavior 

43 continuation_prompt: str | None 

44 spawned_process: subprocess.Popen[str] | None = None 

45 

46 

47class HandoffHandler: 

48 """Handle context handoff signals. 

49 

50 Provides configurable behavior for when handoff signals are detected 

51 in loop action output. 

52 

53 Example: 

54 handler = HandoffHandler(HandoffBehavior.PAUSE) 

55 result = handler.handle("fix-types", "Continue from iteration 5") 

56 # result.behavior == HandoffBehavior.PAUSE 

57 # State should be saved by caller 

58 """ 

59 

60 def __init__(self, behavior: HandoffBehavior = HandoffBehavior.PAUSE) -> None: 

61 """Initialize handler with behavior. 

62 

63 Args: 

64 behavior: How to handle handoff signals (default: pause) 

65 """ 

66 self.behavior = behavior 

67 

68 def handle(self, loop_name: str, continuation: str | None) -> HandoffResult: 

69 """Handle a detected handoff signal. 

70 

71 For PAUSE and SPAWN behaviors, the caller (executor) is responsible 

72 for saving state with the continuation prompt. 

73 

74 Args: 

75 loop_name: Name of the loop for spawn commands 

76 continuation: Continuation prompt from the signal 

77 

78 Returns: 

79 HandoffResult with behavior taken and any spawned process 

80 """ 

81 if self.behavior == HandoffBehavior.TERMINATE: 

82 return HandoffResult(self.behavior, continuation) 

83 

84 if self.behavior == HandoffBehavior.PAUSE: 

85 # State saving handled by executor 

86 return HandoffResult(self.behavior, continuation) 

87 

88 if self.behavior == HandoffBehavior.SPAWN: 

89 process = self._spawn_continuation(loop_name, continuation) 

90 return HandoffResult(self.behavior, continuation, process) 

91 

92 # Should never reach here due to enum exhaustiveness, 

93 # but satisfy type checker 

94 return HandoffResult(self.behavior, continuation) 

95 

96 def _spawn_continuation( 

97 self, loop_name: str, continuation: str | None 

98 ) -> subprocess.Popen[str]: 

99 """Spawn new Claude session to continue loop. 

100 

101 Creates a new Claude CLI process with a prompt instructing it 

102 to resume the loop execution. 

103 

104 Args: 

105 loop_name: Name of the loop to resume 

106 continuation: Continuation context from handoff 

107 

108 Returns: 

109 Popen object for the spawned process 

110 """ 

111 prompt_parts = [f"Continue loop execution. Run: ll-loop resume {loop_name}"] 

112 if continuation: 

113 prompt_parts.append(f"\n\n{continuation}") 

114 prompt = "".join(prompt_parts) 

115 

116 invocation = resolve_host().build_detached(prompt=prompt) 

117 # Legacy argv had no perm-skip; strip it for no-behavior-change refactor. 

118 args = [a for a in invocation.args if a != "--dangerously-skip-permissions"] 

119 return subprocess.Popen( 

120 [invocation.binary, *args], 

121 text=True, 

122 start_new_session=True, 

123 stdout=subprocess.DEVNULL, 

124 stderr=subprocess.DEVNULL, 

125 stdin=subprocess.DEVNULL, 

126 )