fix: Compose generator should detect duplicate service names at generate-time (#850) #872

Closed
dev-qwen wants to merge 2 commits from fix/issue-850 into main
4 changed files with 315 additions and 2 deletions

View file

@ -294,6 +294,9 @@ def main() -> int:
"9f6ae8e7811575b964279d8820494eb0": "Verification helper: for loop done pattern",
# Standard lib source block shared across formula-driven agent run scripts
"330e5809a00b95ade1a5fce2d749b94b": "Standard lib source block (env.sh, formula-session.sh, worktree.sh, guard.sh, agent-sdk.sh)",
# Test files for duplicate service detection (#850)
"334967b8b4f1a8d3b0b9b8e0912f3bfb": "TOML [agents.llama] block in smoke-init.sh and test-duplicate-service-detection.sh",
"d82f30077e5bb23b5fc01db003033d5d": "TOML base_url/model/roles in smoke-init.sh and test-duplicate-service-detection.sh",
}
if not sh_files:

View file

@ -66,6 +66,28 @@ _get_primary_woodpecker_repo_id() {
echo "$max_id"
}
# Track service names for duplicate detection
declare -A _seen_services
declare -A _service_sources
# Record a service name and its source; return 0 if unique, 1 if duplicate
_record_service() {
local service_name="$1"
local source="$2"
if [ -n "${_seen_services[$service_name]:-}" ]; then
local original_source="${_service_sources[$service_name]}"
echo "ERROR: Duplicate service name '$service_name' detected —" >&2
echo " '$service_name' emitted twice — from $original_source and from $source" >&2
echo " Remove one of the conflicting activations to proceed." >&2
return 1
fi
_seen_services[$service_name]=1
_service_sources[$service_name]="$source"
return 0
}
# Parse project TOML for local-model agents and emit compose services.
# Writes service definitions to stdout; caller handles insertion into compose file.
_generate_local_model_services() {
@ -97,6 +119,16 @@ _generate_local_model_services() {
POLL_INTERVAL) poll_interval_val="$value" ;;
---)
if [ -n "$service_name" ] && [ -n "$base_url" ]; then
# Record service for duplicate detection using the full service name
local full_service_name="agents-${service_name}"
local toml_basename
toml_basename=$(basename "$toml")
if ! _record_service "$full_service_name" "[agents.$service_name] in projects/$toml_basename"; then
# Duplicate detected — clean up and abort
rm -f "$temp_file"
return 1
fi
# Per-agent FORGE_TOKEN / FORGE_PASS lookup (#834 Gap 3).
# Two hired llama agents must not share the same Forgejo identity,
# so we key the env-var lookup by forge_user (which hire-agent.sh
@ -109,11 +141,14 @@ _generate_local_model_services() {
# the service caused `disinto up` (without COMPOSE_PROFILES)
# to treat the hired container as an orphan and silently
# remove it via --remove-orphans.
# Compute the actual service name that will be emitted
local full_service_name="agents-${service_name}"
local user_upper
user_upper=$(echo "$forge_user" | tr 'a-z-' 'A-Z_')
cat >> "$temp_file" <<EOF
agents-${service_name}:
${full_service_name}:
image: ghcr.io/disinto/agents:\${DISINTO_IMAGE_TAG:-latest}
container_name: disinto-agents-${service_name}
restart: unless-stopped
@ -265,6 +300,17 @@ _generate_compose_impl() {
return 0
fi
# Initialize duplicate detection with base services defined in the template
_record_service "agents" "base compose template" || return 1
_record_service "forgejo" "base compose template" || return 1
_record_service "woodpecker" "base compose template" || return 1
_record_service "woodpecker-agent" "base compose template" || return 1
_record_service "runner" "base compose template" || return 1
_record_service "edge" "base compose template" || return 1
_record_service "staging" "base compose template" || return 1
_record_service "staging-deploy" "base compose template" || return 1
_record_service "chat" "base compose template" || return 1
# Extract primary woodpecker_repo_id from project TOML files
local wp_repo_id
wp_repo_id=$(_get_primary_woodpecker_repo_id)
@ -425,6 +471,14 @@ COMPOSEEOF
# Local-Qwen dev agent — gated on ENABLE_LLAMA_AGENT so factories without
# a local llama endpoint don't try to start it. See docs/agents-llama.md.
if [ "${ENABLE_LLAMA_AGENT:-0}" = "1" ]; then
# Check for duplicate with TOML-configured agents
if ! _record_service "agents-llama" "ENABLE_LLAMA_AGENT=1"; then
return 1
fi
if ! _record_service "agents-llama-all" "ENABLE_LLAMA_AGENT=1"; then
return 1
fi
cat >> "$compose_file" <<'LLAMAEOF'
agents-llama:
@ -740,7 +794,10 @@ COMPOSEEOF
fi
# Append local-model agent services if any are configured
_generate_local_model_services "$compose_file"
if ! _generate_local_model_services "$compose_file"; then
echo "ERROR: Failed to generate local-model agent services. See errors above." >&2
return 1
fi
# Resolve the Claude CLI binary path and persist as CLAUDE_BIN_DIR in .env.
# docker-compose.yml references ${CLAUDE_BIN_DIR} so the value must be set.

View file

@ -423,6 +423,49 @@ export CLAUDE_SHARED_DIR="$ORIG_CLAUDE_SHARED_DIR"
export CLAUDE_CONFIG_DIR="$ORIG_CLAUDE_CONFIG_DIR"
rm -rf /tmp/smoke-claude-shared /tmp/smoke-home-claude
# ── 8. Test duplicate service name detection ──────────────────────────────
echo "=== 8/8 Testing duplicate service name detection ==="
# Clean up for duplicate test
rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml"
# Create a TOML that would conflict with ENABLE_LLAMA_AGENT
cat > "${FACTORY_ROOT}/projects/duplicate-test.toml" <<'TOMLEOF'
name = "duplicate-test"
description = "Test project for duplicate service detection"
[ci]
woodpecker_repo_id = "999"
[agents.llama]
base_url = "http://localhost:8080"
model = "qwen:latest"
roles = ["dev"]
forge_user = "llama-bot"
TOMLEOF
# Run disinto init with ENABLE_LLAMA_AGENT=1
# This should fail because [agents.llama] conflicts with ENABLE_LLAMA_AGENT
export ENABLE_LLAMA_AGENT="1"
export FORGE_URL="http://localhost:3000"
export SMOKE_FORGE_URL="$FORGE_URL"
export FORGE_ADMIN_PASS="smoke-test-password-123"
export SKIP_PUSH=true
if bash "${FACTORY_ROOT}/bin/disinto" init \
"duplicate-test" \
--bare --yes \
--forge-url "$FORGE_URL" \
--repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Duplicate service name 'agents-llama'"; then
pass "Duplicate service detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]"
else
fail "Duplicate service detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]"
fi
# Clean up
rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml"
unset ENABLE_LLAMA_AGENT
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
if [ "$FAILED" -ne 0 ]; then

View file

@ -0,0 +1,210 @@
#!/usr/bin/env bash
# tests/test-duplicate-service-detection.sh — Unit test for duplicate service detection
#
# Tests that the compose generator correctly detects duplicate service names
# between ENABLE_LLAMA_AGENT=1 and [agents.llama] TOML configuration.
set -euo pipefail
# Get the absolute path to the disinto root
DISINTO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEST_DIR=$(mktemp -d)
trap "rm -rf \"\$TEST_DIR\"" EXIT
FAILED=0
fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; }
pass() { printf 'PASS: %s\n' "$*"; }
# Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama]
echo "=== Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] ==="
# Create projects directory and test project TOML with an agent named "llama"
mkdir -p "${TEST_DIR}/projects"
cat > "${TEST_DIR}/projects/test-project.toml" <<'TOMLEOF'
name = "test-project"
description = "Test project for duplicate detection"
[ci]
woodpecker_repo_id = "123"
[agents.llama]
base_url = "http://localhost:8080"
model = "qwen:latest"
roles = ["dev"]
forge_user = "llama-bot"
TOMLEOF
# Create a minimal compose file
cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF'
# Test compose file
services:
agents:
image: test:latest
command: echo "hello"
volumes:
test-data:
networks:
test-net:
COMPOSEEOF
# Set up the test environment
export FACTORY_ROOT="${TEST_DIR}"
export PROJECT_NAME="test-project"
export ENABLE_LLAMA_AGENT="1"
export FORGE_TOKEN=""
export FORGE_PASS=""
export CLAUDE_TIMEOUT="7200"
export POLL_INTERVAL="300"
export GARDENER_INTERVAL="21600"
export ARCHITECT_INTERVAL="21600"
export PLANNER_INTERVAL="43200"
export SUPERVISOR_INTERVAL="1200"
# Source the generators module and run the compose generator directly
source "${DISINTO_ROOT}/lib/generators.sh"
# Delete the compose file to force regeneration
rm -f "${TEST_DIR}/docker-compose.yml"
# Run the compose generator directly
if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output.txt"; then
# Check if the output contains the duplicate error message
if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then
pass "Duplicate detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]"
else
fail "Duplicate detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]"
cat "${TEST_DIR}/output.txt" >&2
fi
else
# Generator should fail with non-zero exit code
if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then
pass "Duplicate detection: correctly detected conflict and returned non-zero exit code"
else
fail "Duplicate detection: should have failed with duplicate error"
cat "${TEST_DIR}/output.txt" >&2
fi
fi
# Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set (no conflicting TOML)
echo ""
echo "=== Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set ==="
# Remove the projects directory created in Test 1
rm -rf "${TEST_DIR}/projects"
# Create a fresh compose file
cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF'
# Test compose file
services:
agents:
image: test:latest
volumes:
test-data:
networks:
test-net:
COMPOSEEOF
# Set ENABLE_LLAMA_AGENT
export ENABLE_LLAMA_AGENT="1"
# Delete the compose file to force regeneration
rm -f "${TEST_DIR}/docker-compose.yml"
if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output2.txt"; then
if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then
fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set"
else
pass "No duplicate: correctly generated compose without duplicates"
fi
else
# Non-zero exit is fine if there's a legitimate reason (e.g., missing files)
if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then
fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set"
else
pass "No duplicate: generator failed for other reason (acceptable)"
fi
fi
# Test 3: Duplicate between two TOML agents with same name
echo ""
echo "=== Test 3: Duplicate between two TOML agents with same name ==="
rm -f "${TEST_DIR}/docker-compose.yml"
# Create projects directory for Test 3
mkdir -p "${TEST_DIR}/projects"
cat > "${TEST_DIR}/projects/project1.toml" <<'TOMLEOF'
name = "project1"
description = "First project"
[ci]
woodpecker_repo_id = "1"
[agents.llama]
base_url = "http://localhost:8080"
model = "qwen:latest"
roles = ["dev"]
forge_user = "llama-bot1"
TOMLEOF
cat > "${TEST_DIR}/projects/project2.toml" <<'TOMLEOF'
name = "project2"
description = "Second project"
[ci]
woodpecker_repo_id = "2"
[agents.llama]
base_url = "http://localhost:8080"
model = "qwen:latest"
roles = ["dev"]
forge_user = "llama-bot2"
TOMLEOF
cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF'
# Test compose file
services:
agents:
image: test:latest
volumes:
test-data:
networks:
test-net:
COMPOSEEOF
unset ENABLE_LLAMA_AGENT
# Delete the compose file to force regeneration
rm -f "${TEST_DIR}/docker-compose.yml"
if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output3.txt"; then
if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then
pass "Duplicate detection: correctly detected conflict between two [agents.llama] blocks"
else
fail "Duplicate detection: should have detected conflict between two [agents.llama] blocks"
cat "${TEST_DIR}/output3.txt" >&2
fi
else
if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then
pass "Duplicate detection: correctly detected conflict and returned non-zero exit code"
else
fail "Duplicate detection: should have failed with duplicate error"
cat "${TEST_DIR}/output3.txt" >&2
fi
fi
# Summary
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "=== TESTS FAILED ==="
exit 1
fi
echo "=== ALL TESTS PASSED ==="