Files
wehub-resource-sync 26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:16 +08:00

613 lines
20 KiB
Go

package daemon
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sync"
"time"
"github.com/kunchenguid/no-mistakes/internal/agent"
"github.com/kunchenguid/no-mistakes/internal/config"
"github.com/kunchenguid/no-mistakes/internal/db"
"github.com/kunchenguid/no-mistakes/internal/git"
"github.com/kunchenguid/no-mistakes/internal/ipc"
"github.com/kunchenguid/no-mistakes/internal/paths"
"github.com/kunchenguid/no-mistakes/internal/shellenv"
"github.com/kunchenguid/no-mistakes/internal/telemetry"
"github.com/kunchenguid/no-mistakes/internal/types"
)
var applyShellEnvToProcess = shellenv.ApplyToProcess
var createDaemonPIDTempFile = os.CreateTemp
var renameDaemonPIDFile = os.Rename
// Run starts the daemon process. It blocks until a shutdown signal is received
// or the shutdown IPC method is called. This is called via the hidden
// `no-mistakes daemon run` entrypoint used by managed and detached services.
func Run() error {
p, err := paths.New()
if err != nil {
return fmt.Errorf("resolve paths: %w", err)
}
if err := p.EnsureDirs(); err != nil {
return fmt.Errorf("create directories: %w", err)
}
if err := prepareDaemonEnvironment(); err != nil {
return err
}
// Ensure default config exists, then load it.
config.EnsureDefaultGlobalConfig(p.ConfigFile())
globalCfg, err := config.LoadGlobal(p.ConfigFile())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
initLogger(globalCfg.LogLevel)
d, err := db.Open(p.DB())
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer d.Close()
return RunWithResources(p, d)
}
func prepareDaemonEnvironment() error {
nmHome := os.Getenv("NM_HOME")
for _, key := range []string{
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_ENTRY_POINT",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_SESSION_ACCESS_TOKEN",
} {
if err := os.Unsetenv(key); err != nil {
return fmt.Errorf("unset %s: %w", key, err)
}
}
if err := applyShellEnvToProcess(); err != nil {
return fmt.Errorf("apply login shell environment: %w", err)
}
if nmHome != "" {
if err := os.Setenv("NM_HOME", nmHome); err != nil {
return fmt.Errorf("restore NM_HOME: %w", err)
}
}
logDaemonPathSummary()
return nil
}
// logDaemonPathSummary records the effective PATH at daemon startup so that
// "agent binary not in PATH" failures (see #143) can be diagnosed from the
// daemon log alone. We emit it via slog.Default because this runs before
// initLogger; the default handler still writes to stderr, which launchd and
// systemd redirect into the daemon log file.
func logDaemonPathSummary() {
path := os.Getenv("PATH")
entries := 0
if path != "" {
entries = len(filepath.SplitList(path))
}
slog.Info("daemon environment ready",
"path_entries", entries,
"path", path,
)
}
// initLogger sets up the global slog handler with the configured log level.
func initLogger(level string) {
lvl := config.ParseLogLevel(level)
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
slog.SetDefault(slog.New(handler))
}
// RunWithResources starts the daemon with pre-initialized paths and DB.
// Useful for testing where the caller controls resource setup.
func RunWithResources(p *paths.Paths, d *db.DB) error {
return RunWithOptions(p, d, nil)
}
// RunWithOptions starts the daemon with optional overrides.
// stepFactory overrides the default pipeline steps (for testing).
func RunWithOptions(p *paths.Paths, d *db.DB, stepFactory StepFactory) error {
// Singleton guard: only one live daemon may own this NM_HOME at a time.
// This must be acquired before recoverOnStartup (global stale-run
// recovery and orphan-worktree cleanup) and before the IPC socket is
// bound, and held for the rest of the process lifetime - otherwise a
// second daemon racing to start against the same root can mark another
// live daemon's active runs as crashed and delete worktrees out from
// under it (see AGENTS.md "Daemon Singleton Lock"). Covers both the
// `daemon start` -> detached child path and a direct `daemon run --root`
// invocation, since both funnel through here.
lock, err := acquireSingletonLock(p)
if err != nil {
return err
}
defer lock.Release()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
defer cancel()
_ = telemetry.Close(ctx)
}()
// Point the agent package at our PID tracking dir so any managed
// servers we spawn from here on leave crash-recovery breadcrumbs.
agent.SetServerPIDsDir(p.ServerPIDsDir())
defer agent.SetServerPIDsDir("")
mgr := NewRunManager(d, p, stepFactory)
// Recover stale runs from a previous daemon crash.
recoverOnStartup(d, p, mgr)
srv := ipc.NewServer()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var shutdownOnce sync.Once
doShutdown := func(reason string) {
shutdownOnce.Do(func() {
slog.Info("shutting down", "reason", reason)
mgr.Shutdown()
cancel()
srv.Close()
})
}
registerHandlers(srv, mgr, d, func() { doShutdown("ipc request") })
// Write PID file
pidPath := p.PIDFile()
pidRecord, err := currentDaemonPIDRecord(processStartTime, func() time.Time { return time.Now().UTC() })
if err != nil {
return fmt.Errorf("build pid file: %w", err)
}
if err := writeDaemonPIDFile(pidPath, pidRecord); err != nil {
return fmt.Errorf("write pid file: %w", err)
}
defer func() {
if pidData, err := os.ReadFile(pidPath); err == nil {
if current, readErr := readDaemonPIDFileData(pidData); readErr == nil && current.PID == pidRecord.PID && current.StartedAt.Equal(pidRecord.StartedAt) {
os.Remove(pidPath)
}
}
}()
// Handle OS signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, daemonSignals()...)
defer signal.Stop(sigCh)
go func() {
select {
case sig := <-sigCh:
doShutdown(sig.String())
case <-ctx.Done():
}
}()
socketPath := p.Socket()
slog.Info("daemon starting", "socket", socketPath, "pid", os.Getpid())
if err := srv.Serve(socketPath); err != nil {
return fmt.Errorf("serve: %w", err)
}
doShutdown("listener closed")
// Clean up socket file only if we still own the PID file.
// A new daemon may have already replaced the socket.
if pidData, err := os.ReadFile(pidPath); err == nil {
if current, readErr := readDaemonPIDFileData(pidData); readErr == nil && current.PID == pidRecord.PID && current.StartedAt.Equal(pidRecord.StartedAt) {
os.Remove(pidPath)
os.Remove(socketPath)
}
}
slog.Info("daemon stopped")
return nil
}
func currentDaemonPIDRecord(startTime func(int) (time.Time, error), now func() time.Time) (daemonPIDFile, error) {
pid := os.Getpid()
startedAt, err := startTime(pid)
if err != nil {
startedAt = agent.CurrentProcessStartedAt()
if startedAt.IsZero() {
startedAt = now()
}
}
return daemonPIDFile{PID: pid, StartedAt: startedAt.UTC()}, nil
}
func writeDaemonPIDFile(path string, record daemonPIDFile) error {
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal pid file: %w", err)
}
tmp, err := createDaemonPIDTempFile(filepath.Dir(path), filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("create pid temp file: %w", err)
}
tmpPath := tmp.Name()
defer func() {
if tmpPath != "" {
_ = os.Remove(tmpPath)
}
}()
if err := tmp.Chmod(0o644); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod pid temp file: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write pid temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close pid temp file: %w", err)
}
if err := renameDaemonPIDFile(tmpPath, path); err != nil {
return fmt.Errorf("rename pid file: %w", err)
}
tmpPath = ""
return nil
}
// recoverOnStartup cleans up after a previous daemon crash by marking stale
// runs/steps as failed, killing orphaned managed-server subprocesses
// (opencode, rovodev), and removing orphaned worktree directories. It also
// best-effort migrates gate bare repos in place so older installs pick up
// the per-worktree hookspath isolation introduced for issue #122 when Git
// supports config --worktree.
func recoverOnStartup(d *db.DB, p *paths.Paths, mgr *RunManager) {
reapOrphanedServers(p)
migrateGateConfigs(context.Background(), p)
plans := mgr.recoverableParkedRuns(context.Background())
preserved := make(map[string]struct{}, len(plans))
for _, plan := range plans {
preserved[plan.run.ID] = struct{}{}
}
count, err := d.RecoverStaleRunsExcept("daemon crashed during execution", preserved)
if err != nil {
slog.Error("failed to recover stale runs", "error", err)
for _, plan := range plans {
_ = plan.agent.Close()
}
return
}
if count > 0 {
slog.Info("recovered stale runs from previous crash", "count", count)
}
cleanupOrphanWorktrees(d, p)
mgr.resumeRecoveredRuns(plans)
}
// cleanupOrphanWorktrees removes worktree directories left behind by runs
// that are no longer active. It is DB-aware: a worktree is only removed when
// its run row is terminal, or when there is no matching run row at all.
// This is what keeps cleanup from deleting the checkout out from under a
// pipeline that is still actually running (see skipWorktreeCleanup).
// Called from recoverOnStartup after
// RecoverStaleRuns, so in the normal single-daemon path every run this loop
// sees has already been resolved to a terminal status; it is factored out
// separately so it can also be exercised - and its DB-aware skip behavior
// verified - independent of stale-run recovery's side effects.
func cleanupOrphanWorktrees(d *db.DB, p *paths.Paths) {
wtRoot := p.WorktreesDir()
entries, err := os.ReadDir(wtRoot)
if err != nil {
return // directory may not exist yet
}
ctx := context.Background()
for _, repoEntry := range entries {
if !repoEntry.IsDir() {
continue
}
repoPath := filepath.Join(wtRoot, repoEntry.Name())
gateDir := p.RepoDir(repoEntry.Name())
runEntries, err := os.ReadDir(repoPath)
if err != nil {
continue
}
for _, runEntry := range runEntries {
if !runEntry.IsDir() {
continue
}
runID := runEntry.Name()
wtPath := filepath.Join(repoPath, runID)
if skip, reason := skipWorktreeCleanup(d, runID); skip {
slog.Info("skipping worktree cleanup", "path", wtPath, "reason", reason)
continue
}
if err := git.WorktreeRemove(ctx, gateDir, wtPath); err != nil {
slog.Warn("git worktree remove failed, falling back to os.RemoveAll", "path", wtPath, "error", err)
if err := os.RemoveAll(wtPath); err != nil {
slog.Warn("failed to remove orphaned worktree", "path", wtPath, "error", err)
}
} else {
slog.Info("removed orphaned worktree", "path", wtPath)
}
}
// Remove empty repo dir.
os.Remove(repoPath)
}
}
// skipWorktreeCleanup reports whether the worktree directory for runID must
// be left alone during startup cleanup. It is the active-run guard that
// makes cleanup safe even if the singleton lock were ever bypassed: a
// worktree is never removed while its run is still pending or running -
// only terminal-run leftovers or directories with no matching run row at
// all (e.g. a directory left behind after its run row was independently
// pruned) are eligible for removal. RunManager.startRun always inserts the
// run row before creating the worktree directory, so on a single daemon a
// "no matching run" directory is never one whose insert simply hasn't landed
// yet - it is safe to remove immediately.
func skipWorktreeCleanup(d *db.DB, runID string) (bool, string) {
run, err := d.GetRun(runID)
if err != nil {
return true, fmt.Sprintf("failed to look up run %s: %v", runID, err)
}
if run != nil && (run.Status == types.RunPending || run.Status == types.RunRunning) {
return true, fmt.Sprintf("run %s is %s", runID, run.Status)
}
return false, ""
}
// migrateGateConfigs walks every bare repo under p.ReposDir() and refreshes
// no-mistakes-managed post-receive hooks, enables push options, and applies
// git.IsolateHooksPath. The operation is idempotent: bare repos already
// configured by gate.Init are left effectively unchanged, and custom hooks are
// preserved. Older bare repos (from before issue #122 was fixed) best-effort
// get their managed hook updated and per-worktree hookspath pinned when Git
// supports config --worktree, so subsequent husky-style writes to shared local
// config can no longer disable the post-receive hook.
func migrateGateConfigs(ctx context.Context, p *paths.Paths) {
entries, err := os.ReadDir(p.ReposDir())
if err != nil {
// Repos dir may not exist yet on a fresh install.
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
bareDir := filepath.Join(p.ReposDir(), entry.Name())
if _, err := git.RefreshManagedPostReceiveHook(bareDir); err != nil {
slog.Warn("refresh gate post-receive hook failed", "bare", bareDir, "error", err)
}
if _, err := git.Run(ctx, bareDir, "config", "receive.advertisePushOptions", "true"); err != nil {
slog.Warn("enable gate push options failed", "bare", bareDir, "error", err)
}
if err := git.IsolateHooksPath(ctx, bareDir); err != nil {
slog.Warn("isolate gate hooks path failed", "bare", bareDir, "error", err)
}
}
}
func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func()) {
srv.Handle(ipc.MethodHealth, func(_ context.Context, _ json.RawMessage) (interface{}, error) {
return &ipc.HealthResult{Status: "ok"}, nil
})
srv.Handle(ipc.MethodShutdown, func(_ context.Context, _ json.RawMessage) (interface{}, error) {
go shutdown()
return &ipc.ShutdownResult{OK: true}, nil
})
srv.Handle(ipc.MethodGetRun, func(_ context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.GetRunParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
run, err := d.GetRun(p.RunID)
if err != nil {
return nil, fmt.Errorf("get run: %w", err)
}
if run == nil {
return nil, fmt.Errorf("run not found: %s", p.RunID)
}
steps, err := d.GetStepsByRun(p.RunID)
if err != nil {
return nil, fmt.Errorf("get steps: %w", err)
}
return &ipc.GetRunResult{Run: runToInfo(d, run, steps)}, nil
})
srv.Handle(ipc.MethodGetRuns, func(_ context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.GetRunsParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
runs, err := d.GetRunsByRepo(p.RepoID)
if err != nil {
return nil, fmt.Errorf("get runs: %w", err)
}
infos := make([]ipc.RunInfo, 0, len(runs))
for _, r := range runs {
steps, err := d.GetStepsByRun(r.ID)
if err != nil {
return nil, fmt.Errorf("get steps for run %s: %w", r.ID, err)
}
infos = append(infos, *runToInfo(d, r, steps))
}
return &ipc.GetRunsResult{Runs: infos}, nil
})
srv.Handle(ipc.MethodGetRunsForHead, func(_ context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.GetRunsForHeadParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
runs, err := d.GetRunsByRepoHead(p.RepoID, p.Branch, p.HeadSHA)
if err != nil {
return nil, fmt.Errorf("get runs for head: %w", err)
}
infos := make([]ipc.RunInfo, 0, len(runs))
for _, r := range runs {
steps, err := d.GetStepsByRun(r.ID)
if err != nil {
return nil, fmt.Errorf("get steps for run %s: %w", r.ID, err)
}
infos = append(infos, *runToInfo(d, r, steps))
}
return &ipc.GetRunsResult{Runs: infos}, nil
})
srv.Handle(ipc.MethodGetActiveRun, func(_ context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.GetActiveRunParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
run, err := d.GetActiveRun(p.RepoID, p.Branch)
if err != nil {
return nil, fmt.Errorf("get active run: %w", err)
}
if run == nil {
return &ipc.GetActiveRunResult{}, nil
}
steps, err := d.GetStepsByRun(run.ID)
if err != nil {
return nil, fmt.Errorf("get steps: %w", err)
}
return &ipc.GetActiveRunResult{Run: runToInfo(d, run, steps)}, nil
})
srv.Handle(ipc.MethodRerun, func(ctx context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.RerunParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
runID, err := mgr.HandleRerun(ctx, p.RepoID, p.Branch, p.SkipSteps, p.Intent)
if err != nil {
return nil, err
}
return &ipc.RerunResult{RunID: runID}, nil
})
srv.Handle(ipc.MethodPushReceived, func(ctx context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.PushReceivedParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
slog.Info("push received", "ref", p.Ref, "old", p.Old, "new", p.New, "gate", p.Gate)
runID, err := mgr.HandlePushReceived(ctx, &p)
if err != nil {
return nil, err
}
return &ipc.PushReceivedResult{RunID: runID}, nil
})
srv.Handle(ipc.MethodRespond, func(_ context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.RespondParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if err := mgr.HandleRespondWithOverrides(p.RunID, p.Step, p.Action, p.FindingIDs, p.Instructions, p.AddedFindings); err != nil {
return nil, err
}
return &ipc.RespondResult{OK: true}, nil
})
srv.Handle(ipc.MethodCancelRun, func(_ context.Context, params json.RawMessage) (interface{}, error) {
var p ipc.CancelRunParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if err := mgr.HandleCancel(p.RunID); err != nil {
return nil, err
}
return &ipc.CancelRunResult{OK: true}, nil
})
srv.HandleStream(ipc.MethodSubscribe, func(ctx context.Context, params json.RawMessage, send func(interface{}) error) error {
var p ipc.SubscribeParams
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("invalid params: %w", err)
}
ch, unsub := mgr.Subscribe(p.RunID)
defer unsub()
for {
select {
case event, ok := <-ch:
if !ok {
return nil // channel closed (run completed)
}
if err := send(event); err != nil {
return err // client disconnected
}
case <-ctx.Done():
return ctx.Err()
}
}
})
}
func runToInfo(d *db.DB, r *db.Run, steps []*db.StepResult) *ipc.RunInfo {
info := &ipc.RunInfo{
ID: r.ID,
RepoID: r.RepoID,
Branch: r.Branch,
HeadSHA: r.HeadSHA,
BaseSHA: r.BaseSHA,
Status: r.Status,
PRURL: r.PRURL,
Error: r.Error,
AwaitingAgent: r.AwaitingAgentSince != nil,
AwaitingAgentSince: r.AwaitingAgentSince,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if len(steps) > 0 {
info.Steps = make([]ipc.StepResultInfo, 0, len(steps))
for _, s := range steps {
info.Steps = append(info.Steps, stepToInfo(d, s))
}
}
return info
}
func stepToInfo(d *db.DB, s *db.StepResult) ipc.StepResultInfo {
info := ipc.StepResultInfo{
ID: s.ID,
RunID: s.RunID,
StepName: s.StepName,
StepOrder: s.StepOrder,
Status: s.Status,
ExitCode: s.ExitCode,
DurationMS: s.DurationMS,
FindingsJSON: s.FindingsJSON,
Error: s.Error,
StartedAt: s.StartedAt,
CompletedAt: s.CompletedAt,
LastActivityAt: s.LastActivityAt,
LastActivity: s.LastActivity,
AgentPID: s.AgentPID,
}
if s.AutoFixLimit != nil {
info.AutoFixLimit = *s.AutoFixLimit
}
if stats, err := d.StepFindingStats(s); err == nil {
info.ReportedFindings = stats.ReportedFindings
info.FixedFindings = stats.FixedFindings
}
if summaries, err := d.StepFixSummaries(s.ID); err == nil {
info.FixSummaries = summaries
}
if rounds, err := d.StepRoundStats(s.ID); err == nil {
info.RoundCount = rounds.TotalRounds
info.FixRoundCount = rounds.FixRounds
info.PendingFixSource = rounds.PendingFixSource
}
return info
}