fix: bug: dispatcher runner invokes formulas as bash scripts but formulas are TOML (#516)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-09 19:02:52 +00:00
parent e70da015db
commit 77de5ef4c5
7 changed files with 327 additions and 38 deletions

View file

@ -66,6 +66,27 @@ services:
depends_on:
- forgejo
runner:
image: disinto/agents:latest
profiles: ["runner"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /usr/local/bin/claude:/usr/local/bin/claude:ro
- ${HOME}/.claude:/home/agent/.claude
- ${HOME}/.claude.json:/home/agent/.claude.json:ro
entrypoint: ["bash", "/home/agent/disinto/docker/runner/entrypoint-runner.sh"]
environment:
- DISINTO_CONTAINER=1
- FORGE_URL=${FORGE_URL:-}
- FORGE_TOKEN=${FORGE_TOKEN:-}
- FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto}
- FORGE_OPS_REPO=${FORGE_OPS_REPO:-}
- PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CLAUDE_MODEL=${CLAUDE_MODEL:-}
networks:
- default
reproduce:
build:
context: .

View file

@ -408,17 +408,10 @@ launch_runner() {
local secrets_array
secrets_array="${VAULT_ACTION_SECRETS:-}"
# Build command array (safe from shell injection)
local -a cmd=(docker run --rm
--name "vault-runner-${action_id}"
--network disinto_disinto-net
-e "FORGE_URL=${FORGE_URL}"
-e "FORGE_TOKEN=${FORGE_TOKEN}"
-e "FORGE_REPO=${FORGE_REPO}"
-e "FORGE_OPS_REPO=${FORGE_OPS_REPO}"
-e "PRIMARY_BRANCH=${PRIMARY_BRANCH}"
-e DISINTO_CONTAINER=1
)
# Build docker compose run command (delegates to compose runner service)
# The runner service definition handles image, network, volumes, and base env.
# The dispatcher only adds declared secrets and the ops repo mount.
local -a cmd=(docker compose run --rm)
# Add environment variables for secrets (if any declared)
if [ -n "$secrets_array" ]; then
@ -438,27 +431,13 @@ launch_runner() {
log "Action ${action_id} has no secrets declared — runner will execute without extra env vars"
fi
# Add formula and action id as arguments (safe from shell injection)
local formula="${VAULT_ACTION_FORMULA:-}"
cmd+=(disinto-agents:latest bash -c
"cd /home/agent/disinto && bash formulas/${formula}.sh ${action_id}")
# Mount the ops repo so the runner entrypoint can read the action TOML
cmd+=(-v "${OPS_REPO_ROOT}:/home/agent/ops:ro")
# Log command skeleton (hide all -e flags for security)
local -a log_cmd=()
local skip_next=0
for arg in "${cmd[@]}"; do
if [[ $skip_next -eq 1 ]]; then
skip_next=0
continue
fi
if [[ "$arg" == "-e" ]]; then
log_cmd+=("$arg" "<redacted>")
skip_next=1
else
log_cmd+=("$arg")
fi
done
log "Running: ${log_cmd[*]}"
# Service name and action-id argument
cmd+=(runner "$action_id")
log "Running: docker compose run --rm runner ${action_id} (secrets: ${secrets_array:-none})"
# Create temp file for logs
local log_file

View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# entrypoint-runner.sh — Vault runner entrypoint
#
# Receives an action-id, reads the vault action TOML to get the formula name,
# then dispatches to the appropriate executor:
# - formulas/<name>.sh → bash (mechanical operations like release)
# - formulas/<name>.toml → claude -p (reasoning tasks like triage, architect)
#
# Usage: entrypoint-runner.sh <action-id>
#
# Expects:
# OPS_REPO_ROOT — path to the ops repo (mounted by compose)
# FACTORY_ROOT — path to disinto code (default: /home/agent/disinto)
#
# Part of #516.
set -euo pipefail
FACTORY_ROOT="${FACTORY_ROOT:-/home/agent/disinto}"
OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/agent/ops}"
log() {
printf '[%s] runner: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"
}
# ── Argument parsing ─────────────────────────────────────────────────────
action_id="${1:-}"
if [ -z "$action_id" ]; then
log "ERROR: action-id argument required"
echo "Usage: entrypoint-runner.sh <action-id>" >&2
exit 1
fi
# ── Read vault action TOML ───────────────────────────────────────────────
action_toml="${OPS_REPO_ROOT}/vault/actions/${action_id}.toml"
if [ ! -f "$action_toml" ]; then
log "ERROR: vault action TOML not found: ${action_toml}"
exit 1
fi
# Extract formula name from TOML
formula=$(grep -E '^formula\s*=' "$action_toml" \
| sed -E 's/^formula\s*=\s*"(.*)"/\1/' | tr -d '\r')
if [ -z "$formula" ]; then
log "ERROR: no 'formula' field found in ${action_toml}"
exit 1
fi
# Extract context for logging
context=$(grep -E '^context\s*=' "$action_toml" \
| sed -E 's/^context\s*=\s*"(.*)"/\1/' | tr -d '\r')
log "Action: ${action_id}, formula: ${formula}, context: ${context:-<none>}"
# ── Dispatch: .sh (mechanical) vs .toml (Claude reasoning) ──────────────
formula_sh="${FACTORY_ROOT}/formulas/${formula}.sh"
formula_toml="${FACTORY_ROOT}/formulas/${formula}.toml"
if [ -f "$formula_sh" ]; then
# Mechanical operation — run directly
log "Dispatching to shell script: ${formula_sh}"
exec bash "$formula_sh" "$action_id"
elif [ -f "$formula_toml" ]; then
# Reasoning task — launch Claude with the formula as prompt
log "Dispatching to Claude with formula: ${formula_toml}"
formula_content=$(cat "$formula_toml")
action_context=$(cat "$action_toml")
prompt="You are a vault runner executing a formula-based operational task.
## Vault action
\`\`\`toml
${action_context}
\`\`\`
## Formula
\`\`\`toml
${formula_content}
\`\`\`
## Instructions
Execute the steps defined in the formula above. The vault action context provides
the specific parameters for this run. Execute each step in order, verifying
success before proceeding to the next.
FACTORY_ROOT=${FACTORY_ROOT}
OPS_REPO_ROOT=${OPS_REPO_ROOT}
"
exec claude -p "$prompt" \
--dangerously-skip-permissions \
${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"}
else
log "ERROR: no formula found for '${formula}' — checked ${formula_sh} and ${formula_toml}"
exit 1
fi

186
formulas/release.sh Normal file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env bash
# formulas/release.sh — Mechanical release script
#
# Implements the release workflow without Claude:
# 1. Validate prerequisites
# 2. Tag Forgejo main via API
# 3. Push tag to mirrors (Codeberg, GitHub) via token auth
# 4. Build and tag the agents Docker image
# 5. Restart agent containers
#
# Usage: release.sh <action-id>
#
# Expects env vars:
# FORGE_URL, FORGE_TOKEN, FORGE_REPO, PRIMARY_BRANCH
# GITHUB_TOKEN — for pushing tags to GitHub mirror
# CODEBERG_TOKEN — for pushing tags to Codeberg mirror
#
# The action TOML context field must contain the version, e.g.:
# context = "Release v1.2.0"
#
# Part of #516.
set -euo pipefail
FACTORY_ROOT="${FACTORY_ROOT:-/home/agent/disinto}"
OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/agent/ops}"
log() {
printf '[%s] release: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"
}
# ── Argument parsing ─────────────────────────────────────────────────────
action_id="${1:-}"
if [ -z "$action_id" ]; then
log "ERROR: action-id argument required"
exit 1
fi
action_toml="${OPS_REPO_ROOT}/vault/actions/${action_id}.toml"
if [ ! -f "$action_toml" ]; then
log "ERROR: vault action TOML not found: ${action_toml}"
exit 1
fi
# Extract version from context field (e.g. "Release v1.2.0" → "v1.2.0")
context=$(grep -E '^context\s*=' "$action_toml" \
| sed -E 's/^context\s*=\s*"(.*)"/\1/' | tr -d '\r')
RELEASE_VERSION=$(echo "$context" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+') || true
if [ -z "${RELEASE_VERSION:-}" ]; then
log "ERROR: could not extract version from context: '${context}'"
log "Context must contain a version like v1.2.0"
exit 1
fi
log "Starting release ${RELEASE_VERSION} (action: ${action_id})"
# ── Step 1: Preflight ────────────────────────────────────────────────────
log "Step 1/6: Preflight checks"
# Validate version format
if ! echo "$RELEASE_VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
log "ERROR: invalid version format: ${RELEASE_VERSION}"
exit 1
fi
# Required env vars
for var in FORGE_URL FORGE_TOKEN FORGE_REPO PRIMARY_BRANCH; do
if [ -z "${!var:-}" ]; then
log "ERROR: required env var not set: ${var}"
exit 1
fi
done
# Check Docker access
if ! docker info >/dev/null 2>&1; then
log "ERROR: Docker not accessible"
exit 1
fi
# Check tag doesn't already exist on Forgejo
if curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/tags/${RELEASE_VERSION}" >/dev/null 2>&1; then
log "ERROR: tag ${RELEASE_VERSION} already exists on Forgejo"
exit 1
fi
log "Preflight passed"
# ── Step 2: Tag main via Forgejo API ─────────────────────────────────────
log "Step 2/6: Creating tag ${RELEASE_VERSION} on Forgejo"
# Get HEAD SHA of primary branch
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/branches/${PRIMARY_BRANCH}" \
| jq -r '.commit.id // empty')
if [ -z "$head_sha" ]; then
log "ERROR: could not get HEAD SHA for ${PRIMARY_BRANCH}"
exit 1
fi
# Create tag via API
curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/tags" \
-d "{\"tag_name\":\"${RELEASE_VERSION}\",\"target\":\"${head_sha}\",\"message\":\"Release ${RELEASE_VERSION}\"}" \
>/dev/null
log "Tag ${RELEASE_VERSION} created (SHA: ${head_sha})"
# ── Step 3: Push tag to mirrors ──────────────────────────────────────────
log "Step 3/6: Pushing tag to mirrors"
# Extract org/repo from FORGE_REPO (e.g. "disinto-admin/disinto" → "disinto")
project_name="${FORGE_REPO##*/}"
# Push to GitHub mirror (if GITHUB_TOKEN is available)
if [ -n "${GITHUB_TOKEN:-}" ]; then
log "Pushing tag to GitHub mirror"
# Create tag on GitHub via API
if curl -sf -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/Disinto/${project_name}/git/refs" \
-d "{\"ref\":\"refs/tags/${RELEASE_VERSION}\",\"sha\":\"${head_sha}\"}" \
>/dev/null 2>&1; then
log "GitHub: tag pushed"
else
log "WARNING: GitHub tag push failed (may already exist)"
fi
else
log "WARNING: GITHUB_TOKEN not set — skipping GitHub mirror"
fi
# Push to Codeberg mirror (if CODEBERG_TOKEN is available)
if [ -n "${CODEBERG_TOKEN:-}" ]; then
log "Pushing tag to Codeberg mirror"
# Codeberg uses Gitea-compatible API
# Extract owner from FORGE_REPO for Codeberg (use same owner)
codeberg_owner="${FORGE_REPO%%/*}"
if curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \
-H "Content-Type: application/json" \
"https://codeberg.org/api/v1/repos/${codeberg_owner}/${project_name}/tags" \
-d "{\"tag_name\":\"${RELEASE_VERSION}\",\"target\":\"${head_sha}\",\"message\":\"Release ${RELEASE_VERSION}\"}" \
>/dev/null 2>&1; then
log "Codeberg: tag pushed"
else
log "WARNING: Codeberg tag push failed (may already exist)"
fi
else
log "WARNING: CODEBERG_TOKEN not set — skipping Codeberg mirror"
fi
# ── Step 4: Build agents Docker image ────────────────────────────────────
log "Step 4/6: Building agents Docker image"
cd "$FACTORY_ROOT/.." || exit 1
docker compose build --no-cache agents 2>&1 | tail -5
log "Image built"
# ── Step 5: Tag image with version ───────────────────────────────────────
log "Step 5/6: Tagging image"
docker tag disinto/agents:latest "disinto/agents:${RELEASE_VERSION}"
log "Tagged disinto/agents:${RELEASE_VERSION}"
# ── Step 6: Restart agent containers ─────────────────────────────────────
log "Step 6/6: Restarting agent containers"
docker compose stop agents agents-llama 2>/dev/null || true
docker compose up -d agents agents-llama
log "Agent containers restarted"
# ── Done ─────────────────────────────────────────────────────────────────
log "Release ${RELEASE_VERSION} completed successfully"

View file

@ -96,7 +96,7 @@ disinto_release() {
id = "${id}"
formula = "release"
context = "Release ${version}"
secrets = []
secrets = ["GITHUB_TOKEN", "CODEBERG_TOKEN"]
EOF
echo "Created vault item: ${vault_toml}"

View file

@ -12,7 +12,7 @@
# id = "release-v120"
# formula = "release"
# context = "Release v1.2.0"
# secrets = []
# secrets = ["GITHUB_TOKEN", "CODEBERG_TOKEN"]
#
# Steps executed by the release formula:
# 1. preflight - Validate prerequisites (version, FORGE_TOKEN, Docker)
@ -26,7 +26,7 @@
id = "release-v120"
formula = "release"
context = "Release v1.2.0 — includes vault redesign, .profile system, architect agent"
secrets = []
secrets = ["GITHUB_TOKEN", "CODEBERG_TOKEN"]
# Optional: specify a larger model for complex release logic
# model = "sonnet"

View file

@ -29,7 +29,7 @@ fi
# =============================================================================
# Allowed secret names - must match keys in .env.vault.enc
VAULT_ALLOWED_SECRETS="CLAWHUB_TOKEN GITHUB_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN"
VAULT_ALLOWED_SECRETS="CLAWHUB_TOKEN GITHUB_TOKEN CODEBERG_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN"
# Validate a vault action TOML file
# Usage: validate_vault_action <path-to-toml>
@ -99,9 +99,9 @@ validate_vault_action() {
return 1
fi
# Validate formula exists in formulas/
if [ ! -f "$formulas_dir/${formula}.toml" ]; then
echo "ERROR: Formula not found: $formula" >&2
# Validate formula exists in formulas/ (.toml for Claude reasoning, .sh for mechanical)
if [ ! -f "$formulas_dir/${formula}.toml" ] && [ ! -f "$formulas_dir/${formula}.sh" ]; then
echo "ERROR: Formula not found: $formula (checked .toml and .sh)" >&2
return 1
fi