diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 837e43d09..a0f0712aa 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -8,9 +8,9 @@ on: workflow_dispatch: env: - # Force wrapper mode (not async/daemon) so post-commit hooks fire - # synchronously and attribution notes are written in-process. - GIT_AI_ASYNC_MODE: "false" + # Async mode is now fully supported by the e2e tests: a per-test daemon + # is started in setup() and the wrapper polls for authorship notes. + GIT_AI_ASYNC_MODE: "true" jobs: e2e-tests: diff --git a/.github/workflows/install-scripts-local.yml b/.github/workflows/install-scripts-local.yml index 34a8e2642..dbe0dec83 100644 --- a/.github/workflows/install-scripts-local.yml +++ b/.github/workflows/install-scripts-local.yml @@ -20,9 +20,12 @@ on: workflow_dispatch: env: - # Force wrapper mode (not async/daemon) so post-commit hooks fire - # synchronously and attribution notes are written in-process. - GIT_AI_ASYNC_MODE: "false" + # Async mode: each Unix job starts a per-job daemon via + # start-async-daemon.sh which exports GIT_AI_TEST_FORCE_TTY and + # GIT_AI_POST_COMMIT_TIMEOUT_MS so the wrapper polls for authorship + # notes after commits. Windows jobs override this to "false" because + # Unix-domain sockets are not available there. + GIT_AI_ASYNC_MODE: "true" jobs: install-local-unix: @@ -135,6 +138,11 @@ jobs: cd /tmp/e2e-test-repo "$INSTALL_DIR/git-ai" install + - name: Start async daemon + run: | + source "$GITHUB_WORKSPACE/scripts/nightly/start-async-daemon.sh" \ + "$HOME/.git-ai/bin/git-ai" + - name: Run synthetic agent commit env: RESULTS_DIR: /tmp/e2e-results/${{ matrix.agent.name }} @@ -151,6 +159,12 @@ jobs: bash "$GITHUB_WORKSPACE/scripts/nightly/verify-synthetic-attribution.sh" \ "${{ matrix.agent.name }}" /tmp/e2e-test-repo + - name: Stop async daemon + if: always() + run: | + bash "$GITHUB_WORKSPACE/scripts/nightly/stop-async-daemon.sh" \ + "$HOME/.git-ai/bin/git-ai" + - name: Upload E2E test results if: always() uses: actions/upload-artifact@v4 @@ -163,6 +177,9 @@ jobs: install-local-windows: name: E2E ${{ matrix.agent.name }} on windows-latest runs-on: windows-latest + env: + # Windows does not support Unix-domain sockets for the async daemon. + GIT_AI_ASYNC_MODE: "false" if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || diff --git a/.github/workflows/nightly-agent-integration.yml b/.github/workflows/nightly-agent-integration.yml index ca5f927e2..4140b0850 100644 --- a/.github/workflows/nightly-agent-integration.yml +++ b/.github/workflows/nightly-agent-integration.yml @@ -20,9 +20,11 @@ on: env: GIT_AI_DEBUG: "1" CARGO_INCREMENTAL: "0" - # Force wrapper mode (not async/daemon) so post-commit hooks fire - # synchronously and attribution notes are written in-process. - GIT_AI_ASYNC_MODE: "false" + # Async mode: each job starts a per-job daemon via + # start-async-daemon.sh which exports GIT_AI_TEST_FORCE_TTY and + # GIT_AI_POST_COMMIT_TIMEOUT_MS so the wrapper polls for authorship + # notes after commits. + GIT_AI_ASYNC_MODE: "true" jobs: # ── Version Resolution ───────────────────────────────────────────────────── @@ -202,6 +204,11 @@ jobs: cd /tmp/test-repo git-ai install + - name: Start async daemon + run: | + source "$GITHUB_WORKSPACE/scripts/nightly/start-async-daemon.sh" \ + "$GITHUB_WORKSPACE/target/release/git-ai" + - name: Verify hook wiring run: | export PATH="$GITHUB_WORKSPACE/target/release:$PATH" @@ -215,6 +222,12 @@ jobs: "${{ matrix.agent }}" \ /tmp/test-repo + - name: Stop async daemon + if: always() + run: | + bash "$GITHUB_WORKSPACE/scripts/nightly/stop-async-daemon.sh" \ + "$GITHUB_WORKSPACE/target/release/git-ai" + - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -289,6 +302,11 @@ jobs: cd /tmp/test-repo git-ai install + - name: Start async daemon + run: | + source "$GITHUB_WORKSPACE/scripts/nightly/start-async-daemon.sh" \ + "$GITHUB_WORKSPACE/target/release/git-ai" + - name: Run live agent test (with retry) uses: nick-fields/retry@v2 with: @@ -311,6 +329,12 @@ jobs: bash "$GITHUB_WORKSPACE/scripts/nightly/verify-attribution.sh" "${{ matrix.agent }}" continue-on-error: ${{ matrix.channel == 'latest' }} + - name: Stop async daemon + if: always() + run: | + bash "$GITHUB_WORKSPACE/scripts/nightly/stop-async-daemon.sh" \ + "$GITHUB_WORKSPACE/target/release/git-ai" + - name: Upload test results if: always() uses: actions/upload-artifact@v4 diff --git a/scripts/nightly/start-async-daemon.sh b/scripts/nightly/start-async-daemon.sh new file mode 100644 index 000000000..5e9b42736 --- /dev/null +++ b/scripts/nightly/start-async-daemon.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Start the git-ai async daemon for CI workflows. +# +# Usage: source scripts/nightly/start-async-daemon.sh [real-git-path] +# +# The script: +# 1. Creates/updates ~/.git-ai/config.json with async_mode enabled +# 2. Picks socket paths under RUNNER_TEMP (or /tmp) +# 3. Starts the daemon in the background +# 4. Waits for sockets to appear (up to 10 s) +# 5. Exports env vars to GITHUB_ENV so subsequent steps inherit them +# +# After sourcing, the following env vars are set in the current shell AND +# appended to GITHUB_ENV (if it exists): +# GIT_AI_ASYNC_MODE, GIT_AI_TEST_FORCE_TTY, GIT_AI_POST_COMMIT_TIMEOUT_MS, +# GIT_AI_DAEMON_HOME, GIT_AI_DAEMON_CONTROL_SOCKET, GIT_AI_DAEMON_TRACE_SOCKET, +# ASYNC_DAEMON_PID +set -euo pipefail + +GIT_AI_BIN="${1:?Usage: source start-async-daemon.sh [real-git-path]}" + +# ── Locate real git (not the git-ai proxy) ─────────────────────────────────── +# The caller can pass an explicit path; otherwise probe common locations so we +# never accidentally point the daemon config at the git-ai proxy symlink. +REAL_GIT="${2:-}" +if [ -z "$REAL_GIT" ]; then + for candidate in /usr/bin/git /usr/local/bin/git; do + if [ -x "$candidate" ]; then + REAL_GIT="$candidate" + break + fi + done + # Last resort: use whatever is on PATH. + if [ -z "$REAL_GIT" ]; then + REAL_GIT="$(command -v git)" + fi +fi + +# ── Daemon home directory ──────────────────────────────────────────────────── +DAEMON_HOME="${RUNNER_TEMP:-/tmp}/git-ai-daemon-$$" +mkdir -p "$DAEMON_HOME/.git-ai" + +# ── Write daemon config ────────────────────────────────────────────────────── +cat > "$DAEMON_HOME/.git-ai/config.json" </dev/null 2>&1; then + python3 -c " +import json, os, sys +cfg_path = os.path.join(os.environ['HOME'], '.git-ai', 'config.json') +if not os.path.exists(cfg_path): + sys.exit(0) +with open(cfg_path) as f: + cfg = json.load(f) +ff = cfg.setdefault('feature_flags', {}) +ff['async_mode'] = True +with open(cfg_path, 'w') as f: + json.dump(cfg, f, indent=2) +" 2>/dev/null || true + fi +fi + +# ── Socket paths ───────────────────────────────────────────────────────────── +CTRL_SOCK="$DAEMON_HOME/control.sock" +TRACE_SOCK="$DAEMON_HOME/trace.sock" + +# ── Export env vars ────────────────────────────────────────────────────────── +export GIT_AI_ASYNC_MODE=true +export GIT_AI_TEST_FORCE_TTY=1 +export GIT_AI_POST_COMMIT_TIMEOUT_MS=30000 +export GIT_AI_DAEMON_HOME="$DAEMON_HOME" +export GIT_AI_DAEMON_CONTROL_SOCKET="$CTRL_SOCK" +export GIT_AI_DAEMON_TRACE_SOCKET="$TRACE_SOCK" + +# Persist to GITHUB_ENV so subsequent workflow steps inherit them. +if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "GIT_AI_ASYNC_MODE=true" + echo "GIT_AI_TEST_FORCE_TTY=1" + echo "GIT_AI_POST_COMMIT_TIMEOUT_MS=30000" + echo "GIT_AI_DAEMON_HOME=$DAEMON_HOME" + echo "GIT_AI_DAEMON_CONTROL_SOCKET=$CTRL_SOCK" + echo "GIT_AI_DAEMON_TRACE_SOCKET=$TRACE_SOCK" + } >> "$GITHUB_ENV" +fi + +# ── Start the daemon ───────────────────────────────────────────────────────── +"$GIT_AI_BIN" bg run & +ASYNC_DAEMON_PID=$! +export ASYNC_DAEMON_PID + +if [ -n "${GITHUB_ENV:-}" ]; then + echo "ASYNC_DAEMON_PID=$ASYNC_DAEMON_PID" >> "$GITHUB_ENV" +fi + +# ── Wait for sockets (up to 10 s) ─────────────────────────────────────────── +for _i in $(seq 1 400); do + [ -S "$CTRL_SOCK" ] && [ -S "$TRACE_SOCK" ] && break + sleep 0.025 +done + +if [ ! -S "$CTRL_SOCK" ] || [ ! -S "$TRACE_SOCK" ]; then + echo "ERROR: daemon sockets did not appear after 10 s" >&2 + echo " CTRL_SOCK=$CTRL_SOCK" >&2 + echo " TRACE_SOCK=$TRACE_SOCK" >&2 + kill -9 "$ASYNC_DAEMON_PID" 2>/dev/null || true + exit 1 +fi + +echo "Async daemon started (PID=$ASYNC_DAEMON_PID)" +echo " DAEMON_HOME=$DAEMON_HOME" +echo " CTRL_SOCK=$CTRL_SOCK" +echo " TRACE_SOCK=$TRACE_SOCK" diff --git a/scripts/nightly/stop-async-daemon.sh b/scripts/nightly/stop-async-daemon.sh new file mode 100644 index 000000000..0b9650dd4 --- /dev/null +++ b/scripts/nightly/stop-async-daemon.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Gracefully stop the git-ai async daemon started by start-async-daemon.sh. +# +# Usage: bash scripts/nightly/stop-async-daemon.sh [git-ai-binary] +# +# Reads ASYNC_DAEMON_PID, GIT_AI_DAEMON_HOME, and socket paths from env. +# Falls back to kill -9 if graceful shutdown times out. +set -uo pipefail + +GIT_AI_BIN="${1:-}" + +if [ -z "${ASYNC_DAEMON_PID:-}" ]; then + echo "No ASYNC_DAEMON_PID set — nothing to stop." + exit 0 +fi + +# Try graceful shutdown via the control socket. +if [ -n "$GIT_AI_BIN" ] && [ -S "${GIT_AI_DAEMON_CONTROL_SOCKET:-}" ]; then + "$GIT_AI_BIN" bg shutdown 2>/dev/null || true +fi + +# Wait up to 2 s for the process to exit. +for _i in $(seq 1 40); do + kill -0 "$ASYNC_DAEMON_PID" 2>/dev/null || break + sleep 0.05 +done + +# Force-kill if still alive. +kill -9 "$ASYNC_DAEMON_PID" 2>/dev/null || true +wait "$ASYNC_DAEMON_PID" 2>/dev/null || true + +# Clean up daemon home. +if [ -n "${GIT_AI_DAEMON_HOME:-}" ] && [ -d "$GIT_AI_DAEMON_HOME" ]; then + rm -rf "$GIT_AI_DAEMON_HOME" +fi + +echo "Async daemon stopped (PID=$ASYNC_DAEMON_PID)." diff --git a/tests/e2e/user-scenarios.bats b/tests/e2e/user-scenarios.bats index 4010c45d8..7b6394bbb 100644 --- a/tests/e2e/user-scenarios.bats +++ b/tests/e2e/user-scenarios.bats @@ -19,20 +19,94 @@ setup() { echo "Please run 'cargo build' or 'cargo build --release' first" >&3 exit 1 fi - - # Create shell functions to alias git-ai and git commands + + # ── Async-mode daemon setup ─────────────────────────────────────────── + # Create an isolated HOME for the daemon so its config, sockets and + # global gitconfig do not interfere with the developer machine. + export TEST_DAEMON_HOME="$(mktemp -d)" + export ORIGINAL_HOME="$HOME" + export HOME="$TEST_DAEMON_HOME" + export GIT_CONFIG_GLOBAL="$TEST_DAEMON_HOME/.gitconfig" + + # Locate real git so the config can reference it explicitly. + REAL_GIT="$(command -v git)" + + # Write daemon / wrapper config that enables async_mode. + mkdir -p "$TEST_DAEMON_HOME/.git-ai" + cat > "$TEST_DAEMON_HOME/.git-ai/config.json" <&3 + exit 1 + fi + + # ── Shell helpers ───────────────────────────────────────────────────── git-ai() { + GIT_AI_DAEMON_HOME="$TEST_DAEMON_HOME" \ + GIT_AI_DAEMON_CONTROL_SOCKET="$TEST_DAEMON_HOME/control.sock" \ + GIT_AI_DAEMON_TRACE_SOCKET="$TEST_DAEMON_HOME/trace.sock" \ + GIT_AI_DAEMON_CHECKPOINT_DELEGATE=true \ "$GIT_AI_BINARY" "$@" } export -f git-ai git() { - GIT_AI=git "$GIT_AI_BINARY" "$@" + GIT_AI=git \ + GIT_AI_ASYNC_MODE=true \ + GIT_AI_TEST_FORCE_TTY=1 \ + GIT_AI_POST_COMMIT_TIMEOUT_MS=30000 \ + GIT_AI_DAEMON_HOME="$TEST_DAEMON_HOME" \ + GIT_AI_DAEMON_CONTROL_SOCKET="$TEST_DAEMON_HOME/control.sock" \ + GIT_AI_DAEMON_TRACE_SOCKET="$TEST_DAEMON_HOME/trace.sock" \ + GIT_TRACE2_EVENT="af_unix:stream:$TEST_DAEMON_HOME/trace.sock" \ + GIT_TRACE2_EVENT_NESTING=10 \ + "$GIT_AI_BINARY" "$@" } - export -f git - - # Initialize a git repo + + # ── Global gitconfig defaults ───────────────────────────────────────── + # Set default branch name before any git init so tests always get "main". + "$REAL_GIT" config --global init.defaultBranch main + + # ── Set up trace2 via global gitconfig ──────────────────────────────── + git-ai install-hooks --dry-run=false 2>/dev/null || true + + # ── Initialise test repository ─────────────────────────────────────── git init git config user.email "test@example.com" git config user.name "Test User" @@ -41,7 +115,6 @@ setup() { echo "# Test Project" > README.md git add README.md git commit -m "Initial commit" - git config init.defaultBranch main # Check if jq is available (needed for JSON parsing tests) if ! command -v jq &> /dev/null; then @@ -51,15 +124,52 @@ setup() { } teardown() { - # Clean up temporary directory + # Shut down the daemon gracefully; fall back to kill. + if [ -n "$DAEMON_PID" ]; then + GIT_AI_DAEMON_HOME="$TEST_DAEMON_HOME" \ + GIT_AI_DAEMON_CONTROL_SOCKET="$TEST_DAEMON_HOME/control.sock" \ + GIT_AI_DAEMON_TRACE_SOCKET="$TEST_DAEMON_HOME/trace.sock" \ + "$GIT_AI_BINARY" bg shutdown 2>/dev/null || true + # Give the process a moment to exit. + for _i in $(seq 1 40); do + kill -0 "$DAEMON_PID" 2>/dev/null || break + sleep 0.05 + done + kill -9 "$DAEMON_PID" 2>/dev/null || true + wait "$DAEMON_PID" 2>/dev/null || true + fi + + # Restore HOME so cleanup doesn't affect the daemon home. + export HOME="$ORIGINAL_HOME" + + # Clean up temporary directories. cd "$ORIGINAL_DIR" - rm -rf "$TEST_TEMP_DIR" + rm -rf "$TEST_TEMP_DIR" "$TEST_DAEMON_HOME" } # ============================================================================ # Helper Functions # ============================================================================ +# Wait for the daemon to produce an authorship note on a given commit. +# In async mode the daemon writes notes asynchronously; after non-commit +# operations (rebase, cherry-pick, merge --squash) we need to poll. +# Usage: +# wait_for_note # defaults to HEAD if omitted +wait_for_note() { + local commit="${1:-HEAD}" + local sha + sha=$(GIT_AI=git "$GIT_AI_BINARY" rev-parse "$commit" 2>/dev/null) || sha="$commit" + for _i in $(seq 1 800); do + if GIT_AI=git "$GIT_AI_BINARY" notes --ref=ai list "$sha" >/dev/null 2>&1; then + return 0 + fi + sleep 0.025 + done + echo "WARNING: authorship note not found for $sha after 20 s" >&3 + return 1 +} + # Helper function to get clean JSON from git-ai stats # Usage: # stats_json=$(get_stats_json) # Get stats for HEAD @@ -1265,6 +1375,9 @@ EOF git checkout feature-branch git rebase main + # Wait for the daemon to rewrite the authorship note for the rebased commit. + wait_for_note HEAD + # Step 6: Verify AI authorship is preserved after rebase echo "=== Stats AFTER rebase ===" >&3 feature_commit_after=$(git rev-parse HEAD) @@ -1442,6 +1555,9 @@ EOF echo "=== Continuing rebase after conflict resolution ===" >&3 GIT_EDITOR=true git rebase --continue + # Wait for the daemon to rewrite the authorship note for the rebased commit. + wait_for_note HEAD + # Step 6: Verify AI authorship is preserved after conflict resolution echo "=== Stats AFTER conflict resolution ===" >&3 feature_commit_after=$(git rev-parse HEAD) @@ -1848,6 +1964,9 @@ EOF unset GIT_SEQUENCE_EDITOR unset GIT_EDITOR + # Wait for the daemon to rewrite the authorship note for the squashed commit. + wait_for_note HEAD + # Verify that the rebase resulted in one commit squashed_commit_sha=$(git rev-parse HEAD) new_commit_count=$(git rev-list --count HEAD ^$base_commit_sha) @@ -2155,6 +2274,9 @@ EOF # Step 6: Verify authorship is preserved after rebase feature_commit_after=$(git rev-parse HEAD) + # Wait for the daemon to rewrite the authorship note for the rebased commit. + wait_for_note "$feature_commit_after" + echo "=== Feature commit stats AFTER rebase ===" >&3 stats_feature_after=$(get_stats_json "$feature_commit_after") echo "$stats_feature_after" >&3