template-jj/cmd/jj-commitd/main.go
sid deb87b37a9 feat(commitd): legacy cleanup, conflict detection, session inventory, configurable reap/debounce
- On startup, daemon sweeps orphaned /tmp/jj-claude-*-files from pre-daemon
  hook versions and dead-PID tracking files from all repos
- Cross-session file conflicts detected on post-edit and returned in response
  (logged as warnings, non-blocking)
- session-start now returns inventory of all other active sessions with their
  tracked files, PIDs, and ages
- Stale session reap reduced from 2h to 30min default (matches idle shutdown),
  configurable via JJ_HOOK_STALE_MIN env var
- Debounce interval configurable via JJ_HOOK_DEBOUNCE_SEC env var (default 3s,
  max 30s) — previously documented but not implemented
- handleEvent response changed from bare bool to structured JSON with ok/error/
  sessions/conflicts fields (backward compatible — ok field still present)
- 30 Go tests (up from 13): inventory, conflicts, env vars, legacy cleanup
2026-03-29 03:50:06 -06:00

671 lines
18 KiB
Go

// jj-commitd — debounced commit daemon for jj + Claude Code
//
// Listens on a Unix socket for session lifecycle events from the
// jj-hook.sh client script. Manages sessions, tracks edited files,
// and commits changes in debounced batches to avoid racing with
// rapid successive edits.
//
// Architecture:
// hook script (bash) --unix socket--> jj-commitd (this) --exec--> jj CLI
//
// Events (JSON over socket, one per line):
// {"event":"session-start","session_id":"abc123","repo_root":"/path","pid":1234}
// {"event":"post-edit","session_id":"abc123","file":"/path/to/file.ex"}
// {"event":"session-end","session_id":"abc123"}
// {"event":"shutdown"}
//
// The daemon auto-exits after all sessions end + an idle timeout.
//
// Environment variables:
// REPO_ROOT — repo root path (auto-detected from cwd if unset)
// JJ_HOOK_DEBOUNCE_SEC — debounce interval in seconds (default: 3, max: 30)
// JJ_HOOK_STALE_MIN — stale session reap threshold in minutes (default: 30)
package main
import (
"bufio"
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// ── Configuration ───────────────────────────────────────────────
const (
defaultDebounce = 3 * time.Second
quiescenceWindow = 500 * time.Millisecond // must be idle this long before committing
maxDebounce = 30 * time.Second // cap for adaptive/env debounce
idleShutdown = 30 * time.Minute
defaultStaleSessionAge = 30 * time.Minute // reduced from 2h — matches idle shutdown
maxLogSize = 512 * 1024 // 512KB
)
// debounceInterval returns the debounce duration, honoring JJ_HOOK_DEBOUNCE_SEC.
func debounceInterval() time.Duration {
if v := os.Getenv("JJ_HOOK_DEBOUNCE_SEC"); v != "" {
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
d := time.Duration(sec) * time.Second
if d > maxDebounce {
d = maxDebounce
}
return d
}
}
return defaultDebounce
}
// staleSessionAge returns the stale threshold, honoring JJ_HOOK_STALE_MIN.
func staleSessionAge() time.Duration {
if v := os.Getenv("JJ_HOOK_STALE_MIN"); v != "" {
if min, err := strconv.Atoi(v); err == nil && min > 0 {
return time.Duration(min) * time.Minute
}
}
return defaultStaleSessionAge
}
// ── Types ───────────────────────────────────────────────────────
type Event struct {
Event string `json:"event"`
SessionID string `json:"session_id"`
RepoRoot string `json:"repo_root"`
File string `json:"file"`
PID int `json:"pid"`
}
type Session struct {
ID string
RepoRoot string
PID int
Files map[string]bool // tracked files (deduped)
Dirty bool // has unseen edits since last commit
Timer *time.Timer // debounce timer
StartedAt time.Time
LastEditAt time.Time
}
type Daemon struct {
mu sync.Mutex
jjLock sync.Mutex // serializes all jj CLI operations
sessions map[string]*Session // keyed by session_id
listener net.Listener
logFile *os.File
done chan struct{}
}
// ── Daemon lifecycle ────────────────────────────────────────────
func main() {
repoRoot := os.Getenv("REPO_ROOT")
if repoRoot == "" {
// Try to detect from cwd
var err error
repoRoot, err = os.Getwd()
if err != nil {
log.Fatal("cannot determine repo root")
}
}
sockPath := socketPath(repoRoot)
// Clean up stale socket
if _, err := os.Stat(sockPath); err == nil {
// Try connecting — if it fails, the socket is stale
conn, err := net.DialTimeout("unix", sockPath, 100*time.Millisecond)
if err != nil {
os.Remove(sockPath)
} else {
// Another daemon is running
conn.Close()
fmt.Fprintf(os.Stderr, "jj-commitd: already running on %s\n", sockPath)
os.Exit(0)
}
}
d := &Daemon{
sessions: make(map[string]*Session),
done: make(chan struct{}),
}
// Open log file
logPath := fmt.Sprintf("/tmp/jj-commitd-%s.log", repoID(repoRoot))
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err == nil {
d.logFile = f
defer f.Close()
}
d.log("starting daemon repo=%s socket=%s debounce=%s stale=%s",
repoRoot, sockPath, debounceInterval(), staleSessionAge())
// Clean up legacy tracking files from pre-daemon hook versions
d.cleanLegacyTrackingFiles(repoRoot)
// Listen
listener, err := net.Listen("unix", sockPath)
if err != nil {
log.Fatalf("jj-commitd: listen: %v", err)
}
d.listener = listener
defer func() {
listener.Close()
os.Remove(sockPath)
}()
// Handle signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
d.log("received signal, shutting down")
d.shutdown()
}()
// Idle timeout
go d.idleWatchdog()
// Accept loop
d.log("listening on %s", sockPath)
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-d.done:
return
default:
d.log("accept error: %v", err)
continue
}
}
go d.handleConn(conn)
}
}
// ── Connection handling ─────────────────────────────────────────
func (d *Daemon) handleConn(conn net.Conn) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var ev Event
if err := json.Unmarshal([]byte(line), &ev); err != nil {
d.log("bad json: %v", err)
fmt.Fprintf(conn, `{"ok":false,"error":"bad json"}`+"\n")
continue
}
resp := d.handleEvent(ev)
data, _ := json.Marshal(resp)
fmt.Fprintf(conn, "%s\n", data)
}
}
// Response is the JSON response sent back to the hook client.
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Sessions []SessionInfo `json:"sessions,omitempty"` // populated on session-start
Conflicts []ConflictInfo `json:"conflicts,omitempty"` // populated on post-edit
}
// SessionInfo is returned on session-start so agents know who else is active.
type SessionInfo struct {
ID string `json:"id"`
PID int `json:"pid"`
Files []string `json:"files"`
Age string `json:"age"`
}
// ConflictInfo is returned on post-edit when a file is tracked by another session.
type ConflictInfo struct {
File string `json:"file"`
SessionID string `json:"session_id"`
PID int `json:"pid"`
}
func (d *Daemon) handleEvent(ev Event) Response {
switch ev.Event {
case "session-start":
return d.sessionStart(ev)
case "post-edit":
return d.postEdit(ev)
case "session-end":
ok := d.sessionEnd(ev)
return Response{OK: ok}
case "shutdown":
d.log("shutdown requested")
go func() { time.Sleep(100 * time.Millisecond); d.shutdown() }()
return Response{OK: true}
case "status":
d.logStatus()
return Response{OK: true}
default:
d.log("unknown event: %s", ev.Event)
return Response{OK: false, Error: "unknown event"}
}
}
// ── Session lifecycle ───────────────────────────────────────────
func (d *Daemon) sessionStart(ev Event) Response {
d.mu.Lock()
defer d.mu.Unlock()
if _, exists := d.sessions[ev.SessionID]; exists {
d.log("session %s already exists, resetting", ev.SessionID)
}
d.sessions[ev.SessionID] = &Session{
ID: ev.SessionID,
RepoRoot: ev.RepoRoot,
PID: ev.PID,
Files: make(map[string]bool),
StartedAt: time.Now(),
}
// Build inventory of other active sessions
var others []SessionInfo
for id, sess := range d.sessions {
if id == ev.SessionID {
continue
}
files := make([]string, 0, len(sess.Files))
for f := range sess.Files {
files = append(files, f)
}
others = append(others, SessionInfo{
ID: id,
PID: sess.PID,
Files: files,
Age: time.Since(sess.StartedAt).Round(time.Second).String(),
})
}
d.log("session-start id=%s repo=%s pid=%d other_sessions=%d",
ev.SessionID, ev.RepoRoot, ev.PID, len(others))
return Response{OK: true, Sessions: others}
}
func (d *Daemon) postEdit(ev Event) Response {
d.mu.Lock()
defer d.mu.Unlock()
sess, ok := d.sessions[ev.SessionID]
if !ok {
// Auto-create session for robustness (hook may arrive before session-start in edge cases)
sess = &Session{
ID: ev.SessionID,
RepoRoot: ev.RepoRoot,
Files: make(map[string]bool),
StartedAt: time.Now(),
}
d.sessions[ev.SessionID] = sess
d.log("auto-created session %s for post-edit", ev.SessionID)
}
// Make path relative to repo root
relPath := ev.File
if sess.RepoRoot != "" && strings.HasPrefix(ev.File, sess.RepoRoot) {
relPath = strings.TrimPrefix(ev.File, sess.RepoRoot+"/")
}
// Check for cross-session conflicts before tracking
var conflicts []ConflictInfo
for id, other := range d.sessions {
if id == ev.SessionID {
continue
}
if other.Files[relPath] {
conflicts = append(conflicts, ConflictInfo{
File: relPath,
SessionID: id,
PID: other.PID,
})
d.log("conflict: file=%s session=%s also tracked by session=%s (pid=%d)",
relPath, ev.SessionID, id, other.PID)
}
}
sess.Files[relPath] = true
sess.Dirty = true
sess.LastEditAt = time.Now()
// Reset debounce timer
if sess.Timer != nil {
sess.Timer.Stop()
}
sess.Timer = time.AfterFunc(debounceInterval(), func() {
d.commitSession(ev.SessionID)
})
d.log("post-edit session=%s file=%s tracked=%d", ev.SessionID, relPath, len(sess.Files))
return Response{OK: true, Conflicts: conflicts}
}
func (d *Daemon) sessionEnd(ev Event) bool {
d.mu.Lock()
sess, ok := d.sessions[ev.SessionID]
if !ok {
d.mu.Unlock()
d.log("session-end for unknown session %s", ev.SessionID)
return false
}
// Stop debounce timer
if sess.Timer != nil {
sess.Timer.Stop()
}
d.mu.Unlock()
// Final flush commit (synchronous — session-end should wait)
d.commitSession(ev.SessionID)
// Set bookmark (uses jjLock internally via d.jjLocked)
if sess.RepoRoot != "" {
d.jjLocked(sess.RepoRoot, "bookmark", "set", "wip/claude-"+ev.SessionID, "-r", "@-")
}
// Cleanup
d.mu.Lock()
delete(d.sessions, ev.SessionID)
d.mu.Unlock()
d.log("session-end id=%s files_tracked=%d", ev.SessionID, len(sess.Files))
return true
}
// ── Commit logic ────────────────────────────────────────────────
func (d *Daemon) commitSession(sessionID string) {
// Quiescence check: wait until no edits have arrived for quiescenceWindow.
// This prevents committing while a burst of edits (or a formatter) is still active.
for {
d.mu.Lock()
sess, ok := d.sessions[sessionID]
if !ok || !sess.Dirty {
d.mu.Unlock()
return
}
sinceLastEdit := time.Since(sess.LastEditAt)
d.mu.Unlock()
if sinceLastEdit >= quiescenceWindow {
break
}
// Not quiet yet — wait for the remainder of the quiescence window
time.Sleep(quiescenceWindow - sinceLastEdit)
}
d.mu.Lock()
sess, ok := d.sessions[sessionID]
if !ok || !sess.Dirty {
d.mu.Unlock()
return
}
sess.Dirty = false
// Snapshot the file list
files := make([]string, 0, len(sess.Files))
for f := range sess.Files {
files = append(files, f)
}
repoRoot := sess.RepoRoot
d.mu.Unlock()
if len(files) == 0 || repoRoot == "" {
return
}
// Serialize jj operations — prevents concurrent jj invocations from
// different sessions stepping on each other's working copy snapshots.
d.jjLock.Lock()
defer d.jjLock.Unlock()
// Single diff check for all files at once (one jj invocation, not N)
out, err := d.jjOutput(repoRoot, append([]string{"diff", "--summary", "--"}, files...)...)
if err != nil || strings.TrimSpace(out) == "" {
return
}
// Build commit message from tracked file basenames
names := make([]string, 0, len(files))
for _, f := range files {
names = append(names, filepath.Base(f))
}
// Deduplicate basenames (multiple paths may share a basename)
seen := make(map[string]bool)
uniqueNames := make([]string, 0, len(names))
for _, n := range names {
if !seen[n] {
seen[n] = true
uniqueNames = append(uniqueNames, n)
}
}
var summary string
if len(uniqueNames) <= 5 {
summary = strings.Join(uniqueNames, ", ")
} else {
summary = fmt.Sprintf("%s and %d more",
strings.Join(uniqueNames[:5], ", "), len(uniqueNames)-5)
}
msg := fmt.Sprintf("wip(claude:%s): %s", sessionID, summary)
// Commit all tracked files in one operation.
// jj commit with file args only commits changes to those specific files.
args := append([]string{"commit", "-m", msg, "--"}, files...)
if err := d.jj(repoRoot, args...); err != nil {
d.log("commit failed session=%s: %v", sessionID, err)
} else {
d.log("committed session=%s files=%d msg=%s", sessionID, len(files), summary)
}
}
// ── Legacy tracking file cleanup ────────────────────────────────
// cleanLegacyTrackingFiles removes orphaned /tmp/jj-claude-*-files left by
// the pre-daemon hook script. These files can cause stale session artifacts
// that confuse the fallback path if the daemon restarts.
func (d *Daemon) cleanLegacyTrackingFiles(repoRoot string) {
rid := repoID(repoRoot)
pattern := fmt.Sprintf("/tmp/jj-claude-%s-*-files", rid)
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return
}
d.log("found %d legacy tracking files matching %s", len(matches), pattern)
for _, path := range matches {
if err := os.Remove(path); err != nil {
d.log("failed to remove legacy file %s: %v", path, err)
} else {
d.log("removed legacy tracking file: %s", path)
}
}
// Also clean up any PID sidecar files from the legacy system
pidPattern := fmt.Sprintf("/tmp/jj-claude-%s-*-pid", rid)
pidMatches, _ := filepath.Glob(pidPattern)
for _, path := range pidMatches {
os.Remove(path)
}
// Clean tracking files for ALL repos where the PID is dead (not just ours)
allPattern := "/tmp/jj-claude-*-files"
allMatches, _ := filepath.Glob(allPattern)
for _, path := range allMatches {
// Try to find corresponding PID file
pidPath := strings.TrimSuffix(path, "-files") + "-pid"
pidData, err := os.ReadFile(pidPath)
if err != nil {
// No PID file — orphan, remove
os.Remove(path)
d.log("removed orphan tracking file (no pid): %s", path)
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(string(pidData)))
if err != nil {
os.Remove(path)
os.Remove(pidPath)
d.log("removed orphan tracking file (bad pid): %s", path)
continue
}
proc, err := os.FindProcess(pid)
if err != nil || proc.Signal(syscall.Signal(0)) != nil {
os.Remove(path)
os.Remove(pidPath)
d.log("removed dead-pid tracking file: %s (pid=%d)", path, pid)
}
}
}
// ── jj helpers ──────────────────────────────────────────────────
// jjLocked runs a jj command while holding the jj lock.
// Use for one-off jj commands outside of commitSession (which holds its own lock).
func (d *Daemon) jjLocked(repoRoot string, args ...string) error {
d.jjLock.Lock()
defer d.jjLock.Unlock()
return d.jj(repoRoot, args...)
}
func (d *Daemon) jj(repoRoot string, args ...string) error {
cmd := exec.Command("jj", args...)
cmd.Dir = repoRoot
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
d.log("jj %s failed: %s %v", strings.Join(args, " "), string(out), err)
}
return err
}
func (d *Daemon) jjOutput(repoRoot string, args ...string) (string, error) {
cmd := exec.Command("jj", args...)
cmd.Dir = repoRoot
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
return string(out), err
}
// ── Utility ─────────────────────────────────────────────────────
func (d *Daemon) log(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
ts := time.Now().Format("15:04:05")
line := fmt.Sprintf("%s [commitd] %s\n", ts, msg)
if d.logFile != nil {
d.logFile.WriteString(line)
} else {
fmt.Fprint(os.Stderr, line)
}
}
func (d *Daemon) logStatus() {
d.mu.Lock()
defer d.mu.Unlock()
d.log("status: %d active sessions", len(d.sessions))
for id, sess := range d.sessions {
age := time.Since(sess.StartedAt).Round(time.Second)
d.log(" session=%s files=%d dirty=%v pid=%d age=%s",
id, len(sess.Files), sess.Dirty, sess.PID, age)
}
}
func (d *Daemon) idleWatchdog() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-d.done:
return
case <-ticker.C:
d.mu.Lock()
count := len(d.sessions)
d.mu.Unlock()
if count == 0 {
d.log("no active sessions, idle shutdown")
d.shutdown()
return
}
// Reap dead sessions
d.reapDead()
}
}
}
func (d *Daemon) reapDead() {
d.mu.Lock()
defer d.mu.Unlock()
maxAge := staleSessionAge()
for id, sess := range d.sessions {
if sess.PID > 0 {
// Check if PID is alive
proc, err := os.FindProcess(sess.PID)
if err != nil {
d.log("reaping dead session %s (pid=%d not found)", id, sess.PID)
if sess.Timer != nil {
sess.Timer.Stop()
}
delete(d.sessions, id)
continue
}
// On Unix, FindProcess always succeeds. Use kill -0 to check.
if err := proc.Signal(syscall.Signal(0)); err != nil {
d.log("reaping dead session %s (pid=%d dead)", id, sess.PID)
if sess.Timer != nil {
sess.Timer.Stop()
}
delete(d.sessions, id)
continue
}
}
// Reap stale sessions
if time.Since(sess.StartedAt) > maxAge {
d.log("reaping stale session %s (age=%s threshold=%s)", id, time.Since(sess.StartedAt), maxAge)
if sess.Timer != nil {
sess.Timer.Stop()
}
delete(d.sessions, id)
}
}
}
func (d *Daemon) shutdown() {
close(d.done)
if d.listener != nil {
d.listener.Close()
}
}
func socketPath(repoRoot string) string {
return fmt.Sprintf("/tmp/jj-commitd-%s.sock", repoID(repoRoot))
}
func repoID(repoRoot string) string {
h := sha256.Sum256([]byte(repoRoot))
return fmt.Sprintf("%x", h[:6]) // 12 hex chars, collision-safe for local repos
}