feat(backend): Add Celery worker infrastructure with task stubs

- Add Celery app configuration with Redis broker/backend
- Add task modules: agent, workflow, cost, git, sync
- Add task stubs for:
  - Agent execution (spawn, heartbeat, terminate)
  - Workflow orchestration (start sprint, checkpoint, code review)
  - Cost tracking (record usage, calculate, generate report)
  - Git operations (clone, commit, push, sync)
  - External sync (import issues, export updates)
- Add task tests directory structure
- Configure for production-ready Celery setup

Implements #18

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 02:08:14 +01:00
parent acfda1e9a9
commit 11da0d57a8
14 changed files with 3149 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
# app/tasks/__init__.py
"""
Celery background tasks for Syndarix.
This package contains all Celery tasks organized by domain:
Modules:
agent: Agent execution tasks (run_agent_step, spawn_agent, terminate_agent)
git: Git operation tasks (clone, commit, branch, push, PR)
sync: Issue synchronization tasks (incremental/full sync, webhooks)
workflow: Workflow state management tasks
cost: Cost tracking and budget monitoring tasks
"""
from app.tasks import agent, cost, git, sync, workflow
__all__ = [
"agent",
"cost",
"git",
"sync",
"workflow",
]

150
backend/app/tasks/agent.py Normal file
View File

