Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Required — fork details
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
FORK_URL=github.com/your-user/HdrHistogram.NET
UPSTREAM_REPO=HdrHistogram/HdrHistogram.NET
GIT_USER_NAME=hdrhistogram-agent
GIT_USER_EMAIL=agent@example.com
CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token

# Required — upstream PR permissions
GH_TOKEN_UPSTREAM=ghp_xxxxxxxxxxxxxxxxxxxx

# Optional
MAX_ITERATIONS=10
COOLDOWN_SECONDS=30
TIMEOUT_SECONDS=1800
UPSTREAM_BASE_BRANCH=main
66 changes: 66 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# =============================================================================
# HdrHistogram.NET Agent Container
# .NET SDK + Claude Code + gh CLI + firewall
# =============================================================================
FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim
RUN dotnet_version=8.0 \
&& curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin \
--runtime dotnet --channel $dotnet_version --install-dir /usr/share/dotnet

ARG CLAUDE_CODE_VERSION="latest"

# ---------- System deps + firewall tooling ----------
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl wget jq ca-certificates gnupg openssh-client \
iptables ipset dnsutils iproute2 \
sudo \
&& rm -rf /var/lib/apt/lists/*

# ---------- Node.js (required for Claude Code) ----------
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*

# ---------- GitHub CLI ----------
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update && apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*

# ---------- Claude Code ----------
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$NPM_CONFIG_PREFIX/bin:$PATH
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}

# ---------- Agent user ----------
RUN useradd -m -s /bin/bash agent \
&& mkdir -p /home/agent/.claude /workspace \
&& chown -R agent:agent /home/agent/.claude /workspace

# ---------- Firewall ----------
COPY init-firewall.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/init-firewall.sh \
&& echo "agent ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" \
> /etc/sudoers.d/agent-firewall \
&& chmod 0440 /etc/sudoers.d/agent-firewall

# ---------- Entrypoint ----------
COPY entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh

# ---------- Agent loop ----------
COPY agent-loop.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/agent-loop.sh

# ---------- Give access to the nuget cache vol ----------
RUN mkdir -p /home/agent/.nuget/NuGet /home/agent/.nuget/packages \
&& chown -R agent:agent /home/agent/.nuget

COPY prompts/ /usr/local/share/agent-prompts/

USER agent
WORKDIR /workspace

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
194 changes: 194 additions & 0 deletions .devcontainer/agent-loop.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/bin/bash
set -euo pipefail
cd /workspace/repo

CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-600}"
UPSTREAM_REPO="${UPSTREAM_REPO}"
UPSTREAM_BASE_BRANCH="${UPSTREAM_BASE_BRANCH:-main}"
PROMPT_DIR="${PROMPT_DIR:-/usr/local/share/agent-prompts}"

sync_state() {
local msg="${1:-agent: update plan state}"
git add -A
if ! git diff --cached --quiet; then
git commit -m "$msg"
git push -u origin "$(git branch --show-current)"
fi
}

run_claude() {
local prompt="$1"
timeout "$CLAUDE_TIMEOUT" claude --dangerously-skip-permissions --print "$prompt"
}

load_prompt() {
local name="$1"
local file="${PROMPT_DIR}/${name}.md"
if [ ! -f "$file" ]; then
echo "ERROR: Prompt file not found: $file" >&2
exit 1
fi
cat "$file"
}

get_issue_num() {
git branch --show-current | grep -oP 'agent/\K\d+'
}

determine_state() {
if [ -f ./plan/done/brief.md ]; then
echo "create-pr"
elif [ -f ./plan/ready/task.md ]; then
echo "execute-tasks"
elif [ -f ./plan/ready/brief.md ]; then
echo "create-tasks"
elif [ -f ./plan/planning/brief-review.md ]; then
echo "apply-review"
elif [ -f ./plan/planning/brief.md ]; then
echo "review-brief"
else
echo "pick-issue"
fi
}

STATE=$(determine_state)
echo "State: $STATE"

case "$STATE" in

create-pr)
BRANCH=$(git branch --show-current)
ISSUE_NUM=$(get_issue_num)

# Back up plan state for recovery on failure
cp -r ./plan /tmp/plan-backup 2>/dev/null || true

# Remove plan/ so the PR commit is clean
# (upstream uses squash merge, so plan artifacts never appear in main history)
rm -rf ./plan
git add -A
git diff --cached --quiet || git commit -m "feat(#${ISSUE_NUM}): complete implementation"

if ! git push origin "$BRANCH"; then
echo "ERROR: git push failed, restoring plan state" >&2
cp -r /tmp/plan-backup ./plan 2>/dev/null || true
exit 1
fi

if ! PR_URL=$(GH_TOKEN="$GH_TOKEN_UPSTREAM" gh pr create --fill \
--repo "$UPSTREAM_REPO" \
--head "${GIT_USER_NAME}:${BRANCH}" \
--base "$UPSTREAM_BASE_BRANCH"); then
echo "ERROR: PR creation failed, restoring plan state" >&2
cp -r /tmp/plan-backup ./plan 2>/dev/null || true
sync_state "plan(#${ISSUE_NUM}): restore after failed PR"
exit 1
fi

PR_NUM=$(echo "$PR_URL" | grep -oP '\d+$')

[ -f /tmp/plan-backup/done/brief.md ] && \
GH_TOKEN="$GH_TOKEN_UPSTREAM" gh pr comment "$PR_NUM" \
--repo "$UPSTREAM_REPO" \
--body-file /tmp/plan-backup/done/brief.md
[ -f /tmp/plan-backup/done/task.md ] && \
GH_TOKEN="$GH_TOKEN_UPSTREAM" gh pr comment "$PR_NUM" \
--repo "$UPSTREAM_REPO" \
--body-file /tmp/plan-backup/done/task.md

if [ -n "$ISSUE_NUM" ]; then
GH_TOKEN="$GH_TOKEN_UPSTREAM" gh issue comment "$ISSUE_NUM" \
--repo "$UPSTREAM_REPO" --body "PR created: $PR_URL"
fi

# Clean up backup after successful PR creation
rm -rf /tmp/plan-backup

echo "PR created: $PR_URL"
;;

execute-tasks)
ISSUE_NUM=$(get_issue_num)
run_claude "$(load_prompt execute-tasks)"
sync_state "feat(#${ISSUE_NUM}): implement tasks"
;;

create-tasks)
ISSUE_NUM=$(get_issue_num)
run_claude "$(load_prompt create-tasks)"
sync_state "plan(#${ISSUE_NUM}): create task breakdown"
;;

apply-review)
ISSUE_NUM=$(get_issue_num)
run_claude "$(load_prompt apply-review)"
sync_state "plan(#${ISSUE_NUM}): apply brief review feedback"
;;

review-brief)
ISSUE_NUM=$(get_issue_num)
run_claude "$(load_prompt review-brief)"
sync_state "plan(#${ISSUE_NUM}): review brief"
;;

pick-issue)
ISSUE_JSON=$(gh issue list --assignee @me --state open \
--repo "$UPSTREAM_REPO" \
--json number,title --limit 1 2>/dev/null || echo "[]")

if [ "$ISSUE_JSON" = "[]" ] || [ "$ISSUE_JSON" = "" ]; then
ISSUE_JSON=$(gh issue list --label agent --state open \
--repo "$UPSTREAM_REPO" \
--json number,title --limit 1 2>/dev/null || echo "[]")
fi

ISSUE_NUM=$(echo "$ISSUE_JSON" | jq -r '.[0].number // empty')
if [ -z "$ISSUE_NUM" ]; then
echo "No work available."
exit 0
fi

ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.[0].title')
BRANCH_SLUG=$(echo "$ISSUE_TITLE" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | head -c 40)

# Check for existing branch (resume interrupted work)
EXISTING_BRANCH=$(git ls-remote --heads origin "agent/${ISSUE_NUM}-*" \
| head -1 | awk '{print $2}' | sed 's|refs/heads/||')

if [ -n "$EXISTING_BRANCH" ]; then
echo "Resuming branch: $EXISTING_BRANCH"
git checkout -b "$EXISTING_BRANCH" "origin/$EXISTING_BRANCH"

# If plan state doesn't exist on the branch, re-initialise
if [ ! -d ./plan ]; then
echo "No plan state found on branch, starting fresh"
mkdir -p ./plan/planning ./plan/ready ./plan/done

ISSUE_BODY=$(gh issue view "$ISSUE_NUM" --repo "$UPSTREAM_REPO" \
--json body,title --jq '"# Issue #'"$ISSUE_NUM"': " + .title + "\n\n" + .body')

PROMPT=$(load_prompt pick-issue)
PROMPT="${PROMPT//\{\{ISSUE_BODY\}\}/$ISSUE_BODY}"

run_claude "$PROMPT"
sync_state "plan(#${ISSUE_NUM}): initial brief from issue"
fi
else
echo "Starting fresh: agent/${ISSUE_NUM}-${BRANCH_SLUG}"
git fetch upstream
git checkout -b "agent/${ISSUE_NUM}-${BRANCH_SLUG}" "upstream/$UPSTREAM_BASE_BRANCH"
mkdir -p ./plan/planning ./plan/ready ./plan/done

ISSUE_BODY=$(gh issue view "$ISSUE_NUM" --repo "$UPSTREAM_REPO" \
--json body,title --jq '"# Issue #'"$ISSUE_NUM"': " + .title + "\n\n" + .body')

# Load template and substitute issue body
PROMPT=$(load_prompt pick-issue)
PROMPT="${PROMPT//\{\{ISSUE_BODY\}\}/$ISSUE_BODY}"

run_claude "$PROMPT"
sync_state "plan(#${ISSUE_NUM}): initial brief from issue"
fi
;;
esac
56 changes: 56 additions & 0 deletions .devcontainer/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/bash
set -euo pipefail

# ── Firewall ──
sudo /usr/local/bin/init-firewall.sh

# ── Git identity (the agent's digital twin) ──
git config --global user.name "${GIT_USER_NAME}"
git config --global user.email "${GIT_USER_EMAIL}"

# ── Clone from the agent's fork ──
echo "Cloning fork: https://${FORK_URL}..."
git clone "https://x-access-token:${GH_TOKEN}@${FORK_URL}" /workspace/repo
cd /workspace/repo

# ── Set upstream to the parent repo ──
UPSTREAM_BASE_BRANCH="${UPSTREAM_BASE_BRANCH:-main}"
git remote add upstream "https://github.com/${UPSTREAM_REPO}"
git fetch upstream
git branch --set-upstream-to="upstream/$UPSTREAM_BASE_BRANCH" "$UPSTREAM_BASE_BRANCH"


# ── Restore + build to warm the cache ──
echo "Restoring NuGet packages..."
dotnet restore
echo "Building..."
dotnet build --no-restore

# ── Run the agent ──
MAX_ITERATIONS="${MAX_ITERATIONS:-10}"
COOLDOWN="${COOLDOWN_SECONDS:-30}"
export CLAUDE_TIMEOUT="${TIMEOUT_SECONDS:-1800}"

for i in $(seq 1 "$MAX_ITERATIONS"); do
echo ""
echo "=== Iteration $i/$MAX_ITERATIONS ==="
find ./plan -name "*.md" 2>/dev/null | sort || echo " (no plan state)"

EXIT_CODE=0
bash /usr/local/bin/agent-loop.sh || EXIT_CODE=$?

if [ "$EXIT_CODE" -ne 0 ]; then
echo "Iteration $i failed with exit code $EXIT_CODE"
break
fi

# Done?
if [ ! -d "./plan" ]; then
echo "Work complete."
break
fi

[ "$i" -lt "$MAX_ITERATIONS" ] && sleep "$COOLDOWN"
done

echo "Agent finished after $i iterations."
31 changes: 31 additions & 0 deletions .devcontainer/fleet.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/bash
# fleet.sh — Spin up N agents, each assigned a different issue
set -euo pipefail

FLEET_SIZE="${1:-3}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/.env"

# Build the image
docker build -t hdrhistogram-agent -f "$SCRIPT_DIR/Dockerfile" "$SCRIPT_DIR/"

echo "Launching $FLEET_SIZE agents..."

for i in $(seq 1 "$FLEET_SIZE"); do
AGENT_NAME="hdrhistogram-agent-$i"
echo "Starting $AGENT_NAME..."

docker run -d --rm \
--name "$AGENT_NAME" \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--memory=4g \
--cpus=2 \
--env-file "$SCRIPT_DIR/.env" \
-e MAX_ITERATIONS=15 \
-v nuget-cache:/home/agent/.nuget/packages \
hdrhistogram-agent
done

echo "Fleet launched. Monitor with: docker ps --filter name=hdrhistogram-agent"
echo "Logs: docker logs -f hdrhistogram-agent-1"
Loading
Loading