diff --git a/bin/agent-dispatch.sh b/bin/agent-dispatch.sh index deb16abe..3250c778 100755 --- a/bin/agent-dispatch.sh +++ b/bin/agent-dispatch.sh @@ -202,6 +202,19 @@ curl -s -X POST "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}/comments" \\ REVIEW CHECKLIST BEFORE YOU PUSH: {review} +COMMIT DISCIPLINE (CRITICAL): +- Commit every 3-5 tool calls. Do NOT wait until the end. +- After every meaningful file change: git add -A && git commit -m "WIP: " +- Before running any destructive command: commit current state first. +- If you are unsure whether to commit: commit. WIP commits are safe. Lost work is not. +- Never use --no-verify. +- The auto-commit-guard is your safety net, but do not rely on it. Commit proactively. + +RECOVERY COMMANDS (if interrupted, another agent can resume): +git log --oneline -10 # see your WIP commits +git diff HEAD~1 # see what the last commit changed +git status # see uncommitted work + RULES: - Do not skip hooks with --no-verify. - Do not silently widen the scope. diff --git a/bin/agent-loop.sh b/bin/agent-loop.sh index d2e56bc0..83e2cf32 100755 --- a/bin/agent-loop.sh +++ b/bin/agent-loop.sh @@ -161,6 +161,14 @@ run_worker() { CYCLE_END=$(date +%s) CYCLE_DURATION=$((CYCLE_END - CYCLE_START)) + # --- Mid-session auto-commit: commit before timeout if work is dirty --- + cd "$worktree" 2>/dev/null || true + # Ensure auto-commit-guard is running + if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then + log "Starting auto-commit-guard daemon" + nohup bash "$(dirname "$0")/auto-commit-guard.sh" 120 "$WORKTREE_BASE" >> "$LOG_DIR/auto-commit-guard.log" 2>&1 & + fi + # Salvage cd "$worktree" 2>/dev/null || true DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') diff --git a/bin/auto-commit-guard.sh b/bin/auto-commit-guard.sh new file mode 100644 index 00000000..e95494d7 --- /dev/null +++ b/bin/auto-commit-guard.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# auto-commit-guard.sh — Background daemon that auto-commits uncommitted work +# +# Usage: auto-commit-guard.sh [interval_seconds] [worktree_base] +# auto-commit-guard.sh # defaults: 120s, ~/worktrees +# auto-commit-guard.sh 60 # check every 60s +# auto-commit-guard.sh 180 ~/my-worktrees +# +# Scans all git repos under the worktree base for uncommitted changes. +# If dirty for >= 1 check cycle, auto-commits with a WIP message. +# Pushes unpushed commits so work is always recoverable from the remote. +# +# Also scans /tmp for orphaned agent workdirs on startup. + +set -uo pipefail + +INTERVAL="${1:-120}" +WORKTREE_BASE="${2:-$HOME/worktrees}" +LOG_DIR="$HOME/.hermes/logs" +LOG="$LOG_DIR/auto-commit-guard.log" +PIDFILE="$LOG_DIR/auto-commit-guard.pid" +ORPHAN_SCAN_DONE="$LOG_DIR/.orphan-scan-done" + +mkdir -p "$LOG_DIR" + +# Single instance guard +if [ -f "$PIDFILE" ]; then + old_pid=$(cat "$PIDFILE") + if kill -0 "$old_pid" 2>/dev/null; then + echo "auto-commit-guard already running (PID $old_pid)" >&2 + exit 0 + fi +fi +echo $$ > "$PIDFILE" +trap 'rm -f "$PIDFILE"' EXIT + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] AUTO-COMMIT: $*" >> "$LOG" +} + +# --- Orphaned workdir scan (runs once on startup) --- +scan_orphans() { + if [ -f "$ORPHAN_SCAN_DONE" ]; then + return 0 + fi + log "Scanning /tmp for orphaned agent workdirs..." + local found=0 + local rescued=0 + + for dir in /tmp/*-work-* /tmp/timmy-burn-* /tmp/tc-burn; do + [ -d "$dir" ] || continue + [ -d "$dir/.git" ] || continue + + found=$((found + 1)) + cd "$dir" 2>/dev/null || continue + + local dirty + dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ") + if [ "${dirty:-0}" -gt 0 ]; then + local branch + branch=$(git branch --show-current 2>/dev/null || echo "orphan") + git add -A 2>/dev/null + if git commit -m "WIP: orphan rescue — $dirty file(s) auto-committed on $(date -u +%Y-%m-%dT%H:%M:%SZ) + +Orphaned workdir detected at $dir. +Branch: $branch +Rescued by auto-commit-guard on startup." 2>/dev/null; then + rescued=$((rescued + 1)) + log "RESCUED: $dir ($dirty files on branch $branch)" + + # Try to push if remote exists + if git remote get-url origin >/dev/null 2>&1; then + git push -u origin "$branch" 2>/dev/null && log "PUSHED orphan rescue: $dir → $branch" || log "PUSH FAILED orphan rescue: $dir (no remote access)" + fi + fi + fi + done + + log "Orphan scan complete: $found workdirs checked, $rescued rescued" + touch "$ORPHAN_SCAN_DONE" +} + +# --- Main guard loop --- +guard_cycle() { + local committed=0 + local scanned=0 + + # Scan worktree base + if [ -d "$WORKTREE_BASE" ]; then + for dir in "$WORKTREE_BASE"/*/; do + [ -d "$dir" ] || continue + [ -d "$dir/.git" ] || continue + + scanned=$((scanned + 1)) + cd "$dir" 2>/dev/null || continue + + local dirty + dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ") + [ "${dirty:-0}" -eq 0 ] && continue + + local branch + branch=$(git branch --show-current 2>/dev/null || echo "detached") + + git add -A 2>/dev/null + if git commit -m "WIP: auto-commit — $dirty file(s) on $branch + +Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ). +Work preserved to prevent loss on crash." 2>/dev/null; then + committed=$((committed + 1)) + log "COMMITTED: $dir ($dirty files, branch $branch)" + + # Push to preserve remotely + if git remote get-url origin >/dev/null 2>&1; then + git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)" + fi + fi + done + fi + + # Also scan /tmp for agent workdirs + for dir in /tmp/*-work-*; do + [ -d "$dir" ] || continue + [ -d "$dir/.git" ] || continue + + scanned=$((scanned + 1)) + cd "$dir" 2>/dev/null || continue + + local dirty + dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ") + [ "${dirty:-0}" -eq 0 ] && continue + + local branch + branch=$(git branch --show-current 2>/dev/null || echo "detached") + + git add -A 2>/dev/null + if git commit -m "WIP: auto-commit — $dirty file(s) on $branch + +Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ). +Agent workdir preserved to prevent loss." 2>/dev/null; then + committed=$((committed + 1)) + log "COMMITTED: $dir ($dirty files, branch $branch)" + + if git remote get-url origin >/dev/null 2>&1; then + git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)" + fi + fi + done + + [ "$committed" -gt 0 ] && log "Cycle done: $scanned scanned, $committed committed" +} + +# --- Entry point --- +log "Starting auto-commit-guard (interval=${INTERVAL}s, worktree=${WORKTREE_BASE})" +scan_orphans + +while true; do + guard_cycle + sleep "$INTERVAL" +done diff --git a/bin/timmy-orchestrator.sh b/bin/timmy-orchestrator.sh index 806a721f..990e83da 100755 --- a/bin/timmy-orchestrator.sh +++ b/bin/timmy-orchestrator.sh @@ -3,7 +3,7 @@ # Uses Hermes CLI plus workforce-manager to triage and review. # Timmy is the brain. Other agents are the hands. -set -uo pipefail +set -uo pipefail\n\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_DIR="$HOME/.hermes/logs" LOG="$LOG_DIR/timmy-orchestrator.log" @@ -40,6 +40,7 @@ gather_state() { > "$state_dir/unassigned.txt" > "$state_dir/open_prs.txt" > "$state_dir/agent_status.txt" + > "$state_dir/uncommitted_work.txt" for repo in $REPOS; do local short=$(echo "$repo" | cut -d/ -f2) @@ -71,6 +72,24 @@ for p in json.load(sys.stdin): tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt" tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt" + # Scan worktrees for uncommitted work + for wt_dir in "$HOME/worktrees"/*/; do + [ -d "$wt_dir" ] || continue + [ -d "$wt_dir/.git" ] || continue + local dirty + dirty=$(cd "$wt_dir" && git status --porcelain 2>/dev/null | wc -l | tr -d " ") + if [ "${dirty:-0}" -gt 0 ]; then + local branch + branch=$(cd "$wt_dir" && git branch --show-current 2>/dev/null || echo "?") + local age="" + local last_commit + last_commit=$(cd "$wt_dir" && git log -1 --format=%ct 2>/dev/null || echo 0) + local now=$(date +%s) + local stale_mins=$(( (now - last_commit) / 60 )) + echo "DIR=$wt_dir BRANCH=$branch DIRTY=$dirty STALE=${stale_mins}m" >> "$state_dir/uncommitted_work.txt" + fi + done + echo "$state_dir" } @@ -81,6 +100,25 @@ run_triage() { log "Cycle: $unassigned_count unassigned, $pr_count open PRs" + # Check for uncommitted work — nag if stale + local uncommitted_count + uncommitted_count=$(wc -l < "$state_dir/uncommitted_work.txt" 2>/dev/null | tr -d " " || echo 0) + if [ "${uncommitted_count:-0}" -gt 0 ]; then + log "WARNING: $uncommitted_count worktree(s) with uncommitted work" + while IFS= read -r line; do + log " UNCOMMITTED: $line" + # Auto-commit stale work (>60 min without commit) + local stale=$(echo "$line" | sed 's/.*STALE=\([0-9]*\)m.*/\1/') + local wt_dir=$(echo "$line" | sed 's/.*DIR=\([^ ]*\) .*/\1/') + if [ "${stale:-0}" -gt 60 ]; then + log " AUTO-COMMITTING stale work in $wt_dir (${stale}m stale)" + (cd "$wt_dir" && git add -A && git commit -m "WIP: orchestrator auto-commit — ${stale}m stale work + +Preserved by timmy-orchestrator to prevent loss." 2>/dev/null && git push 2>/dev/null) && log " COMMITTED: $wt_dir" || log " COMMIT FAILED: $wt_dir" + fi + done < "$state_dir/uncommitted_work.txt" + fi + # If nothing to do, skip the LLM call if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then log "Nothing to triage" @@ -198,6 +236,12 @@ FOOTER log "=== Timmy Orchestrator Started (PID $$) ===" log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI" +# Start auto-commit-guard daemon for work preservation +if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then + nohup bash "$SCRIPT_DIR/auto-commit-guard.sh" 120 >> "$LOG_DIR/auto-commit-guard.log" 2>&1 & + log "Started auto-commit-guard daemon (PID $!)" +fi + WORKFORCE_CYCLE=0 while true; do diff --git a/deploy/auto-commit-guard.plist b/deploy/auto-commit-guard.plist new file mode 100644 index 00000000..9877d734 --- /dev/null +++ b/deploy/auto-commit-guard.plist @@ -0,0 +1,24 @@ + + + + + Label + ai.timmy.auto-commit-guard + ProgramArguments + + /bin/bash + /Users/apayne/.hermes/bin/auto-commit-guard.sh + 120 + + RunAtLoad + + KeepAlive + + StandardOutPath + /Users/apayne/.hermes/logs/auto-commit-guard.stdout.log + StandardErrorPath + /Users/apayne/.hermes/logs/auto-commit-guard.stderr.log + WorkingDirectory + /Users/apayne + +