mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
* fix(core): generation-counted session busy lock with stale-break self-heal and stop-path release Root cause of the busy-lock leak family (#1829, long-standing #1091, and the related #1830/#1832): Session.busy is a bare bool, Unlock is not deferred and is scattered across ~8 call sites — any early return or panic between TryLock and one of those sites leaks the flag forever, queueing every subsequent message until a daemon restart. This change: 1. core/session.go — generation counter: TryLock returns (gen, ok); Unlock/UnlockWithoutUpdate take the gen and a stale gen (lock was broken and re-acquired by a newer turn) is silently dropped, so a late Unlock can never clear the next turn's flag. BreakStaleLock breaks a lock held past a threshold and bumps the generation. ForceUnlock releases unconditionally for teardown paths (/stop). 2. core/engine.go — the turn pipeline keeps its existing deferred fallback (which also covers panics) and now passes the captured gen through every explicit Unlock site. On the next incoming message, if the agent process is dead and the lock has been held past busy_timeout, the dispatch dumps all goroutine stacks, breaks the lock, notifies the user, and processes the message in a fresh turn instead of queueing it behind a dead one. /stop releases the busy lock after teardown (both the kill and the ACP cancel paths), fixing #1830. 3. config — busy_timeout_mins (0 disables, default 2 minutes; the default is deliberately above the 120s stop-hook wait cap). Acceptance criteria (one row per behavior) in the PR description. Fixes #1829 Fixes #1830 * fix(core): harden stale-lock dump handling per review - dumps go to a 0700 subdir of os.TempDir() with 0600 files, never the cwd - dump only when a break actually fired (no megabyte churn while merely busy-but-not-yet-breakable; same-second filename collisions impossible) - zero-padded 13-digit ts-leading filenames: lexicographic = chronological across sessions - prune keeps the 5 most recent; concurrent prunes tolerated - tests: padding boundary, interleaved-session retention, no-ops, TMPDIR e2e - config.example.toml: document busy_timeout_mins - drop the two accidentally committed goroutine dumps - document gen=0 test shim (TODO removal) and zombie-PID fallthrough Addresses P2 #1/#2 and P3 #1/#2 from the review. Co-Authored-By: Claude Code <noreply@anthropic.com> * style: apply De Morgan's law in padding test (staticcheck QF1001) Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: retrigger after cron timing flake (TestCronScheduler_MultipleJobsAllFire; PR does not touch core/cron*) --------- Co-authored-by: ce <ce@local> Co-authored-by: CE <ce@CEdeMacBook-Pro-2.local> Co-authored-by: Claude Code <noreply@anthropic.com>
335 lines
8.8 KiB
Go
335 lines
8.8 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// WebhookServer exposes an HTTP endpoint for external systems
|
|
// (git hooks, CI/CD, file watchers, etc.) to trigger agent or shell actions.
|
|
type WebhookServer struct {
|
|
port int
|
|
token string
|
|
path string
|
|
server *http.Server
|
|
engines map[string]*Engine
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// WebhookRequest is the JSON body for POST /hook.
|
|
type WebhookRequest struct {
|
|
Event string `json:"event,omitempty"` // event name for logging (e.g. "git:commit")
|
|
Project string `json:"project,omitempty"` // target project; optional if single project
|
|
SessionKey string `json:"session_key"` // target session key (required)
|
|
Prompt string `json:"prompt,omitempty"` // agent prompt (mutually exclusive with exec)
|
|
Exec string `json:"exec,omitempty"` // shell command (mutually exclusive with prompt)
|
|
WorkDir string `json:"work_dir,omitempty"` // working dir for exec
|
|
Silent bool `json:"silent,omitempty"` // suppress notification
|
|
Payload any `json:"payload,omitempty"` // arbitrary extra data; appended to prompt context
|
|
}
|
|
|
|
func NewWebhookServer(port int, token, path string) *WebhookServer {
|
|
if port <= 0 {
|
|
port = 9111
|
|
}
|
|
if path == "" {
|
|
path = "/hook"
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
return &WebhookServer{
|
|
port: port,
|
|
token: token,
|
|
path: path,
|
|
engines: make(map[string]*Engine),
|
|
}
|
|
}
|
|
|
|
func (ws *WebhookServer) RegisterEngine(name string, e *Engine) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
ws.engines[name] = e
|
|
}
|
|
|
|
func (ws *WebhookServer) Start() {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(ws.path, ws.handleHook)
|
|
|
|
addr := fmt.Sprintf(":%d", ws.port)
|
|
ws.server = &http.Server{Addr: addr, Handler: mux}
|
|
|
|
go func() {
|
|
slog.Info("webhook: server started", "addr", addr, "path", ws.path)
|
|
if err := ws.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("webhook: server error", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (ws *WebhookServer) Stop() {
|
|
if ws.server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = ws.server.Shutdown(ctx)
|
|
}
|
|
}
|
|
|
|
func (ws *WebhookServer) handleHook(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if !ws.authenticate(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req WebhookRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.SessionKey == "" {
|
|
http.Error(w, "session_key is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Prompt == "" && req.Exec == "" {
|
|
http.Error(w, "either prompt or exec is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Prompt != "" && req.Exec != "" {
|
|
http.Error(w, "prompt and exec are mutually exclusive", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
engine, err := ws.resolveEngine(req.Project)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
eventName := req.Event
|
|
if eventName == "" {
|
|
eventName = "webhook"
|
|
}
|
|
|
|
slog.Info("webhook: received",
|
|
"event", eventName,
|
|
"project", req.Project,
|
|
"session_key", req.SessionKey,
|
|
"has_prompt", req.Prompt != "",
|
|
"has_exec", req.Exec != "",
|
|
)
|
|
|
|
if req.Exec != "" {
|
|
go ws.executeShell(engine, req, eventName)
|
|
} else {
|
|
prompt := req.Prompt
|
|
if req.Payload != nil {
|
|
if payloadJSON, err := json.Marshal(req.Payload); err == nil {
|
|
prompt += "\n\nContext:\n```json\n" + string(payloadJSON) + "\n```"
|
|
}
|
|
}
|
|
go ws.executePrompt(engine, req.SessionKey, prompt, req.Silent, eventName)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "accepted",
|
|
"event": eventName,
|
|
})
|
|
}
|
|
|
|
func (ws *WebhookServer) authenticate(r *http.Request) bool {
|
|
if ws.token == "" {
|
|
return true
|
|
}
|
|
|
|
// Check Authorization: Bearer <token>
|
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
got := auth[7:]
|
|
return subtle.ConstantTimeCompare([]byte(got), []byte(ws.token)) == 1
|
|
}
|
|
}
|
|
|
|
// Check X-Webhook-Token header
|
|
if tok := r.Header.Get("X-Webhook-Token"); tok != "" {
|
|
return subtle.ConstantTimeCompare([]byte(tok), []byte(ws.token)) == 1
|
|
}
|
|
|
|
// Check query parameter as fallback
|
|
if tok := r.URL.Query().Get("token"); tok != "" {
|
|
return subtle.ConstantTimeCompare([]byte(tok), []byte(ws.token)) == 1
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (ws *WebhookServer) resolveEngine(project string) (*Engine, error) {
|
|
ws.mu.RLock()
|
|
defer ws.mu.RUnlock()
|
|
|
|
if project != "" {
|
|
e, ok := ws.engines[project]
|
|
if !ok {
|
|
return nil, fmt.Errorf("project %q not found", project)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
if len(ws.engines) == 1 {
|
|
for _, e := range ws.engines {
|
|
return e, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("project is required (multiple projects configured)")
|
|
}
|
|
|
|
func (ws *WebhookServer) executePrompt(engine *Engine, sessionKey, prompt string, silent bool, event string) {
|
|
platformName := ""
|
|
if idx := strings.Index(sessionKey, ":"); idx > 0 {
|
|
platformName = sessionKey[:idx]
|
|
}
|
|
|
|
var targetPlatform Platform
|
|
for _, p := range engine.platforms {
|
|
if p.Name() == platformName {
|
|
targetPlatform = p
|
|
break
|
|
}
|
|
}
|
|
if targetPlatform == nil {
|
|
slog.Error("webhook: platform not found", "event", event, "platform", platformName)
|
|
return
|
|
}
|
|
|
|
rc, ok := targetPlatform.(ReplyContextReconstructor)
|
|
if !ok {
|
|
slog.Error("webhook: platform does not support proactive messaging", "event", event, "platform", platformName)
|
|
return
|
|
}
|
|
|
|
replyCtx, err := rc.ReconstructReplyCtx(sessionKey)
|
|
if err != nil {
|
|
slog.Error("webhook: reconstruct reply context failed", "event", event, "error", err)
|
|
return
|
|
}
|
|
|
|
if !silent {
|
|
engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 %s", event))
|
|
}
|
|
|
|
msg := &Message{
|
|
SessionKey: sessionKey,
|
|
Platform: platformName,
|
|
UserID: "webhook",
|
|
UserName: "webhook",
|
|
Content: prompt,
|
|
ReplyCtx: replyCtx,
|
|
}
|
|
|
|
session := engine.sessions.GetOrCreateActive(sessionKey)
|
|
lockGen, locked := session.TryLock()
|
|
if !locked {
|
|
slog.Warn("webhook: session busy, queued prompt dropped", "event", event, "session_key", sessionKey)
|
|
if !silent {
|
|
engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 ⚠️ session busy, skipped: %s", event))
|
|
}
|
|
return
|
|
}
|
|
|
|
engine.processInteractiveMessage(targetPlatform, msg, session, lockGen)
|
|
slog.Info("webhook: prompt executed", "event", event, "session_key", sessionKey)
|
|
}
|
|
|
|
const webhookShellTimeout = 5 * time.Minute
|
|
|
|
func (ws *WebhookServer) executeShell(engine *Engine, req WebhookRequest, event string) {
|
|
sessionKey := req.SessionKey
|
|
platformName := ""
|
|
if idx := strings.Index(sessionKey, ":"); idx > 0 {
|
|
platformName = sessionKey[:idx]
|
|
}
|
|
|
|
var targetPlatform Platform
|
|
for _, p := range engine.platforms {
|
|
if p.Name() == platformName {
|
|
targetPlatform = p
|
|
break
|
|
}
|
|
}
|
|
if targetPlatform == nil {
|
|
slog.Error("webhook: platform not found for shell exec", "event", event, "platform", platformName)
|
|
return
|
|
}
|
|
|
|
rc, ok := targetPlatform.(ReplyContextReconstructor)
|
|
if !ok {
|
|
slog.Error("webhook: platform does not support proactive messaging", "event", event, "platform", platformName)
|
|
return
|
|
}
|
|
|
|
replyCtx, err := rc.ReconstructReplyCtx(sessionKey)
|
|
if err != nil {
|
|
slog.Error("webhook: reconstruct reply context failed", "event", event, "error", err)
|
|
return
|
|
}
|
|
|
|
if !req.Silent {
|
|
engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 %s: `%s`", event, truncateStr(req.Exec, 60)))
|
|
}
|
|
|
|
workDir := req.WorkDir
|
|
if workDir == "" {
|
|
if wd, ok := engine.agent.(interface{ GetWorkDir() string }); ok {
|
|
workDir = wd.GetWorkDir()
|
|
}
|
|
}
|
|
if workDir == "" {
|
|
workDir, _ = os.Getwd()
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), webhookShellTimeout)
|
|
defer cancel()
|
|
|
|
cmd := shellExecCommand(ctx, engine.shell, engine.shellFlag, engine.shellProfile, req.Exec)
|
|
cmd.Dir = workDir
|
|
output, execErr := cmd.CombinedOutput()
|
|
|
|
result := strings.TrimSpace(string(output))
|
|
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 ⚠️ timeout: `%s`", truncateStr(req.Exec, 60)))
|
|
return
|
|
}
|
|
|
|
if execErr != nil {
|
|
msg := fmt.Sprintf("🪝 ❌ `%s`", truncateStr(req.Exec, 60))
|
|
if result != "" {
|
|
msg += "\n\n" + truncateStr(result, 3000)
|
|
}
|
|
msg += "\n\nerror: " + execErr.Error()
|
|
engine.send(targetPlatform, replyCtx, msg)
|
|
} else {
|
|
if result == "" {
|
|
result = "(no output)"
|
|
}
|
|
engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 ✅ `%s`\n\n%s", truncateStr(req.Exec, 60), truncateStr(result, 3000)))
|
|
}
|
|
|
|
slog.Info("webhook: shell executed", "event", event, "session_key", sessionKey, "success", execErr == nil)
|
|
}
|