Bot Workflow Loop¶
The bot operates as an autonomous loop: a scheduler triggers cycles, lightweight Python scripts gather data and decide whether there's work to do, and only then does a Claude AI session start. This design ensures AI tokens are spent only when there's real work — the common "nothing to do" case costs zero.
Architecture Overview¶
graph TB
subgraph Scheduler["Scheduler"]
KEDA["KEDA Cron Scaler<br/>(Kubernetes)"]
end
subgraph Core["Polling Loop (bot/run.py)"]
Loop["preflight → session or sleep → repeat"]
Preflight["Preflight Scripts<br/>(Python, $0)"]
Session["Claude Code Session<br/>(AI, tokens)"]
Sleep["Sleep<br/>(no session)"]
end
subgraph Persistence["Memory Layer"]
Memory["Memory Server<br/>(FastMCP + PostgreSQL)"]
TasksDB["Tasks DB"]
SSE["SSE Event Bus"]
REST["REST API"]
end
KEDA -->|"pod running"| Loop
Loop -->|"each cycle"| Preflight
Preflight -->|"start"| Session
Preflight -->|"skip"| Sleep
Session -->|"MCP calls"| Memory
Memory --- TasksDB
Memory --- SSE
Memory --- REST
The Cycle¶
Each iteration of the polling loop follows this sequence:
graph TD
Sync["1. Sync remote config<br/>(git pull instance config repo)"]
Load["2. Load instance config<br/>(workflow selection, env presets)"]
Assemble["3. Assemble CLAUDE.md<br/>(core + workflow + instance)"]
Run["4. Run preflight scripts<br/>(Python, sequential, all run)"]
Agg{"5. Aggregate results"}
Launch["6. Launch Claude session<br/>(preflight content in prompt)"]
Orphan["7. Record orphan cycle"]
SleepNode["Sleep (default 5 minutes)"]
Cleanup["8. Cleanup<br/>(costs, transcripts, cache)"]
LoopBack["9. Loop back to step 1"]
Sync --> Load --> Assemble --> Run --> Agg
Agg -->|"any 'start'"| Launch --> Cleanup
Agg -->|"all 'skip'"| Orphan --> SleepNode --> Cleanup
Cleanup --> LoopBack
When AI Runs vs When It Doesn't¶
| Scenario | AI runs? | Cost |
|---|---|---|
| No active tasks, no open bot PRs | No | $0 |
| Active task, PR CI still pending | No | $0 |
| Active task, PR is clean (no issues) | No | $0 |
| Active task, PR CI failed | Yes | tokens |
| Active task, PR has review feedback | Yes | tokens |
| Active task, PR was merged | Yes | tokens |
| No active tasks, new Jira candidate found | Yes | tokens |
| All preflight scripts error (API down) | No | $0 (backoff) |
The common case — "nothing changed since last cycle" — is handled entirely by Python scripts. The AI only wakes up when a preflight script explicitly returns "start".
An orphan cycle is the recorded result when all preflight scripts return skip. No Claude session starts, so it normally has zero tools, zero tokens, and $0 model cost. The name does not mean failed or abandoned work; it marks an idle polling check with no task execution.
Preflight System¶
Preflight scripts are Python programs that run before each Claude session. They gather data from external systems (GitHub, GitLab, Jira, memory server), classify it, and decide whether the AI should wake up.
For the full reference on writing preflight scripts — output contract, naming conventions, execution model, shared utilities, error handling, and inter-script state — see Writing Custom Preflight Scripts.
This section covers the concepts that tie preflight into the broader workflow loop.
Aggregation¶
All scripts run to completion before any decision is made. There is no short-circuit — if script 01 returns "start", scripts 02, 03 still run because the AI needs the full picture.
graph LR
S1["Script 01<br/>start"]
S2["Script 02<br/>skip"]
S3["Script 03<br/>start"]
S4["Script 04<br/>error"]
Agg["_aggregate()"]
Claude["Claude receives ONE prompt<br/>with ALL data"]
S1 --> Agg
S2 --> Agg
S3 --> Agg
S4 --> Agg
Agg --> Claude
| Condition | Result |
|---|---|
Any script returns "start" (others skip or error) |
Session starts. All content merged. |
All scripts return "skip" |
No session. Loop sleeps. |
All scripts return "error" |
No session. Exponential backoff (up to 300s). |
One session receives all data — not one session per "start". This lets Claude triage across all data sources. Errors are prepended as [PREFLIGHT ERROR] warnings so Claude knows a data source is degraded.
Preflight Is Read-Only¶
Preflight scripts only read tasks — they never create, update, or archive them. This separation is intentional: preflights are pure functions over external state. They can never corrupt the task system, even if they crash. See the design doc for the rationale.
What a Preflight Script Must Do¶
Every preflight script follows a two-phase pattern. The order matters — check tasks first, then check for work.
graph TD
Start["Preflight script starts"]
Tasks["Phase 1: get_tasks() + get_capacity()"]
Dup{"Task with MY prefix<br/>already active?"}
Cap{"At capacity?<br/>(active ≥ max)"}
Work["Phase 2: Query external system<br/>(GitHub, Jira, Jenkins, etc.)"]
Found{"Actionable<br/>work found?"}
SkipDup["skip: Already in progress"]
SkipCap["skip: At capacity"]
SkipNone["skip: No work found"]
GoStart["start: structured data for Claude"]
Start --> Tasks --> Dup
Dup -->|"Yes"| SkipDup
Dup -->|"No"| Cap
Cap -->|"Yes"| SkipCap
Cap -->|"No"| Work --> Found
Found -->|"No"| SkipNone
Found -->|"Yes"| GoStart
Phase 1: Task Checks¶
These three checks prevent the bot from creating duplicate work or exceeding capacity. Every preflight script should include them:
from common import get_tasks, get_capacity, output_result
TASK_KEY_PREFIX = "my-workflow:" # unique to your workflow
tasks = get_tasks()
active_n, max_n = get_capacity()
active = [t for t in tasks if t.get("status") in ("in_progress", "pr_open", "pr_changes")]
# Check for duplicate work
my_tasks = [t for t in active if t.get("external_key", "").startswith(TASK_KEY_PREFIX)]
if my_tasks:
output_result("skip", f"Already in progress: {my_tasks[0]['external_key']}")
return
# Check capacity (global — counts ALL active tasks, not just yours)
if active_n >= max_n:
output_result("skip", f"At capacity ({active_n}/{max_n})")
return
The TASK_KEY_PREFIX is how a workflow identifies "its" tasks. Each workflow uses a different prefix (see Task Identity below).
Phase 2: Work Discovery¶
This is workflow-specific. Query your external system and decide if there's actionable work:
prs = find_bot_prs(upstream_repo, bot_author)
if len(prs) < 2:
output_result("skip", f"Only {len(prs)} PRs, need ≥2")
return
output_result(
"start",
json.dumps(
{
"repo": upstream_repo,
"pr_count": len(prs),
"prs": pr_summary,
"task_key": f"{TASK_KEY_PREFIX}{upstream_repo}", # pre-computed for the agent
}
),
)
Key points:
- The content in "start" becomes the AI's input prompt — include all data Claude needs
- Pre-compute the task_key so the agent doesn't have to figure out the key format
- Filter out noise (healthy items, resolved issues) — every character costs tokens
Preflight-to-Agent Data Handoff¶
The content field from output_result("start", content) is the only data channel between the preflight and the agent. The framework injects it into the Claude prompt like this:
## Pre-flight Data
The following data was gathered by pre-flight scripts.
Do NOT re-fetch task statuses, PR statuses, or Jira comments already shown below.
{content from all "start" scripts, concatenated}
Include everything the agent needs to act:
- What to work on — repo name, PR numbers, Jira keys
- Pre-computed task key — so the agent uses the correct external_key format
- Classification results — MERGED/CI FAIL/FEEDBACK buckets (already triaged)
- Context — comments, error messages, CI pipeline URLs
The CLAUDE.md runbook then tells the agent how to interpret this data and what actions to take.
Task State Machine¶
Tasks are the coordination primitive between cycles. They track what the bot is working on, prevent duplicate work, and manage capacity.
The 6 States¶
Defined as a PostgreSQL enum in memory-server/bot_memory_server/schema.sql:
CREATE TYPE task_status AS ENUM (
'in_progress', 'pr_open', 'pr_changes', 'paused', 'done', 'archived'
);
| Status | Blocks new work? | Who sets it | Meaning |
|---|---|---|---|
in_progress |
Yes | Agent (task_add) |
Agent is actively working (coding, testing) |
pr_open |
Yes | Agent (task_update) |
PR created, waiting for CI and/or review |
pr_changes |
Yes | Agent (task_update) |
Reviewer requested changes, agent addressing them |
paused |
No | Agent (task_update) |
Work intentionally paused (blocked on question). Has paused_reason. |
done |
No | Agent (task_update) |
Work completed — PR merged, cleanup finished |
archived |
No | Agent (task_remove) |
Soft-deleted — excluded from all queries by default |
Active Statuses¶
The three states that block new work are defined in memory-server/bot_memory_server/tools/tasks.py:
Preflight scripts use this to:
1. Prevent duplicate work — skip if a task with a matching external_key prefix is active
2. Enforce capacity — skip if active task count ≥ MAX_ACTIVE (default 10)
State Diagram¶
stateDiagram-v2
[*] --> in_progress : task_add()
[*] --> pr_open : task_add(status=pr_open)
in_progress --> pr_open : Push PR
in_progress --> paused : Blocked on question
pr_open --> pr_open : CI fix pushed
pr_open --> pr_changes : Reviewer requests changes
pr_open --> done : PR merged, cleanup done
pr_open --> done : CI failed, can't fix
pr_open --> paused : Blocked on question
pr_changes --> pr_open : Fix pushed, awaiting re-review
pr_changes --> paused : Blocked on question
paused --> in_progress : Unblocked
done --> archived : task_remove()
state in_progress {
direction LR
[*] : coding / testing
}
state pr_open {
direction LR
[*] : waiting for CI / review
}
state pr_changes {
direction LR
[*] : addressing review feedback
}
state paused {
direction LR
[*] : does NOT count as active
}
State Transitions¶
| # | From | To | Who | When |
|---|---|---|---|---|
| 1 | (none) | in_progress |
Agent via task_add |
Agent claims a Jira ticket and starts coding |
| 2 | (none) | pr_open |
Agent via task_add |
Agent creates task after pushing PR (e.g. consolidation workflows) |
| 3 | in_progress |
pr_open |
Agent via task_update |
Agent pushes code and opens a PR |
| 4 | pr_open |
pr_open |
Agent via task_update |
Pushed a CI fix, still waiting |
| 5 | pr_open |
pr_changes |
Agent via task_update |
Addressed reviewer feedback |
| 6 | pr_changes |
pr_open |
Agent via task_update |
Pushed review fix, waiting for re-review |
| 7 | pr_open |
done |
Agent via task_update |
CI passed and PR merged, cleanup complete |
| 8 | pr_open |
done |
Agent via task_update |
CI failed, can't fix, branch deleted |
| 9 | any active | paused |
Agent via task_update |
Blocked on external question |
| 10 | paused |
in_progress |
Agent via task_update |
Unblocked, resuming work |
| 11 | done |
archived |
Agent via task_remove |
Cleanup, hide from default queries |
Task Identity¶
Each task is uniquely identified by (external_key, source_type):
The external_key Convention¶
The external_key follows the pattern: <workflow-name>:<scope>
| Part | Purpose | Example |
|---|---|---|
workflow-name |
Namespace that groups all tasks from one workflow | konflux-pr-squash |
: |
Separator | — |
scope |
What specifically is being worked on | project-kessel/insights-rbac |
Full examples:
| Workflow | external_key |
source_type |
|---|---|---|
| Jira-driven | RHCLOUD-12345 |
jira |
| Konflux PR squash | konflux-pr-squash:project-kessel/insights-rbac |
github |
| Custom CI fixer | ci-fix:org/repo#42 |
github |
The workflow-name prefix is what preflight scripts use to find "their" tasks (see Phase 1: Task Checks above). It must be:
- Unique per workflow — two different workflows must not share a prefix
- Deterministic — the same work must always produce the same key
- Stable — if the bot wakes up and checks, the key shouldn't have changed
The source_type Field¶
The source_type defaults to "jira". Non-Jira workflows must set it explicitly — getting it wrong means lookups and duplicate-prevention checks will fail silently, because task_get and task_update look up by (external_key, source_type).
Common values: "jira", "github", "gitlab", "scheduled".
How Task Keys Flow Through the System¶
graph LR
PF["Preflight script<br/><br/>TASK_KEY_PREFIX =<br/>'my-workflow:'<br/><br/>Pre-computes task_key<br/>in output"]
CM["CLAUDE.md runbook<br/><br/>Defines the key<br/>format as prose<br/>instructions"]
Agent["Agent (Claude)<br/><br/>task_add(<br/> external_key=<br/> 'my-workflow:org/repo',<br/> source_type='github'<br/>)"]
PF -->|"content includes task_key"| CM -->|"agent reads instructions"| Agent
- Preflight defines the prefix — in Python code, used for duplicate checking
- Preflight pre-computes the full key — includes it in the
"start"content so the agent doesn't have to guess - CLAUDE.md documents the key format — as prose instructions for the AI agent
- Agent uses the key — in
task_addandtask_updateMCP calls
MCP Tools¶
The agent interacts with tasks through MCP tools exposed by the bot-memory server. For the full tool reference, see the core instructions.
| Tool | Purpose | Key behavior |
|---|---|---|
task_add |
Create a new task | Refuses if ≥10 active tasks. Publishes task_added event. |
task_update |
Change status, summary, metadata | Lookup by external_key + source_type. Metadata is merged (not replaced). |
task_get |
Fetch one task | Lookup by external_key + source_type. |
task_list |
List all tasks | Filters by status, instance_id. Excludes archived by default. |
task_remove |
Archive a task | Sets status to archived (soft delete, preserves history). |
task_check_capacity |
Check capacity | Returns {active: N, max: 10, has_capacity: bool}. |
Who Reads vs Who Writes¶
graph TB
DB["PostgreSQL<br/>tasks table"]
PF["Preflight scripts<br/>(Python, no AI)<br/><br/>get_tasks()<br/>get_capacity()<br/><br/>READ ONLY"]
Agent2["Claude agent<br/>(MCP tools)<br/><br/>task_list / task_get<br/>task_add / task_update<br/>task_remove<br/><br/>READ + WRITE"]
Dash["Dashboard<br/>(REST API)<br/><br/>GET /api/tasks<br/>SSE /api/events<br/><br/>READ ONLY"]
DB --> PF
DB --> Agent2
DB --> Dash
Complete Workflow Example¶
This traces a full lifecycle through multiple scheduler ticks, showing every task state transition. The example uses the Konflux PR consolidation workflow, but the pattern applies to any workflow.
Tick 1 — First Run, No Task Exists¶
graph TD
PF1["Preflight: 01-check-bot-prs.py<br/>get_tasks() → empty<br/>get_capacity() → 0/10<br/>gh pr list → 4 bot PRs"]
Start1["output_result('start', ...)"]
CS1["Claude session starts<br/>Groups PRs: Go=3, Python=1<br/>Skips Python (only 1)<br/>Processes Go: go get × 3<br/>Creates PR #55"]
Task1["task_add(<br/> key='konflux-pr-squash:org/repo',<br/> source_type='github',<br/> status='pr_open'<br/>)"]
State1(("pr_open"))
PF1 --> Start1 --> CS1 --> Task1 --> State1
Tick 2 — CI Still Running¶
graph TD
PF2a["01-check-bot-prs.py<br/>Found active task with prefix<br/>→ skip"]
PF2b["gh_pr_status.py<br/>PR #55 CI: PENDING<br/>→ skip (CLEAN)"]
NoSession["No Claude session. Zero tokens."]
State2(("pr_open<br/>unchanged"))
PF2a --> NoSession
PF2b --> NoSession
NoSession -.-> State2
Tick 3 — CI Passed, PR Merged¶
graph TD
PF3["gh_pr_status.py<br/>PR #55 state: MERGED<br/>→ start"]
CS3["Claude session<br/>Closes original bot PRs 101, 102, 103<br/>with comment linking to #55"]
Task3["task_update(status='done',<br/>summary='3 PRs consolidated, merged')"]
State3(("done"))
PF3 --> CS3 --> Task3 --> State3
Tick 4 — Loop Is Free Again¶
graph TD
PF4["01-check-bot-prs.py<br/>Tasks: [{status: 'done'}] — not active<br/>gh pr list → 0 bot PRs<br/>→ skip"]
NoSession4["No Claude session. Waiting for new bot PRs."]
PF4 --> NoSession4
Alternative: CI Fails¶
graph TD
PF5["gh_pr_status.py<br/>PR #55 CI: FAILURE<br/>→ start"]
CS5{"Can agent fix?"}
Fix["Push fix, stay pr_open<br/>Next tick re-checks CI"]
Fail["Delete branch, close PR<br/>Do NOT close originals<br/>task_update(status='done')"]
StateOpen(("pr_open"))
StateDone(("done<br/>originals still open"))
PF5 --> CS5
CS5 -->|"Yes"| Fix --> StateOpen
CS5 -->|"No"| Fail --> StateDone
Alternative: Review Feedback¶
graph TD
PF6["gh_pr_status.py<br/>reviewDecision: CHANGES_REQUESTED<br/>→ start"]
CS6["Claude session<br/>Reads review comments<br/>Addresses feedback, pushes fix"]
Task6["task_update(<br/> status='pr_changes',<br/> last_addressed=now<br/>)"]
State6(("pr_changes<br/>still blocks new runs"))
Next["Next tick: gh_pr_status.py checks again<br/>→ approved: MERGED path<br/>→ more feedback: repeats"]
PF6 --> CS6 --> Task6 --> State6 -.-> Next
Built-In Preflight Scripts¶
The jira-sprint workflow includes three preflight scripts:
| Script | What it checks | Returns "start" when |
|---|---|---|
01-gh-pr-status.py |
GitHub PR states (CI, reviews, conflicts, merges) | Any PR is merged, has CI failure, has conflicts, or has new review feedback |
02-gl-mr-status.py |
GitLab MR states (pipelines, threads) | Same as above but for GitLab |
03-jira-sprint.py |
Jira sprint for comments and new work candidates | Active task has new Jira comments, or new unassigned ticket found |
GitHub PR Classification Buckets¶
gh_pr_status.py classifies each PR into one of these buckets:
| Bucket | Condition | Actionable? |
|---|---|---|
| MERGED | PR state is MERGED |
Yes — agent wraps up |
| CLOSED | PR state is CLOSED |
Yes — agent handles closure |
| CI FAILING | statusCheckRollup has FAILURE conclusions |
Yes — agent investigates |
| CONFLICTS | mergeable is CONFLICTING |
Yes — agent rebases |
| FEEDBACK | reviewDecision is CHANGES_REQUESTED, or new review comments from humans |
Yes — agent addresses |
| CLEAN | No issues found | No |
The last_addressed timestamp on each task is used to filter out old feedback. Reviews submitted before last_addressed are ignored — the bot already handled them in a prior cycle.
Related Docs¶
- Workflow Presets — Available workflows and their decision loops
- Writing Custom Preflight Scripts — How to write your own preflight scripts
- Creating Custom Workflows — Building complete custom workflows
- Scheduling — KEDA cron scaling configuration