mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 12:27:29 +08:00
upgrade autonomy with task-state persistence and proactive notify cooldown
This commit is contained in:
@@ -648,6 +648,7 @@ func buildAutonomyEngine(cfg *config.Config, msgBus *bus.MessageBus) *autonomy.E
|
|||||||
MaxPendingDurationSec: a.MaxPendingDurationSec,
|
MaxPendingDurationSec: a.MaxPendingDurationSec,
|
||||||
MaxConsecutiveStalls: a.MaxConsecutiveStalls,
|
MaxConsecutiveStalls: a.MaxConsecutiveStalls,
|
||||||
MaxDispatchPerTick: a.MaxDispatchPerTick,
|
MaxDispatchPerTick: a.MaxDispatchPerTick,
|
||||||
|
NotifyCooldownSec: a.NotifyCooldownSec,
|
||||||
Workspace: cfg.WorkspacePath(),
|
Workspace: cfg.WorkspacePath(),
|
||||||
DefaultNotifyChannel: a.NotifyChannel,
|
DefaultNotifyChannel: a.NotifyChannel,
|
||||||
DefaultNotifyChatID: a.NotifyChatID,
|
DefaultNotifyChatID: a.NotifyChatID,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"max_pending_duration_sec": 180,
|
"max_pending_duration_sec": 180,
|
||||||
"max_consecutive_stalls": 3,
|
"max_consecutive_stalls": 3,
|
||||||
"max_dispatch_per_tick": 2,
|
"max_dispatch_per_tick": 2,
|
||||||
|
"notify_cooldown_sec": 300,
|
||||||
"notify_channel": "",
|
"notify_channel": "",
|
||||||
"notify_chat_id": ""
|
"notify_chat_id": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Options struct {
|
|||||||
Workspace string
|
Workspace string
|
||||||
DefaultNotifyChannel string
|
DefaultNotifyChannel string
|
||||||
DefaultNotifyChatID string
|
DefaultNotifyChatID string
|
||||||
|
NotifyCooldownSec int
|
||||||
}
|
}
|
||||||
|
|
||||||
type taskState struct {
|
type taskState struct {
|
||||||
@@ -37,12 +38,14 @@ type taskState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
opts Options
|
opts Options
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
runner *lifecycle.LoopRunner
|
runner *lifecycle.LoopRunner
|
||||||
|
taskStore *TaskStore
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
state map[string]*taskState
|
state map[string]*taskState
|
||||||
|
lastNotify map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(opts Options, msgBus *bus.MessageBus) *Engine {
|
func NewEngine(opts Options, msgBus *bus.MessageBus) *Engine {
|
||||||
@@ -61,11 +64,16 @@ func NewEngine(opts Options, msgBus *bus.MessageBus) *Engine {
|
|||||||
if opts.MaxDispatchPerTick <= 0 {
|
if opts.MaxDispatchPerTick <= 0 {
|
||||||
opts.MaxDispatchPerTick = 2
|
opts.MaxDispatchPerTick = 2
|
||||||
}
|
}
|
||||||
|
if opts.NotifyCooldownSec <= 0 {
|
||||||
|
opts.NotifyCooldownSec = 300
|
||||||
|
}
|
||||||
return &Engine{
|
return &Engine{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
runner: lifecycle.NewLoopRunner(),
|
runner: lifecycle.NewLoopRunner(),
|
||||||
state: map[string]*taskState{},
|
taskStore: NewTaskStore(opts.Workspace),
|
||||||
|
state: map[string]*taskState{},
|
||||||
|
lastNotify: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,16 +104,28 @@ func (e *Engine) runLoop(stopCh <-chan struct{}) {
|
|||||||
func (e *Engine) tick() {
|
func (e *Engine) tick() {
|
||||||
todos := e.scanTodos()
|
todos := e.scanTodos()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
stored, _ := e.taskStore.Load()
|
||||||
|
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
storedMap := map[string]TaskItem{}
|
||||||
|
for _, it := range stored {
|
||||||
|
storedMap[it.ID] = it
|
||||||
|
}
|
||||||
|
|
||||||
known := map[string]struct{}{}
|
known := map[string]struct{}{}
|
||||||
for _, t := range todos {
|
for _, t := range todos {
|
||||||
known[t.ID] = struct{}{}
|
known[t.ID] = struct{}{}
|
||||||
st, ok := e.state[t.ID]
|
st, ok := e.state[t.ID]
|
||||||
if !ok {
|
if !ok {
|
||||||
e.state[t.ID] = &taskState{ID: t.ID, Content: t.Content, Status: "idle"}
|
status := "idle"
|
||||||
|
if old, ok := storedMap[t.ID]; ok {
|
||||||
|
if old.Status == "blocked" {
|
||||||
|
status = "blocked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.state[t.ID] = &taskState{ID: t.ID, Content: t.Content, Status: status}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st.Content = t.Content
|
st.Content = t.Content
|
||||||
@@ -150,6 +170,7 @@ func (e *Engine) tick() {
|
|||||||
st.LastAutonomyAt = now
|
st.LastAutonomyAt = now
|
||||||
dispatched++
|
dispatched++
|
||||||
}
|
}
|
||||||
|
e.persistStateLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
type todoItem struct {
|
type todoItem struct {
|
||||||
@@ -210,7 +231,7 @@ func (e *Engine) dispatchTask(st *taskState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) sendCompletionNotification(st *taskState) {
|
func (e *Engine) sendCompletionNotification(st *taskState) {
|
||||||
if strings.TrimSpace(e.opts.DefaultNotifyChannel) == "" || strings.TrimSpace(e.opts.DefaultNotifyChatID) == "" {
|
if !e.shouldNotify("done:" + st.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.bus.PublishOutbound(bus.OutboundMessage{
|
e.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
@@ -221,7 +242,7 @@ func (e *Engine) sendCompletionNotification(st *taskState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) sendFailureNotification(st *taskState, reason string) {
|
func (e *Engine) sendFailureNotification(st *taskState, reason string) {
|
||||||
if strings.TrimSpace(e.opts.DefaultNotifyChannel) == "" || strings.TrimSpace(e.opts.DefaultNotifyChatID) == "" {
|
if !e.shouldNotify("blocked:" + st.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.bus.PublishOutbound(bus.OutboundMessage{
|
e.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
@@ -231,6 +252,46 @@ func (e *Engine) sendFailureNotification(st *taskState, reason string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) shouldNotify(key string) bool {
|
||||||
|
if strings.TrimSpace(e.opts.DefaultNotifyChannel) == "" || strings.TrimSpace(e.opts.DefaultNotifyChatID) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if last, ok := e.lastNotify[key]; ok {
|
||||||
|
if now.Sub(last) < time.Duration(e.opts.NotifyCooldownSec)*time.Second {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.lastNotify[key] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) persistStateLocked() {
|
||||||
|
items := make([]TaskItem, 0, len(e.state))
|
||||||
|
for _, st := range e.state {
|
||||||
|
status := "todo"
|
||||||
|
switch st.Status {
|
||||||
|
case "running":
|
||||||
|
status = "doing"
|
||||||
|
case "blocked":
|
||||||
|
status = "blocked"
|
||||||
|
case "completed":
|
||||||
|
status = "done"
|
||||||
|
default:
|
||||||
|
status = "todo"
|
||||||
|
}
|
||||||
|
items = append(items, TaskItem{
|
||||||
|
ID: st.ID,
|
||||||
|
Content: st.Content,
|
||||||
|
Priority: "normal",
|
||||||
|
Status: status,
|
||||||
|
Source: "memory_todo",
|
||||||
|
UpdatedAt: nowRFC3339(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = e.taskStore.Save(items)
|
||||||
|
}
|
||||||
|
|
||||||
func hashID(s string) string {
|
func hashID(s string) string {
|
||||||
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(s))))
|
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(s))))
|
||||||
return hex.EncodeToString(sum[:])[:12]
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
|
|||||||
71
pkg/autonomy/task_store.go
Normal file
71
pkg/autonomy/task_store.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package autonomy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
DueAt string `json:"due_at,omitempty"`
|
||||||
|
Status string `json:"status"` // todo|doing|blocked|done
|
||||||
|
Source string `json:"source"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskStore struct {
|
||||||
|
workspace string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskStore(workspace string) *TaskStore {
|
||||||
|
return &TaskStore{workspace: workspace}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskStore) path() string {
|
||||||
|
return filepath.Join(s.workspace, "memory", "tasks.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskStore) Load() ([]TaskItem, error) {
|
||||||
|
data, err := os.ReadFile(s.path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []TaskItem{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var items []TaskItem
|
||||||
|
if err := json.Unmarshal(data, &items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskStore) Save(items []TaskItem) error {
|
||||||
|
_ = os.MkdirAll(filepath.Dir(s.path()), 0755)
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt > items[j].UpdatedAt })
|
||||||
|
data, err := json.MarshalIndent(items, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.path(), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStatus(v string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
switch s {
|
||||||
|
case "todo", "doing", "blocked", "done":
|
||||||
|
return s
|
||||||
|
default:
|
||||||
|
return "todo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nowRFC3339() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ type AutonomyConfig struct {
|
|||||||
MaxPendingDurationSec int `json:"max_pending_duration_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_PENDING_DURATION_SEC"`
|
MaxPendingDurationSec int `json:"max_pending_duration_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_PENDING_DURATION_SEC"`
|
||||||
MaxConsecutiveStalls int `json:"max_consecutive_stalls" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_CONSECUTIVE_STALLS"`
|
MaxConsecutiveStalls int `json:"max_consecutive_stalls" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_CONSECUTIVE_STALLS"`
|
||||||
MaxDispatchPerTick int `json:"max_dispatch_per_tick" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_DISPATCH_PER_TICK"`
|
MaxDispatchPerTick int `json:"max_dispatch_per_tick" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_DISPATCH_PER_TICK"`
|
||||||
|
NotifyCooldownSec int `json:"notify_cooldown_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_COOLDOWN_SEC"`
|
||||||
NotifyChannel string `json:"notify_channel" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_CHANNEL"`
|
NotifyChannel string `json:"notify_channel" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_CHANNEL"`
|
||||||
NotifyChatID string `json:"notify_chat_id" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_CHAT_ID"`
|
NotifyChatID string `json:"notify_chat_id" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_CHAT_ID"`
|
||||||
}
|
}
|
||||||
@@ -303,6 +304,7 @@ func DefaultConfig() *Config {
|
|||||||
MaxPendingDurationSec: 180,
|
MaxPendingDurationSec: 180,
|
||||||
MaxConsecutiveStalls: 3,
|
MaxConsecutiveStalls: 3,
|
||||||
MaxDispatchPerTick: 2,
|
MaxDispatchPerTick: 2,
|
||||||
|
NotifyCooldownSec: 300,
|
||||||
NotifyChannel: "",
|
NotifyChannel: "",
|
||||||
NotifyChatID: "",
|
NotifyChatID: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ func Validate(cfg *Config) []error {
|
|||||||
if aut.MaxDispatchPerTick <= 0 {
|
if aut.MaxDispatchPerTick <= 0 {
|
||||||
errs = append(errs, fmt.Errorf("agents.defaults.autonomy.max_dispatch_per_tick must be > 0 when enabled=true"))
|
errs = append(errs, fmt.Errorf("agents.defaults.autonomy.max_dispatch_per_tick must be > 0 when enabled=true"))
|
||||||
}
|
}
|
||||||
|
if aut.NotifyCooldownSec <= 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("agents.defaults.autonomy.notify_cooldown_sec must be > 0 when enabled=true"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
texts := cfg.Agents.Defaults.Texts
|
texts := cfg.Agents.Defaults.Texts
|
||||||
if strings.TrimSpace(texts.NoResponseFallback) == "" {
|
if strings.TrimSpace(texts.NoResponseFallback) == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user