Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e3bd936
chore(lib): extract Conversation interface
johnstcn Jan 22, 2026
e5f1bda
Merge branch 'main' into cj/refactor-conversation
35C4n0r Jan 26, 2026
a0f8bb5
feat: implement state persistence
35C4n0r Jan 31, 2026
ca3cdff
feat: pid file writing and clearing and improved error handling for l…
35C4n0r Jan 31, 2026
1c224e9
refactor: remove redundant save logic
35C4n0r Jan 31, 2026
30f82d7
feat: improve logic for first run with empty state file
35C4n0r Feb 2, 2026
12bed1c
feat: implement platform-specific signal handling
35C4n0r Feb 3, 2026
e366e8b
feat: refactor cfg -> Config and move pid ops to server
35C4n0r Feb 5, 2026
26fdf81
feat: unregister the signal handlers on teardown
35C4n0r Feb 5, 2026
021e33f
Merge branch 'main' into 35C4n0r/agentapi-state-persistence
35C4n0r Feb 16, 2026
5795db7
feat: resolve conflicts and improve shutdown sequence
35C4n0r Feb 17, 2026
b44fe5d
Merge branch 'main' into 35C4n0r/agentapi-state-persistence
35C4n0r Feb 17, 2026
9deab88
feat: resolve conflicts
35C4n0r Feb 17, 2026
18fb1e4
chore: not dirty after load state
35C4n0r Feb 17, 2026
b719dac
feat: add tests
35C4n0r Feb 17, 2026
3959002
feat: remove comment
35C4n0r Feb 17, 2026
7e389d2
feat: remove comments
35C4n0r Feb 17, 2026
1d7aaed
wip: address comments
35C4n0r Feb 18, 2026
058b18f
feat: remove anti-pattern for graceful shutdown
35C4n0r Feb 18, 2026
2565a3c
feat: remove additional message upon load state fail
35C4n0r Feb 18, 2026
1033cd7
wip: apply suggestions from cian
35C4n0r Feb 18, 2026
cfb7601
wip: apply suggestions from cian
35C4n0r Feb 18, 2026
9d7eb5a
feat: update tests
35C4n0r Feb 18, 2026
759ec53
feat: improved initial prompt handling
35C4n0r Feb 19, 2026
03c6f16
chore: comments
35C4n0r Feb 19, 2026
bd75240
chore: address cian's file permission comments
35C4n0r Feb 19, 2026
b1ab615
feat: implement error handling for agent events
35C4n0r Feb 19, 2026
31d27a7
fix: no screen adjustment in case of loadState failure
35C4n0r Feb 19, 2026
220d360
feat: add three e2e tests for statePersistence
35C4n0r Feb 20, 2026
eef927d
feat: address maf's review
35C4n0r Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion chat/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function RootLayout({
disableTransitionOnChange
>
{children}
<Toaster richColors />
<Toaster richColors closeButton />
</ThemeProvider>
</body>
</html>
Expand Down
25 changes: 25 additions & 0 deletions chat/src/components/chat-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ interface StatusChangeEvent {
agent_type: string;
}

interface ErrorEventData {
message: string;
level: string;
time: string;
}

interface APIErrorDetail {
location: string;
message: string;
Expand Down Expand Up @@ -215,6 +221,25 @@ export function ChatProvider({ children }: PropsWithChildren) {
setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType);
});

// Handle agent error events
eventSource.addEventListener("agent_error", (event) => {
const messageEvent = event as MessageEvent;
try {
const data: ErrorEventData = JSON.parse(messageEvent.data);

// Display error as toast notification that persists until manually dismissed
if (data.level === "error") {
toast.error(data.message, { duration: Infinity });
} else if (data.level === "warning") {
toast.warning(data.message, { duration: Infinity });
} else {
toast.info(data.message, { duration: Infinity });
}
} catch (e) {
console.error("Failed to parse agent_error event data:", e);
}
});

// Handle connection open (server is online)
eventSource.onopen = () => {
// Connection is established, but we'll wait for status_change event
Expand Down
133 changes: 128 additions & 5 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/coder/agentapi/lib/screentracker"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -103,6 +106,43 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
}
}

// Get the variables related to state management
stateFile := viper.GetString(FlagStateFile)
loadState := false
saveState := false

// Validate state file configuration
if stateFile != "" {
if !viper.IsSet(FlagLoadState) {
loadState = true
} else {
loadState = viper.GetBool(FlagLoadState)
}

if !viper.IsSet(FlagSaveState) {
saveState = true
} else {
saveState = viper.GetBool(FlagSaveState)
}
} else {
if viper.IsSet(FlagLoadState) && viper.GetBool(FlagLoadState) {
return xerrors.Errorf("--load-state requires --state-file to be set")
}
if viper.IsSet(FlagSaveState) && viper.GetBool(FlagSaveState) {
return xerrors.Errorf("--save-state requires --state-file to be set")
}
}

pidFile := viper.GetString(FlagPidFile)

// Write PID file if configured
if pidFile != "" {
if err := writePIDFile(pidFile, logger); err != nil {
return xerrors.Errorf("failed to write PID file: %w", err)
}
defer cleanupPIDFile(pidFile, logger)
}

printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
var process *termexec.Process
if printOpenAPI {
Expand All @@ -128,36 +168,82 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins),
InitialPrompt: initialPrompt,
StatePersistenceConfig: screentracker.StatePersistenceConfig{
StateFile: stateFile,
LoadState: loadState,
SaveState: saveState,
},
})

