Run Claude sessions on tasks with event streaming and restart recovery

This commit is contained in:
Ian Gulliver
2026-10-13 22:30:39 -07:00
parent 2328904131
commit 09951e64e7
16 changed files with 1630 additions and 170 deletions
+120 -2
View File
@@ -15,6 +15,7 @@ import (
"sync"
"time"
"g.fc.run/misc/byre/internal/agents"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
@@ -22,6 +23,7 @@ import (
type Server struct {
store *store.Store
agents *agents.Manager
hub *hub
worktreeRoot string
startedAt time.Time
@@ -52,10 +54,18 @@ func New(dataDir string, spa fs.FS) (*Server, error) {
return nil, err
}
s.agents = agents.New(s.store)
err = s.agents.Recover(context.Background())
if err != nil {
s.store.Close()
return nil, fmt.Errorf("recover sessions: %w", err)
}
return s, nil
}
func (s *Server) Close() error {
s.agents.Close()
return s.store.Close()
}
@@ -242,7 +252,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st
Title: req.Title,
Branch: "byre/" + id,
WorktreePath: filepath.Join(s.worktreeRoot, p.Id, id),
Status: api.Active,
Status: api.TaskStatusActive,
CreatedBy: actor(r),
CreatedAt: time.Now().UTC(),
}
@@ -277,7 +287,7 @@ func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID str
return
}
if t.Status != api.Active {
if t.Status != api.TaskStatusActive {
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
return
}
@@ -288,6 +298,12 @@ func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID str
return
}
err = s.agents.StopTask(r.Context(), t.Id, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
@@ -324,9 +340,111 @@ func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
Projects: c.Projects,
ActiveTasks: c.ActiveTasks,
SseClients: s.hub.count(),
LiveAgents: s.agents.LiveCount(),
})
}
func (s *Server) ListSessions(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
sessions, err := s.store.ListSessions(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, sessions)
}
func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID string) {
req := api.SessionCreate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Prompt) == "" {
writeError(w, http.StatusBadRequest, errors.New("prompt is required"))
return
}
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, sess)
}
func (s *Server) GetSession(w http.ResponseWriter, r *http.Request, sessionID string) {
sess, err := s.store.GetSession(r.Context(), sessionID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, sess)
}
func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID string) {
req := api.MessageCreate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, errors.New("text is required"))
return
}
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) InterruptSession(w http.ResponseWriter, r *http.Request, sessionID string) {
sess, err := s.agents.Interrupt(r.Context(), sessionID)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func writeAgentError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, err)
case errors.Is(err, agents.ErrConflict):
writeError(w, http.StatusConflict, err)
case errors.Is(err, agents.ErrNoGitIdentity):
writeError(w, http.StatusBadRequest, err)
default:
writeError(w, http.StatusInternalServerError, err)
}
}
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
+1 -1
View File
@@ -169,7 +169,7 @@ func TestProjectAndTaskLifecycle(t *testing.T) {
done := api.Task{}
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, &done)
if code != http.StatusOK || done.Status != api.Completed || done.CompletedAt == nil {
if code != http.StatusOK || done.Status != api.TaskStatusCompleted || done.CompletedAt == nil {
t.Fatalf("complete: %d %+v", code, done)
}