@@ -11,11 +11,13 @@ import (
1111 "net/http"
1212 "net/url"
1313 "os"
14+ "os/signal"
1415 "path/filepath"
1516 "slices"
1617 "sort"
1718 "strings"
1819 "sync"
20+ "syscall"
1921 "time"
2022 "unicode"
2123
@@ -34,18 +36,20 @@ import (
3436
3537// Server represents the HTTP server
3638type Server struct {
37- router chi.Router
38- api huma.API
39- port int
40- srv * http.Server
41- mu sync.RWMutex
42- logger * slog.Logger
43- conversation * st.PTYConversation
44- agentio * termexec.Process
45- agentType mf.AgentType
46- emitter * EventEmitter
47- chatBasePath string
48- tempDir string
39+ router chi.Router
40+ api huma.API
41+ port int
42+ srv * http.Server
43+ mu sync.RWMutex
44+ logger * slog.Logger
45+ conversation * st.PTYConversation
46+ agentio * termexec.Process
47+ agentType mf.AgentType
48+ emitter * EventEmitter
49+ chatBasePath string
50+ tempDir string
51+ statePersistenceCfg StatePersistenceCfg
52+ stateLoadComplete bool
4953}
5054
5155func (s * Server ) NormalizeSchema (schema any ) any {
@@ -94,14 +98,22 @@ func (s *Server) GetOpenAPI() string {
9498// because the action of taking a snapshot takes time too.
9599const snapshotInterval = 25 * time .Millisecond
96100
101+ type StatePersistenceCfg struct {
102+ StateFile string
103+ LoadState bool
104+ SaveState bool
105+ PidFile string
106+ }
107+
97108type ServerConfig struct {
98- AgentType mf.AgentType
99- Process * termexec.Process
100- Port int
101- ChatBasePath string
102- AllowedHosts []string
103- AllowedOrigins []string
104- InitialPrompt string
109+ AgentType mf.AgentType
110+ Process * termexec.Process
111+ Port int
112+ ChatBasePath string
113+ AllowedHosts []string
114+ AllowedOrigins []string
115+ InitialPrompt string
116+ StatePersistenceCfg StatePersistenceCfg
105117}
106118
107119// Validate allowed hosts don't contain whitespace, commas, schemes, or ports.
@@ -260,16 +272,18 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) {
260272 logger .Info ("Created temporary directory for uploads" , "tempDir" , tempDir )
261273
262274 s := & Server {
263- router : router ,
264- api : api ,
265- port : config .Port ,
266- conversation : conversation ,
267- logger : logger ,
268- agentio : config .Process ,
269- agentType : config .AgentType ,
270- emitter : emitter ,
271- chatBasePath : strings .TrimSuffix (config .ChatBasePath , "/" ),
272- tempDir : tempDir ,
275+ router : router ,
276+ api : api ,
277+ port : config .Port ,
278+ conversation : conversation ,
279+ logger : logger ,
280+ agentio : config .Process ,
281+ agentType : config .AgentType ,
282+ emitter : emitter ,
283+ chatBasePath : strings .TrimSuffix (config .ChatBasePath , "/" ),
284+ tempDir : tempDir ,
285+ statePersistenceCfg : config .StatePersistenceCfg ,
286+ stateLoadComplete : false ,
273287 }
274288
275289 // Register API routes
@@ -337,15 +351,26 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) {
337351 currentStatus := s .conversation .Status ()
338352
339353 // Send initial prompt when agent becomes stable for the first time
340- if ! s .conversation .InitialPromptSent && convertStatus (currentStatus ) == AgentStatusStable {
341-
342- if err := s .conversation .Send (FormatMessage (s .agentType , s .conversation .InitialPrompt )... ); err != nil {
343- s .logger .Error ("Failed to send initial prompt" , "error" , err )
344- } else {
345- s .conversation .InitialPromptSent = true
346- s .conversation .ReadyForInitialPrompt = false
347- currentStatus = st .ConversationStatusChanging
348- s .logger .Info ("Initial prompt sent successfully" )
354+ if convertStatus (currentStatus ) == AgentStatusStable {
355+
356+ if ! s .stateLoadComplete && s .statePersistenceCfg .LoadState {
357+ _ , err := s .conversation .LoadState (s .statePersistenceCfg .StateFile )
358+ if err != nil {
359+ s .logger .Warn ("Failed to load state file" , "path" , s .statePersistenceCfg .StateFile , "err" , err )
360+ } else {
361+ s .logger .Info ("Successfully loaded state" , "path" , s .statePersistenceCfg .StateFile )
362+ }
363+ s .stateLoadComplete = true
364+ }
365+ if ! s .conversation .InitialPromptSent {
366+ if err := s .conversation .Send (FormatMessage (s .agentType , s .conversation .InitialPrompt )... ); err != nil {
367+ s .logger .Error ("Failed to send initial prompt" , "error" , err )
368+ } else {
369+ s .conversation .InitialPromptSent = true
370+ s .conversation .ReadyForInitialPrompt = false
371+ currentStatus = st .ConversationStatusChanging
372+ s .logger .Info ("Initial prompt sent successfully" )
373+ }
349374 }
350375 }
351376 s .emitter .UpdateStatusAndEmitChanges (currentStatus , s .agentType )
@@ -592,6 +617,15 @@ func (s *Server) Start() error {
592617
593618// Stop gracefully stops the HTTP server
594619func (s * Server ) Stop (ctx context.Context ) error {
620+ // Save conversation state if configured
621+ if s .statePersistenceCfg .SaveState && s .statePersistenceCfg .StateFile != "" {
622+ if err := s .conversation .SaveState (s .conversation .Messages (), s .statePersistenceCfg .StateFile ); err != nil {
623+ s .logger .Error ("Failed to save conversation state" , "error" , err )
624+ } else {
625+ s .logger .Info ("Saved conversation state" , "stateFile" , s .statePersistenceCfg .StateFile )
626+ }
627+ }
628+
595629 // Clean up temporary directory
596630 s .cleanupTempDir ()
597631
@@ -610,6 +644,58 @@ func (s *Server) cleanupTempDir() {
610644 }
611645}
612646
647+ // HandleSignals sets up signal handlers for:
648+ // - SIGTERM, SIGINT, SIGHUP: save conversation state, then close the process
649+ // - SIGUSR1: save conversation state without exiting
650+ func (s * Server ) HandleSignals (ctx context.Context , process * termexec.Process ) {
651+ // Handle shutdown signals (SIGTERM, SIGINT, SIGHUP)
652+ shutdownCh := make (chan os.Signal , 1 )
653+ signal .Notify (shutdownCh , os .Interrupt , syscall .SIGTERM , syscall .SIGHUP )
654+ go func () {
655+ sig := <- shutdownCh
656+ s .logger .Info ("Received shutdown signal, saving state before closing process" , "signal" , sig )
657+
658+ // Save conversation state if configured (synchronously before closing process)
659+ if s .statePersistenceCfg .SaveState && s .statePersistenceCfg .StateFile != "" {
660+ if err := s .conversation .SaveState (s .conversation .Messages (), s .statePersistenceCfg .StateFile ); err != nil {
661+ s .logger .Error ("Failed to save conversation state on signal" , "signal" , sig , "error" , err )
662+ } else {
663+ s .logger .Info ("Saved conversation state on signal" , "signal" , sig , "stateFile" , s .statePersistenceCfg .StateFile )
664+ }
665+ }
666+
667+ // Now close the process
668+ if err := process .Close (s .logger , 5 * time .Second ); err != nil {
669+ s .logger .Error ("Error closing process" , "signal" , sig , "error" , err )
670+ }
671+ }()
672+
673+ // Handle SIGUSR1 for save without exit
674+ saveOnlyCh := make (chan os.Signal , 1 )
675+ signal .Notify (saveOnlyCh , syscall .SIGUSR1 )
676+ go func () {
677+ for {
678+ select {
679+ case <- saveOnlyCh :
680+ s .logger .Info ("Received SIGUSR1, saving state without exiting" )
681+
682+ // Save conversation state if configured
683+ if s .statePersistenceCfg .SaveState && s .statePersistenceCfg .StateFile != "" {
684+ if err := s .conversation .SaveState (s .conversation .Messages (), s .statePersistenceCfg .StateFile ); err != nil {
685+ s .logger .Error ("Failed to save conversation state on SIGUSR1" , "error" , err )
686+ } else {
687+ s .logger .Info ("Saved conversation state on SIGUSR1" , "stateFile" , s .statePersistenceCfg .StateFile )
688+ }
689+ } else {
690+ s .logger .Warn ("SIGUSR1 received but state saving is not configured" )
691+ }
692+ case <- ctx .Done ():
693+ return
694+ }
695+ }
696+ }()
697+ }
698+
613699// registerStaticFileRoutes sets up routes for serving static files
614700func (s * Server ) registerStaticFileRoutes () {
615701 chatHandler := FileServerWithIndexFallback (s .chatBasePath )
0 commit comments