if err != nil {
return xerrors.Errorf("failed to create server: %w", err)
}
if printOpenAPI {
fmt.Println(srv.GetOpenAPI())
return nil
}

// Create a context for graceful shutdown
gracefulCtx, gracefulCancel := context.WithCancel(ctx)
defer gracefulCancel()

// Setup signal handlers (they will call gracefulCancel)
handleSignals(gracefulCtx, gracefulCancel, logger, srv)

logger.Info("Starting server on port", "port", port)

// Monitor process exit
processExitCh := make(chan error, 1)
go func() {
defer close(processExitCh)
defer gracefulCancel()
if err := process.Wait(); err != nil {
if errors.Is(err, termexec.ErrNonZeroExitCode) {
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
} else {
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
}
}
if err := srv.Stop(ctx); err != nil {
logger.Error("Failed to stop server", "error", err)
}()

// Start the server
serverErrCh := make(chan error, 1)
go func() {
defer close(serverErrCh)
if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, http.ErrServerClosed) {
serverErrCh <- err
}
}()
if err := srv.Start(); err != nil && err != context.Canceled && err != http.ErrServerClosed {
return xerrors.Errorf("failed to start server: %w", err)

select {
case err := <-serverErrCh:
if err != nil {
return xerrors.Errorf("failed to start server: %w", err)
}
case <-gracefulCtx.Done():
}

if err := srv.SaveState("shutdown"); err != nil {
logger.Error("Failed to save state during shutdown", "error", err)
}

// Stop the HTTP server
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Stop(shutdownCtx); err != nil {
logger.Error("Failed to stop HTTP server", "error", err)
}
Comment on lines +230 to +235
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider moving this into case <-gracefulCtx.Done(): above? I'm guessing we don't have to call srv.Stop if we receive on serverErrCh.

Does stop error if the server already closed? If yes, we'll end up printing a misleading error here.

EDIT: I looked at Stop and the once does guard against multiple Stops, but not against Stop producing an error if the server closed before?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We return early if we receive anything(non nil) on serverErrCh

	select {
	case err := <-serverErrCh:
		if err != nil {
			return xerrors.Errorf("failed to start server: %w", err)

I looked at Stop and the once does guard against multiple Stops, but not against Stop producing an error if the server closed before?

True, I had an alternative in mind, but I decided to proceed with once here for the above-mentioned reason. I'll add a check in Stop
I'll check for this ErrServerClosed, and return nil in that case.

From docs
Once Shutdown has been called on a server, it may not be reused; future calls to methods such as Serve will return ErrServerClosed.


select {
case err := <-processExitCh:
return xerrors.Errorf("agent exited with error: %w", err)
if err != nil {
return xerrors.Errorf("agent exited with error: %w", err)
}
default:
// Close the process
if err := process.Close(logger, 5*time.Second); err != nil {
logger.Error("Failed to close process cleanly", "error", err)
}
}
return nil
}
Expand All @@ -171,6 +257,35 @@ var agentNames = (func() []string {
return names
})()

// writePIDFile writes the current process ID to the specified file
func writePIDFile(pidFile string, logger *slog.Logger) error {
pid := os.Getpid()
pidContent := fmt.Sprintf("%d\n", pid)

// Create directory if it doesn't exist
dir := filepath.Dir(pidFile)
if err := os.MkdirAll(dir, 0o700); err != nil {
return xerrors.Errorf("failed to create PID file directory: %w", err)
}

// Write PID file
if err := os.WriteFile(pidFile, []byte(pidContent), 0o600); err != nil {
return xerrors.Errorf("failed to write PID file: %w", err)
}

logger.Info("Wrote PID file", "pidFile", pidFile, "pid", pid)
return nil
}

// cleanupPIDFile removes the PID file if it exists
func cleanupPIDFile(pidFile string, logger *slog.Logger) {
if err := os.Remove(pidFile); err != nil && !os.IsNotExist(err) {
logger.Error("Failed to remove PID file", "pidFile", pidFile, "error", err)
} else if err == nil {
logger.Info("Removed PID file", "pidFile", pidFile)
}
}

type flagSpec struct {
name string
shorthand string
Expand All @@ -190,6 +305,10 @@ const (
FlagAllowedOrigins = "allowed-origins"
FlagExit = "exit"
FlagInitialPrompt = "initial-prompt"
FlagStateFile = "state-file"
FlagLoadState = "load-state"
FlagSaveState = "save-state"
FlagPidFile = "pid-file"
)

func CreateServerCmd() *cobra.Command {
Expand Down Expand Up @@ -228,6 +347,10 @@ func CreateServerCmd() *cobra.Command {
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
{FlagAllowedOrigins, "o", []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, "HTTP allowed origins. Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_ORIGINS env var", "stringSlice"},
{FlagInitialPrompt, "I", "", "Initial prompt for the agent. Recommended only if the agent doesn't support initial prompt in interaction mode. Will be read from stdin if piped (e.g., echo 'prompt' | agentapi server -- my-agent)", "string"},
{FlagStateFile, "s", "", "Path to file for saving/loading server state", "string"},
{FlagLoadState, "", false, "Load state from state-file on startup (defaults to true when state-file is set)", "bool"},
{FlagSaveState, "", false, "Save state to state-file on shutdown (defaults to true when state-file is set)", "bool"},
{FlagPidFile, "", "", "Path to file where the server process ID will be written for shutdown scripts", "string"},
}

for _, spec := range flagSpecs {
Expand Down
Loading