@@ -0,0 +1,150 @@
# app/tasks/agent.py
"""
Agent execution tasks for Syndarix.
These tasks handle the lifecycle of AI agent instances:
- Spawning new agent instances from agent types
- Executing agent steps (LLM calls, tool execution)
- Terminating agent instances
Tasks are routed to the 'agent' queue for dedicated processing.
"""
import logging
from typing import Any
from app.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="app.tasks.agent.run_agent_step")
def run_agent_step(
self,
agent_instance_id: str,
context: dict[str, Any],
) -> dict[str, Any]:
"""
Execute a single step of an agent's workflow.
This task performs one iteration of the agent loop:
1. Load agent instance state
2. Call LLM with context and available tools
3. Execute tool calls if any
4. Update agent state
5. Return result for next step or completion
Args:
agent_instance_id: UUID of the agent instance
context: Current execution context including:
- messages: Conversation history
- tools: Available tool definitions
- state: Agent state data
- metadata: Project/task metadata
Returns:
dict with status and agent_instance_id
"""
logger.info(
f"Running agent step for instance {agent_instance_id} with context keys: {list(context.keys())}"
)
# TODO: Implement actual agent step execution
# This will involve:
# 1. Loading agent instance from database
# 2. Calling LLM provider (via litellm or anthropic SDK)
# 3. Processing tool calls through MCP servers
# 4. Updating agent state in database
# 5. Scheduling next step if needed
return {
"status": "pending",
"agent_instance_id": agent_instance_id,
}
@celery_app.task(bind=True, name="app.tasks.agent.spawn_agent")
def spawn_agent(
self,
agent_type_id: str,
project_id: str,
initial_context: dict[str, Any],
) -> dict[str, Any]:
"""
Spawn a new agent instance from an agent type.
This task creates a new agent instance:
1. Load agent type configuration (model, expertise, personality)
2. Create agent instance record in database
3. Initialize agent state with project context
4. Start first agent step
Args:
agent_type_id: UUID of the agent type template
project_id: UUID of the project this agent will work on
initial_context: Starting context including:
- goal: High-level objective
- constraints: Any limitations or requirements
- assigned_issues: Issues to work on
- autonomy_level: FULL_CONTROL, MILESTONE, or AUTONOMOUS
Returns:
dict with status, agent_type_id, and project_id
"""
logger.info(
f"Spawning agent of type {agent_type_id} for project {project_id}"
)
# TODO: Implement agent spawning
# This will involve:
# 1. Loading agent type from database
# 2. Creating agent instance record
# 3. Setting up MCP tool access
# 4. Initializing agent state
# 5. Kicking off first step
return {
"status": "spawned",
"agent_type_id": agent_type_id,
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.agent.terminate_agent")
def terminate_agent(
self,
agent_instance_id: str,
reason: str,
) -> dict[str, Any]:
"""
Terminate an agent instance.
This task gracefully shuts down an agent:
1. Mark agent instance as terminated
2. Save final state for audit
3. Release any held resources
4. Notify relevant subscribers
Args:
agent_instance_id: UUID of the agent instance
reason: Reason for termination (completion, error, manual, budget)
Returns:
dict with status and agent_instance_id
"""
logger.info(
f"Terminating agent instance {agent_instance_id} with reason: {reason}"
)
# TODO: Implement agent termination
# This will involve:
# 1. Loading agent instance
# 2. Updating status to terminated
# 3. Saving termination reason
# 4. Cleaning up any pending tasks
# 5. Sending termination event
return {
"status": "terminated",
"agent_instance_id": agent_instance_id,
}

201
backend/app/tasks/cost.py Normal file
View File

@@ -0,0 +1,201 @@
# app/tasks/cost.py
"""
Cost tracking and budget management tasks for Syndarix.
These tasks implement multi-layered cost tracking per ADR-012:
- Per-agent token usage tracking
- Project budget monitoring
- Daily cost aggregation
- Budget threshold alerts
- Cost reporting
Costs are tracked in real-time in Redis for speed,
then aggregated to PostgreSQL for durability.
"""
import logging
from typing import Any
from app.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="app.tasks.cost.aggregate_daily_costs")
def aggregate_daily_costs(self) -> dict[str, Any]:
"""
Aggregate daily costs from Redis to PostgreSQL.
This periodic task (runs daily):
1. Read accumulated costs from Redis
2. Aggregate by project, agent, and model
3. Store in PostgreSQL cost_records table
4. Clear Redis counters for new day
Returns:
dict with status
"""
logger.info("Starting daily cost aggregation")
# TODO: Implement cost aggregation
# This will involve:
# 1. Fetching cost data from Redis
# 2. Grouping by project_id, agent_id, model
# 3. Inserting into PostgreSQL cost tables
# 4. Resetting Redis counters
return {
"status": "pending",
}
@celery_app.task(bind=True, name="app.tasks.cost.check_budget_thresholds")
def check_budget_thresholds(
self,
project_id: str,
) -> dict[str, Any]:
"""
Check if a project has exceeded budget thresholds.
This task checks budget limits:
1. Get current spend from Redis counters
2. Compare against project budget limits
3. Send alerts if thresholds exceeded
4. Pause agents if hard limit reached
Args:
project_id: UUID of the project
Returns:
dict with status and project_id
"""
logger.info(f"Checking budget thresholds for project {project_id}")
# TODO: Implement budget checking
# This will involve:
# 1. Loading project budget configuration
# 2. Getting current spend from Redis
# 3. Comparing against soft/hard limits
# 4. Sending alerts or pausing agents
return {
"status": "pending",
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.cost.record_llm_usage")
def record_llm_usage(
self,
agent_id: str,
project_id: str,
model: str,
prompt_tokens: int,
completion_tokens: int,
cost_usd: float,
) -> dict[str, Any]:
"""
Record LLM usage from an agent call.
This task tracks each LLM API call:
1. Increment Redis counters for real-time tracking
2. Store raw usage event for audit
3. Trigger budget check if threshold approaching
Args:
agent_id: UUID of the agent instance
project_id: UUID of the project
model: Model identifier (e.g., claude-opus-4-5-20251101)
prompt_tokens: Number of input tokens
completion_tokens: Number of output tokens
cost_usd: Calculated cost in USD
Returns:
dict with status, agent_id, project_id, and cost_usd
"""
logger.debug(
f"Recording LLM usage for model {model}: "
f"{prompt_tokens} prompt + {completion_tokens} completion tokens = ${cost_usd}"
)
# TODO: Implement usage recording
# This will involve:
# 1. Incrementing Redis counters
# 2. Storing usage event
# 3. Checking if near budget threshold
return {
"status": "pending",
"agent_id": agent_id,
"project_id": project_id,
"cost_usd": cost_usd,
}
@celery_app.task(bind=True, name="app.tasks.cost.generate_cost_report")
def generate_cost_report(
self,
project_id: str,
start_date: str,
end_date: str,
) -> dict[str, Any]:
"""
Generate a cost report for a project.
This task creates a detailed cost breakdown:
1. Query cost records for date range
2. Group by agent, model, and day
3. Calculate totals and trends
4. Format report for display
Args:
project_id: UUID of the project
start_date: Report start date (YYYY-MM-DD)
end_date: Report end date (YYYY-MM-DD)
Returns:
dict with status, project_id, and date range
"""
logger.info(
f"Generating cost report for project {project_id} from {start_date} to {end_date}"
)
# TODO: Implement report generation
# This will involve:
# 1. Querying PostgreSQL for cost records
# 2. Aggregating by various dimensions
# 3. Calculating totals and averages
# 4. Formatting report data
return {
"status": "pending",
"project_id": project_id,
"start_date": start_date,
"end_date": end_date,
}
@celery_app.task(bind=True, name="app.tasks.cost.reset_daily_budget_counters")
def reset_daily_budget_counters(self) -> dict[str, Any]:
"""
Reset daily budget counters in Redis.
This periodic task (runs daily at midnight UTC):
1. Archive current day's counters
2. Reset all daily budget counters
3. Prepare for new day's tracking
Returns:
dict with status
"""
logger.info("Resetting daily budget counters")
# TODO: Implement counter reset
# This will involve:
# 1. Getting all daily counter keys from Redis
# 2. Archiving current values
# 3. Resetting counters to zero
return {
"status": "pending",
}

225
backend/app/tasks/git.py Normal file
View File

@@ -0,0 +1,225 @@
# app/tasks/git.py
"""
Git operation tasks for Syndarix.
These tasks handle Git operations for projects:
- Cloning repositories
- Creating branches
- Committing changes
- Pushing to remotes
- Creating pull requests
Tasks are routed to the 'git' queue for dedicated processing.
All operations are scoped by project_id for multi-tenancy.
"""
import logging
from typing import Any
from app.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="app.tasks.git.clone_repository")
def clone_repository(
self,
project_id: str,
repo_url: str,
branch: str = "main",
) -> dict[str, Any]:
"""
Clone a repository for a project.
This task clones a Git repository to the project workspace:
1. Prepare workspace directory
2. Clone repository with credentials
3. Checkout specified branch
4. Update project metadata
Args:
project_id: UUID of the project
repo_url: Git repository URL (HTTPS or SSH)
branch: Branch to checkout (default: main)
Returns:
dict with status and project_id
"""
logger.info(
f"Cloning repository {repo_url} for project {project_id} on branch {branch}"
)
# TODO: Implement repository cloning
# This will involve:
# 1. Getting project credentials from secrets store
# 2. Creating workspace directory
# 3. Running git clone with proper auth
# 4. Checking out the target branch
# 5. Updating project record with clone status
return {
"status": "pending",
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.git.commit_changes")
def commit_changes(
self,
project_id: str,
message: str,
files: list[str] | None = None,
) -> dict[str, Any]:
"""
Commit changes in a project repository.
This task creates a Git commit:
1. Stage specified files (or all if None)
2. Create commit with message
3. Update commit history record
Args:
project_id: UUID of the project
message: Commit message (follows conventional commits)
files: List of files to stage, or None for all staged
Returns:
dict with status and project_id
"""
logger.info(
f"Committing changes for project {project_id}: {message}"
)
# TODO: Implement commit operation
# This will involve:
# 1. Loading project workspace path
# 2. Running git add for specified files
# 3. Running git commit with message
# 4. Recording commit hash in database
return {
"status": "pending",
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.git.create_branch")
def create_branch(
self,
project_id: str,
branch_name: str,
from_ref: str = "HEAD",
) -> dict[str, Any]:
"""
Create a new branch in a project repository.
This task creates a Git branch:
1. Checkout from reference
2. Create new branch
3. Update branch tracking
Args:
project_id: UUID of the project
branch_name: Name of the new branch (e.g., feature/123-description)
from_ref: Reference to branch from (default: HEAD)
Returns:
dict with status and project_id
"""
logger.info(
f"Creating branch {branch_name} from {from_ref} for project {project_id}"
)
# TODO: Implement branch creation
# This will involve:
# 1. Loading project workspace
# 2. Running git checkout -b from_ref
# 3. Recording branch in database
return {
"status": "pending",
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.git.create_pull_request")
def create_pull_request(
self,
project_id: str,
title: str,
body: str,
head_branch: str,
base_branch: str = "main",
) -> dict[str, Any]:
"""
Create a pull request for a project.
This task creates a PR on the external Git provider:
1. Push branch if needed
2. Create PR via API (Gitea, GitHub, GitLab)
3. Store PR reference
Args:
project_id: UUID of the project
title: PR title
body: PR description (markdown)
head_branch: Branch with changes
base_branch: Target branch (default: main)
Returns:
dict with status and project_id
"""
logger.info(
f"Creating PR '{title}' from {head_branch} to {base_branch} for project {project_id}"
)
# TODO: Implement PR creation
# This will involve:
# 1. Loading project and Git provider config
# 2. Ensuring head_branch is pushed
# 3. Calling provider API to create PR
# 4. Storing PR URL and number
return {
"status": "pending",
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.git.push_changes")
def push_changes(
self,
project_id: str,
branch: str,
force: bool = False,
) -> dict[str, Any]:
"""
Push changes to remote repository.
This task pushes commits to the remote:
1. Verify authentication
2. Push branch to remote
3. Handle push failures
Args:
project_id: UUID of the project
branch: Branch to push
force: Whether to force push (use with caution)
Returns:
dict with status and project_id
"""
logger.info(
f"Pushing branch {branch} for project {project_id} (force={force})"
)
# TODO: Implement push operation
# This will involve:
# 1. Loading project credentials
# 2. Running git push (with --force if specified)
# 3. Handling authentication and conflicts
return {
"status": "pending",
"project_id": project_id,
}

198
backend/app/tasks/sync.py Normal file
View File

@@ -0,0 +1,198 @@
# app/tasks/sync.py
"""
Issue synchronization tasks for Syndarix.
These tasks handle bidirectional issue synchronization:
- Incremental sync (polling for recent changes)
- Full reconciliation (daily comprehensive sync)
- Webhook event processing
- Pushing local changes to external trackers
Tasks are routed to the 'sync' queue for dedicated processing.
Per ADR-011, sync follows a master/replica model with configurable direction.
"""
import logging
from typing import Any
from app.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="app.tasks.sync.sync_issues_incremental")
def sync_issues_incremental(self) -> dict[str, Any]:
"""
Perform incremental issue synchronization across all projects.
This periodic task (runs every 5 minutes):
1. Query each project's external tracker for recent changes
2. Compare with local issue cache
3. Apply updates to local database
4. Handle conflicts based on sync direction config
Returns:
dict with status and type
"""
logger.info("Starting incremental issue sync across all projects")
# TODO: Implement incremental sync
# This will involve:
# 1. Loading all active projects with sync enabled
# 2. For each project, querying external tracker since last_sync_at
# 3. Upserting issues into local database
# 4. Updating last_sync_at timestamp
return {
"status": "pending",
"type": "incremental",
}
@celery_app.task(bind=True, name="app.tasks.sync.sync_issues_full")
def sync_issues_full(self) -> dict[str, Any]:
"""
Perform full issue reconciliation across all projects.
This periodic task (runs daily):
1. Fetch all issues from external trackers
2. Compare with local database
3. Handle orphaned issues
4. Resolve any drift between systems
Returns:
dict with status and type
"""
logger.info("Starting full issue reconciliation across all projects")
# TODO: Implement full sync
# This will involve:
# 1. Loading all active projects
# 2. Fetching complete issue lists from external trackers
# 3. Comparing with local database
# 4. Handling deletes and orphans
# 5. Resolving conflicts based on sync config
return {
"status": "pending",
"type": "full",
}
@celery_app.task(bind=True, name="app.tasks.sync.process_webhook_event")
def process_webhook_event(
self,
provider: str,
event_type: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""
Process a webhook event from an external Git provider.
This task handles real-time updates from:
- Gitea: issue.created, issue.updated, pull_request.*, etc.
- GitHub: issues, pull_request, push, etc.
- GitLab: issue events, merge request events, etc.
Args:
provider: Git provider name (gitea, github, gitlab)
event_type: Event type from provider
payload: Raw webhook payload
Returns:
dict with status, provider, and event_type
"""
logger.info(f"Processing webhook event from {provider}: {event_type}")
# TODO: Implement webhook processing
# This will involve:
# 1. Validating webhook signature
# 2. Parsing provider-specific payload
# 3. Mapping to internal event format
# 4. Updating local database
# 5. Triggering any dependent workflows
return {
"status": "pending",
"provider": provider,
"event_type": event_type,
}
@celery_app.task(bind=True, name="app.tasks.sync.sync_project_issues")
def sync_project_issues(
self,
project_id: str,
full: bool = False,
) -> dict[str, Any]:
"""
Synchronize issues for a specific project.
This task can be triggered manually or by webhooks:
1. Connect to project's external tracker
2. Fetch issues (incremental or full)
3. Update local database
Args:
project_id: UUID of the project
full: Whether to do full sync or incremental
Returns:
dict with status and project_id
"""
logger.info(
f"Syncing issues for project {project_id} (full={full})"
)
# TODO: Implement project-specific sync
# This will involve:
# 1. Loading project configuration
# 2. Connecting to external tracker
# 3. Fetching issues based on full flag
# 4. Upserting to database
return {
"status": "pending",
"project_id": project_id,
}
@celery_app.task(bind=True, name="app.tasks.sync.push_issue_to_external")
def push_issue_to_external(
self,
project_id: str,
issue_id: str,
operation: str,
) -> dict[str, Any]:
"""
Push a local issue change to the external tracker.
This task handles outbound sync when Syndarix is the master:
- create: Create new issue in external tracker
- update: Update existing issue
- close: Close issue in external tracker
Args:
project_id: UUID of the project
issue_id: UUID of the local issue
operation: Operation type (create, update, close)
Returns:
dict with status, issue_id, and operation
"""
logger.info(
f"Pushing {operation} for issue {issue_id} in project {project_id}"
)
# TODO: Implement outbound sync
# This will involve:
# 1. Loading issue and project config
# 2. Mapping to external tracker format
# 3. Calling provider API
# 4. Updating external_id mapping
return {
"status": "pending",
"issue_id": issue_id,
"operation": operation,
}

View File

@@ -0,0 +1,213 @@
# app/tasks/workflow.py
"""
Workflow state management tasks for Syndarix.
These tasks manage workflow execution and state transitions:
- Sprint workflows (planning -> implementation -> review -> done)
- Story workflows (todo -> in_progress -> review -> done)
- Approval checkpoints for autonomy levels
- Stale workflow recovery
Per ADR-007 and ADR-010, workflow state is durable in PostgreSQL
with defined state transitions.
"""
import logging
from typing import Any
from app.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="app.tasks.workflow.recover_stale_workflows")
def recover_stale_workflows(self) -> dict[str, Any]:
"""
Recover workflows that have become stale.
This periodic task (runs every 5 minutes):
1. Find workflows stuck in intermediate states
2. Check for timed-out agent operations
3. Retry or escalate based on configuration
4. Notify relevant users if needed
Returns:
dict with status and recovered count
"""
logger.info("Checking for stale workflows to recover")
# TODO: Implement stale workflow recovery
# This will involve:
# 1. Querying for workflows with last_updated > threshold
# 2. Checking if associated agents are still running
# 3. Retrying or resetting stuck workflows
# 4. Sending notifications for manual intervention
return {
"status": "pending",
"recovered": 0,
}
@celery_app.task(bind=True, name="app.tasks.workflow.execute_workflow_step")
def execute_workflow_step(
self,
workflow_id: str,
transition: str,
) -> dict[str, Any]:
"""
Execute a state transition for a workflow.
This task applies a transition to a workflow:
1. Validate transition is allowed from current state
2. Execute any pre-transition hooks
3. Update workflow state
4. Execute any post-transition hooks
5. Trigger follow-up tasks
Args:
workflow_id: UUID of the workflow
transition: Transition to execute (start, approve, reject, etc.)
Returns:
dict with status, workflow_id, and transition
"""
logger.info(
f"Executing transition '{transition}' for workflow {workflow_id}"
)
# TODO: Implement workflow transition
# This will involve:
# 1. Loading workflow from database
# 2. Validating transition from current state
# 3. Running pre-transition hooks
# 4. Updating state in database
# 5. Running post-transition hooks
# 6. Scheduling follow-up tasks
return {
"status": "pending",
"workflow_id": workflow_id,
"transition": transition,
}
@celery_app.task(bind=True, name="app.tasks.workflow.handle_approval_response")
def handle_approval_response(
self,
workflow_id: str,
approved: bool,
comment: str | None = None,
) -> dict[str, Any]:
"""
Handle a user approval response for a workflow checkpoint.
This task processes approval decisions:
1. Record approval decision with timestamp
2. Update workflow state accordingly
3. Resume or halt workflow execution
4. Notify relevant parties
Args:
workflow_id: UUID of the workflow
approved: Whether the checkpoint was approved
comment: Optional comment from approver
Returns:
dict with status, workflow_id, and approved flag
"""
logger.info(
f"Handling approval response for workflow {workflow_id}: approved={approved}"
)
# TODO: Implement approval handling
# This will involve:
# 1. Loading workflow and approval checkpoint
# 2. Recording decision with user and timestamp
# 3. Transitioning workflow state
# 4. Resuming or stopping execution
# 5. Sending notifications
return {
"status": "pending",
"workflow_id": workflow_id,
"approved": approved,
}
@celery_app.task(bind=True, name="app.tasks.workflow.start_sprint_workflow")
def start_sprint_workflow(
self,
project_id: str,
sprint_id: str,
) -> dict[str, Any]:
"""
Start a new sprint workflow.
This task initializes sprint execution:
1. Create sprint workflow record
2. Set up sprint planning phase
3. Spawn Product Owner agent for planning
4. Begin story assignment
Args:
project_id: UUID of the project
sprint_id: UUID of the sprint
Returns:
dict with status and sprint_id
"""
logger.info(
f"Starting sprint workflow for sprint {sprint_id} in project {project_id}"
)
# TODO: Implement sprint workflow initialization
# This will involve:
# 1. Creating workflow record for sprint
# 2. Setting initial state to PLANNING
# 3. Spawning PO agent for sprint planning
# 4. Setting up monitoring and checkpoints
return {
"status": "pending",
"sprint_id": sprint_id,
}
@celery_app.task(bind=True, name="app.tasks.workflow.start_story_workflow")
def start_story_workflow(
self,
project_id: str,
story_id: str,
) -> dict[str, Any]:
"""
Start a new story workflow.
This task initializes story execution:
1. Create story workflow record
2. Spawn appropriate developer agent
3. Set up implementation tracking
4. Configure approval checkpoints based on autonomy level
Args:
project_id: UUID of the project
story_id: UUID of the story/issue
Returns:
dict with status and story_id
"""
logger.info(
f"Starting story workflow for story {story_id} in project {project_id}"
)
# TODO: Implement story workflow initialization
# This will involve:
# 1. Creating workflow record for story
# 2. Determining appropriate agent type
# 3. Spawning developer agent
# 4. Setting up checkpoints based on autonomy level
return {
"status": "pending",
"story_id": story_id,
}