Files
cc-connect/core/heartbeat.go
T
2026-06-02 23:14:41 +08:00

414 lines
10 KiB
Go

package core
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// HeartbeatConfig holds runtime heartbeat settings for a single project.
type HeartbeatConfig struct {
Enabled bool
IntervalMins int
OnlyWhenIdle bool
SessionKey string
Prompt string // explicit prompt; empty = read HEARTBEAT.md
Silent bool // suppress "💓" notification
TimeoutMins int
}
// HeartbeatStatus is returned by the /heartbeat command.
type HeartbeatStatus struct {
Enabled bool
Paused bool
IntervalMins int
OnlyWhenIdle bool
SessionKey string
Silent bool
RunCount int
ErrorCount int
SkippedBusy int
LastRun time.Time
LastError string
}
// heartbeatPersisted is the JSON-serialisable per-project state.
type heartbeatPersisted struct {
Paused bool `json:"paused"`
IntervalMins int `json:"interval_mins,omitempty"`
}
// HeartbeatScheduler manages periodic heartbeat execution across projects.
type HeartbeatScheduler struct {
mu sync.Mutex
entries map[string]*heartbeatEntry // project name → entry
stopCh chan struct{}
stopped bool
stateFile string // path to heartbeat_state.json; empty = no persistence
}
type heartbeatEntry struct {
project string
config HeartbeatConfig
engine *Engine
workDir string
ticker *time.Ticker
stopCh chan struct{}
paused bool
origIntervalMins int // interval from config, for detecting overrides
// Runtime stats
runCount int
errorCount int
skippedBusy int
lastRun time.Time
lastError string
}
func NewHeartbeatScheduler(dataDir string) *HeartbeatScheduler {
hs := &HeartbeatScheduler{
entries: make(map[string]*heartbeatEntry),
stopCh: make(chan struct{}),
}
if dataDir != "" {
hs.stateFile = filepath.Join(dataDir, "heartbeat_state.json")
}
return hs
}
// Register adds a heartbeat entry for a project. Call before Start().
func (hs *HeartbeatScheduler) Register(project string, cfg HeartbeatConfig, engine *Engine, workDir string) {
if !cfg.Enabled || cfg.SessionKey == "" {
return
}
if cfg.IntervalMins <= 0 {
cfg.IntervalMins = 30
}
if cfg.TimeoutMins <= 0 {
cfg.TimeoutMins = 30
}
hs.mu.Lock()
defer hs.mu.Unlock()
entry := &heartbeatEntry{
project: project,
config: cfg,
engine: engine,
workDir: workDir,
stopCh: make(chan struct{}),
origIntervalMins: cfg.IntervalMins,
}
// Restore persisted overrides
if saved := hs.loadProjectState(project); saved != nil {
entry.paused = saved.Paused
if saved.IntervalMins > 0 {
entry.config.IntervalMins = saved.IntervalMins
}
}
hs.entries[project] = entry
}
// Start begins all registered heartbeat tickers.
func (hs *HeartbeatScheduler) Start() {
hs.mu.Lock()
defer hs.mu.Unlock()
for _, entry := range hs.entries {
hs.startEntry(entry)
}
if len(hs.entries) > 0 {
slog.Info("heartbeat: scheduler started", "entries", len(hs.entries))
}
}
func (hs *HeartbeatScheduler) startEntry(entry *heartbeatEntry) {
interval := time.Duration(entry.config.IntervalMins) * time.Minute
entry.ticker = time.NewTicker(interval)
go hs.run(entry)
state := "running"
if entry.paused {
state = "paused"
}
slog.Info("heartbeat: started",
"project", entry.project,
"interval", interval,
"state", state,
"session_key", entry.config.SessionKey,
"only_when_idle", entry.config.OnlyWhenIdle,
)
}
// Stop halts all heartbeat tickers.
func (hs *HeartbeatScheduler) Stop() {
hs.mu.Lock()
defer hs.mu.Unlock()
if hs.stopped {
return
}
hs.stopped = true
close(hs.stopCh)
for _, entry := range hs.entries {
if entry.ticker != nil {
entry.ticker.Stop()
}
select {
case <-entry.stopCh:
default:
close(entry.stopCh)
}
}
}
// Status returns the heartbeat status for a project.
func (hs *HeartbeatScheduler) Status(project string) *HeartbeatStatus {
hs.mu.Lock()
defer hs.mu.Unlock()
entry, ok := hs.entries[project]
if !ok {
return nil
}
return &HeartbeatStatus{
Enabled: entry.config.Enabled,
Paused: entry.paused,
IntervalMins: entry.config.IntervalMins,
OnlyWhenIdle: entry.config.OnlyWhenIdle,
SessionKey: entry.config.SessionKey,
Silent: entry.config.Silent,
RunCount: entry.runCount,
ErrorCount: entry.errorCount,
SkippedBusy: entry.skippedBusy,
LastRun: entry.lastRun,
LastError: entry.lastError,
}
}
// Pause temporarily stops heartbeat for a project without removing it.
func (hs *HeartbeatScheduler) Pause(project string) bool {
hs.mu.Lock()
defer hs.mu.Unlock()
entry, ok := hs.entries[project]
if !ok {
return false
}
if entry.paused {
return true
}
entry.paused = true
if entry.ticker != nil {
entry.ticker.Stop()
}
slog.Info("heartbeat: paused", "project", project)
hs.persistLocked()
return true
}
// Resume resumes a paused heartbeat.
func (hs *HeartbeatScheduler) Resume(project string) bool {
hs.mu.Lock()
defer hs.mu.Unlock()
entry, ok := hs.entries[project]
if !ok {
return false
}
if !entry.paused {
return true
}
entry.paused = false
interval := time.Duration(entry.config.IntervalMins) * time.Minute
if entry.ticker != nil {
entry.ticker.Reset(interval)
}
slog.Info("heartbeat: resumed", "project", project, "interval", interval)
hs.persistLocked()
return true
}
// SetInterval changes the heartbeat interval for a project.
func (hs *HeartbeatScheduler) SetInterval(project string, mins int) bool {
if mins <= 0 {
return false
}
hs.mu.Lock()
defer hs.mu.Unlock()
entry, ok := hs.entries[project]
if !ok {
return false
}
entry.config.IntervalMins = mins
interval := time.Duration(mins) * time.Minute
if entry.ticker != nil && !entry.paused {
entry.ticker.Reset(interval)
}
slog.Info("heartbeat: interval changed", "project", project, "interval", interval)
hs.persistLocked()
return true
}
// TriggerNow executes a heartbeat immediately (async).
func (hs *HeartbeatScheduler) TriggerNow(project string) bool {
hs.mu.Lock()
entry, ok := hs.entries[project]
hs.mu.Unlock()
if !ok {
return false
}
go hs.execute(entry)
return true
}
// ── persistence ──────────────────────────────────────────────
// loadProjectState reads persisted state for a single project.
// Must NOT hold hs.mu when reading the file (called during Register which already holds it,
// but file I/O here is acceptable because Register is called sequentially at startup).
func (hs *HeartbeatScheduler) loadProjectState(project string) *heartbeatPersisted {
if hs.stateFile == "" {
return nil
}
data, err := os.ReadFile(hs.stateFile)
if err != nil {
return nil
}
var states map[string]*heartbeatPersisted
if err := json.Unmarshal(data, &states); err != nil {
return nil
}
return states[project]
}
// persistLocked saves all project overrides to disk. Caller must hold hs.mu.
func (hs *HeartbeatScheduler) persistLocked() {
if hs.stateFile == "" {
return
}
states := make(map[string]*heartbeatPersisted, len(hs.entries))
needSave := false
for name, entry := range hs.entries {
p := &heartbeatPersisted{Paused: entry.paused}
if entry.config.IntervalMins != entry.origIntervalMins {
p.IntervalMins = entry.config.IntervalMins
}
if p.Paused || p.IntervalMins > 0 {
states[name] = p
needSave = true
}
}
if !needSave {
os.Remove(hs.stateFile)
return
}
data, err := json.MarshalIndent(states, "", " ")
if err != nil {
slog.Error("heartbeat: persist state failed", "error", err)
return
}
if err := os.WriteFile(hs.stateFile, data, 0o644); err != nil {
slog.Error("heartbeat: write state file failed", "error", err)
}
}
// ── ticker loop ──────────────────────────────────────────────
func (hs *HeartbeatScheduler) run(entry *heartbeatEntry) {
for {
select {
case <-hs.stopCh:
return
case <-entry.stopCh:
return
case <-entry.ticker.C:
hs.mu.Lock()
paused := entry.paused
hs.mu.Unlock()
if !paused {
hs.execute(entry)
}
}
}
}
func (hs *HeartbeatScheduler) execute(entry *heartbeatEntry) {
cfg := entry.config
if cfg.OnlyWhenIdle {
session := entry.engine.sessions.GetOrCreateActive(cfg.SessionKey)
if !session.TryLock() {
slog.Debug("heartbeat: session busy, skipping", "project", entry.project, "session_key", cfg.SessionKey)
hs.mu.Lock()
entry.skippedBusy++
hs.mu.Unlock()
return
}
session.Unlock()
}
prompt := cfg.Prompt
if prompt == "" {
prompt = readHeartbeatMD(entry.workDir)
}
if prompt == "" {
prompt = defaultHeartbeatPrompt
}
slog.Info("heartbeat: executing", "project", entry.project, "session_key", cfg.SessionKey, "prompt_len", len(prompt))
timeout := time.Duration(cfg.TimeoutMins) * time.Minute
done := make(chan error, 1)
go func() {
done <- entry.engine.ExecuteHeartbeat(cfg.SessionKey, prompt, cfg.Silent)
}()
var err error
select {
case err = <-done:
case <-time.After(timeout):
err = fmt.Errorf("heartbeat timed out after %v", timeout)
}
hs.mu.Lock()
entry.runCount++
entry.lastRun = time.Now()
if err != nil {
entry.errorCount++
entry.lastError = err.Error()
slog.Error("heartbeat: execution failed", "project", entry.project, "error", err)
} else {
entry.lastError = ""
slog.Info("heartbeat: execution completed", "project", entry.project)
}
hs.mu.Unlock()
}
const defaultHeartbeatPrompt = `This is a periodic heartbeat check. Please briefly review:
- Any pending tasks or unfinished work
- Current project status
If nothing needs attention, respond briefly that all is well.`
func readHeartbeatMD(workDir string) string {
if workDir == "" {
return ""
}
candidates := []string{
filepath.Join(workDir, "HEARTBEAT.md"),
filepath.Join(workDir, "heartbeat.md"),
}
for _, path := range candidates {
data, err := os.ReadFile(path)
if err == nil {
content := strings.TrimSpace(string(data))
if content != "" {
slog.Debug("heartbeat: loaded prompt from file", "path", path)
return content
}
}
}
return ""
}