Add a per-user lead agent and make the right pane task-focused
This commit is contained in:
+100
-38
@@ -7,6 +7,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,6 +23,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrInvalid = errors.New("invalid")
|
||||
ErrNoGitIdentity = errors.New("set your git name and email (PUT /api/me) before starting agents")
|
||||
)
|
||||
|
||||
@@ -41,15 +45,19 @@ type Manager struct {
|
||||
store *store.Store
|
||||
limits Limits
|
||||
worktreeRoot string
|
||||
leadRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
closing bool
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
merges map[string]string
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
closing bool
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
merges map[string]string
|
||||
leadAnswered map[string]bool
|
||||
|
||||
deliverMu sync.Mutex
|
||||
capMu sync.Mutex
|
||||
leadMu sync.Mutex
|
||||
signals chan api.Event
|
||||
|
||||
snapMu sync.Mutex
|
||||
snaps map[string]*snapState
|
||||
@@ -68,11 +76,13 @@ type live struct {
|
||||
vendor api.AgentSessionVendor
|
||||
taskID string
|
||||
projectID string
|
||||
owner string
|
||||
stopping bool
|
||||
interrupted bool
|
||||
asked bool
|
||||
bounced bool
|
||||
compacting bool
|
||||
wantCompact bool
|
||||
rateLimited map[string]any
|
||||
|
||||
contextModel string
|
||||
@@ -80,18 +90,30 @@ type live struct {
|
||||
contextWindow int
|
||||
}
|
||||
|
||||
func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
|
||||
return &Manager{
|
||||
func New(st *store.Store, limits Limits, worktreeRoot, leadRoot string) *Manager {
|
||||
m := &Manager{
|
||||
store: st,
|
||||
limits: limits,
|
||||
worktreeRoot: worktreeRoot,
|
||||
leadRoot: leadRoot,
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
merges: map[string]string{},
|
||||
leadAnswered: map[string]bool{},
|
||||
signals: make(chan api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
bad: newBadLines(),
|
||||
watchStop: make(chan struct{}),
|
||||
}
|
||||
go m.signalLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, error) {
|
||||
if sess.Owner != "" {
|
||||
return api.Task{}, nil
|
||||
}
|
||||
return m.store.GetTask(ctx, sess.TaskId)
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) {
|
||||
@@ -156,7 +178,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string
|
||||
sess.Model = model
|
||||
}
|
||||
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
task, err := m.taskFor(ctx, sess)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
@@ -245,7 +267,7 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
task, err := m.taskFor(ctx, sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -264,9 +286,10 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("resumed session", "session", sess.Id, "task", task.Id)
|
||||
slog.Info("resumed session", "session", sess.Id, "task", task.Id, "owner", sess.Owner)
|
||||
}
|
||||
|
||||
go m.deliverAllLeads()
|
||||
return m.drain(ctx)
|
||||
}
|
||||
|
||||
@@ -284,6 +307,11 @@ func (m *Manager) Close() {
|
||||
for _, l := range all {
|
||||
l.sess.Kill()
|
||||
}
|
||||
|
||||
err := m.parkLeads(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("park lead sessions", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) LiveCount() int {
|
||||
@@ -315,6 +343,9 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
|
||||
return u, err
|
||||
}
|
||||
for _, sess := range running {
|
||||
if sess.Owner != "" {
|
||||
continue
|
||||
}
|
||||
u.Running = append(u.Running, api.TurnRef{SessionId: sess.Id, TaskId: sess.TaskId, Vendor: string(sess.Vendor)})
|
||||
}
|
||||
|
||||
@@ -335,6 +366,10 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
|
||||
if sess.Owner != "" {
|
||||
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
|
||||
}
|
||||
|
||||
m.capMu.Lock()
|
||||
defer m.capMu.Unlock()
|
||||
|
||||
@@ -506,15 +541,18 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
|
||||
imgs = append(imgs, claude.Image{MediaType: b.ContentType, Data: data})
|
||||
}
|
||||
|
||||
head, err := git.Head(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
payload := map[string]any{"text": text}
|
||||
if l.owner == "" {
|
||||
head, err := git.Head(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload["head"] = head
|
||||
}
|
||||
payload := map[string]any{"text": text, "head": head}
|
||||
if len(images) > 0 {
|
||||
payload["images"] = images
|
||||
}
|
||||
err = m.emit(ctx, l, actor, "user.message", payload)
|
||||
err := m.emit(ctx, l, actor, "user.message", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -523,7 +561,8 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
|
||||
l.interrupted = false
|
||||
l.asked = false
|
||||
l.bounced = false
|
||||
l.compacting = actor == "byre" && text == handoffPrompt
|
||||
l.wantCompact = false
|
||||
l.compacting = isHandoffPrompt(actor, text)
|
||||
l.rateLimited = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
@@ -557,22 +596,36 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
vendor: sess.Vendor,
|
||||
taskID: task.Id,
|
||||
projectID: task.ProjectId,
|
||||
owner: sess.Owner,
|
||||
}
|
||||
|
||||
g := taskGuidance(task)
|
||||
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}
|
||||
hooks := map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance(g)}},
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
|
||||
}
|
||||
if l.owner != "" {
|
||||
l.dir = filepath.Join(m.leadRoot, url.PathEscape(l.owner))
|
||||
err = os.MkdirAll(l.dir, 0o755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g = leadGuidance
|
||||
tools = m.leadTools(l)
|
||||
hooks = map[string][]claude.HookMatcher{"UserPromptSubmit": {{Func: remindGuidance(g)}}}
|
||||
}
|
||||
|
||||
opts := claude.Options{
|
||||
Cwd: task.WorktreePath,
|
||||
Cwd: l.dir,
|
||||
Model: sess.Model,
|
||||
GitName: creator.GitName,
|
||||
GitEmail: creator.GitEmail,
|
||||
Guidance: g,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance(g)}},
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
|
||||
},
|
||||
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}},
|
||||
Hooks: hooks,
|
||||
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
|
||||
}
|
||||
started, err := m.store.SessionStarted(ctx, sess.Id)
|
||||
if err != nil {
|
||||
@@ -594,7 +647,9 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
|
||||
m.live[sess.Id] = l
|
||||
go m.pump(l)
|
||||
go m.snapshotTimer(l)
|
||||
if l.owner == "" {
|
||||
go m.snapshotTimer(l)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
@@ -641,7 +696,9 @@ func (m *Manager) pump(l *live) {
|
||||
if err != nil {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
}
|
||||
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
||||
if l.owner == "" {
|
||||
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
||||
}
|
||||
|
||||
err = m.drain(ctx)
|
||||
if err != nil {
|
||||
@@ -701,6 +758,9 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if l.owner != "" {
|
||||
return m.leadTurnEnded(ctx, l, res)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
@@ -777,14 +837,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
if err != nil || text == "" {
|
||||
return err
|
||||
}
|
||||
m.store.Broadcast(api.Event{
|
||||
Kind: "agent.delta",
|
||||
ProjectId: &l.projectID,
|
||||
TaskId: &l.taskID,
|
||||
AgentSessionId: &l.id,
|
||||
Actor: agentActor,
|
||||
Payload: map[string]any{"text": text},
|
||||
})
|
||||
m.store.Broadcast(m.event(l, agentActor, "agent.delta", map[string]any{"text": text}))
|
||||
return nil
|
||||
|
||||
case "rate_limit_event":
|
||||
@@ -870,14 +923,23 @@ func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Bl
|
||||
}
|
||||
|
||||
func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload map[string]any) error {
|
||||
return m.store.AppendEvent(ctx, api.Event{
|
||||
return m.store.AppendEvent(ctx, m.event(l, actor, kind, payload))
|
||||
}
|
||||
|
||||
func (m *Manager) event(l *live, actor, kind string, payload map[string]any) api.Event {
|
||||
ev := api.Event{
|
||||
Kind: kind,
|
||||
ProjectId: &l.projectID,
|
||||
TaskId: &l.taskID,
|
||||
AgentSessionId: &l.id,
|
||||
Actor: actor,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
if l.owner != "" {
|
||||
ev.Owner = &l.owner
|
||||
return ev
|
||||
}
|
||||
ev.ProjectId = &l.projectID
|
||||
ev.TaskId = &l.taskID
|
||||
return ev
|
||||
}
|
||||
|
||||
func decode(raw json.RawMessage) (any, error) {
|
||||
|
||||
Reference in New Issue
Block a user