---
description: Self-healing CI pipeline -- 3-attempt retry budget with strategy rotation, inbox-wait for results, autonomous fix loop with escalation
version: 2.0.0
level: intermediate
debug: false
category: workflow
tags:
  - ci
  - github-actions
  - autonomous
  - pipeline
  - slack
  - self-healing
author: OmniClaude Team
args:
  - name: --pr
    description: PR number to fix CI failures for
    required: false
  - name: --branch
    description: "Branch name to fix CI failures for (default: current branch)"
    required: false
  - name: --skip-patterns
    description: "Comma-separated job/step name patterns to skip (e.g., 'test_*,lint')"
    required: false
  - name: --max-fix-files
    description: "Max files in scope before creating a sub-ticket instead of fixing (default: 10)"
    required: false
  - name: --no-slack
    description: Disable Slack notifications for this run
    required: false
  - name: --ticket-id
    description: Ticket ID context (included in Slack messages and sub-ticket descriptions)
    required: false
  - name: --max-attempts
    description: "Maximum repair attempts with strategy rotation (default: 3)"
    required: false
  - name: --self-heal
    description: "Enable self-healing mode: retry loop with strategy rotation and inbox-wait (default: false)"
    required: false
  - name: --analyze-only
    description: "Fetch and display CI failure analysis without attempting fixes. Reproduces former ci-failures behavior."
    required: false
composable: true
mode: full
---

# CI Fix Pipeline

## Overview

Autonomous pipeline that fetches GitHub Actions CI failures and fixes them -- ALL failures by
default. No selective mode. Failures beyond `max_fix_files` trigger sub-ticket creation and
continue with the remaining fixable failures.

**v2.0 Self-Healing Mode (OMN-2829)**: When `--self-heal` is enabled, the pipeline enters a
multi-attempt repair loop with strategy rotation. Each attempt uses a different fix strategy
(targeted -> broad lint -> regenerate). Between attempts, the pipeline uses inbox-wait (not
polling) to detect CI re-run results. The `node_ci_repair_effect` ONEX node orchestrates this
loop.

**Workflow (standard)**: Fetch CI failures -> Slack start -> Classify + Sub-ticket large-scope -> Fix ALL fixable -> Commit -> Slack complete -> ModelSkillResult

**Workflow (self-healing)**: `_bin/ci-status.sh` -> detect failures -> attempt 1 (targeted fix) -> push -> inbox-wait -> if still failing -> attempt 2 (broad lint fix) -> push -> inbox-wait -> if still failing -> attempt 3 (regenerate) -> push -> inbox-wait -> inbox notification on success/exhaustion

**Announce at start:** "I'm using the ci-fix-pipeline skill to fix CI failures."

## Policy Defaults

```yaml
policy:
  fix_all: true                     # always fix all -- no selective mode
  max_fix_files: 10                 # files in scope trigger sub-ticket (not skip)
  fix_preexisting_in_touched: true  # fix pre-existing issues in touched files
  slack_on_start: true              # notify Slack before fixing
  slack_on_complete: true           # notify Slack with fix summary
  max_attempts: 3                   # self-heal: retry budget (1-3)
  self_heal: false                  # self-heal: enable multi-attempt loop
```

## Quick Start

```
/ci-fix-pipeline                              # Fix all CI failures on current branch
/ci-fix-pipeline --pr 42                      # Fix failures for PR #42
/ci-fix-pipeline --ticket-id OMN-1234         # Include ticket context in Slack messages
/ci-fix-pipeline --no-slack                   # Suppress Slack notifications
/ci-fix-pipeline --skip-patterns "test_*"     # Skip jobs/steps matching pattern
/ci-fix-pipeline --max-fix-files 20           # Raise the sub-ticket threshold
/ci-fix-pipeline --self-heal --pr 42          # Self-healing mode with retry loop
/ci-fix-pipeline --self-heal --max-attempts 2 # Limit to 2 attempts
/ci-fix-pipeline --analyze-only --pr 42       # Diagnose CI failures without fixing (formerly ci-failures)
```

## Arguments

| Argument | Default | Description |
|----------|---------|-------------|
| `--pr <number>` | none | PR number for CI failure fetch |
| `--branch <ref>` | current | Branch name for CI failure fetch |
| `--skip-patterns <patterns>` | none | Comma-separated job/step name patterns to skip |
| `--max-fix-files <n>` | 10 | Files-in-scope threshold; above this -> sub-ticket |
| `--no-slack` | false | Disable Slack notifications |
| `--ticket-id <id>` | none | Context ticket ID for Slack messages |
| `--self-heal` | false | Enable self-healing retry loop with strategy rotation |
| `--max-attempts <n>` | 3 | Maximum repair attempts (only with --self-heal) |
| `--analyze-only` | false | Fetch and display CI failure analysis without attempting fixes |

## Analyze-Only Mode (`--analyze-only`)

<!-- Absorbed from ci-failures -->

When `--analyze-only` is passed, the pipeline stops after the analysis phase without attempting fixes.
This reproduces the former `ci-failures` skill behavior.

**What happens:**
1. Fetch CI failure logs (same as normal mode)
2. Parse and categorize failures
3. Display analysis results with categorization
4. **Stop** -- do not enter the fix loop

**Use case:** Quick CI failure diagnosis without committing to the full fix pipeline.

### Tools available in analyze-only mode

The underlying CI scripts remain in `${CLAUDE_PLUGIN_ROOT}/skills/ci-fix-pipeline/`:

- `ci-quick-review <PR#>` -- Quick summary of all failures (Tier 1)
- `get-ci-job-details <job_id>` -- Deep dive into specific job (Tier 2)
- `fetch-ci-data <PR#>` -- Raw CI data in JSON format

**Two-Tier Investigation Workflow:**
1. **Tier 1**: Run `ci-quick-review` to see summary of all failures
2. **Tier 2**: For each critical/major failure, run `get-ci-job-details <job_id>` for deep investigation

## Execution Phases

### Phase 1: Fetch CI Failures

Dispatch to polymorphic agent:

```
Task(
  subagent_type="onex:polymorphic-agent",
  description="Fetch CI failures for ci-fix-pipeline",
  prompt="Fetch CI failures for analysis.

    Run: ${CLAUDE_PLUGIN_ROOT}/skills/ci-fix-pipeline/ci-quick-review {N | branch_name}

    Return the raw JSON from ci-quick-review (pass through unchanged).
    The response has structure:
    {\"repository\": str, \"pr_number\": int,
     \"summary\": {\"total\": N, \"critical\": N, \"major\": N, \"minor\": N},
     \"failures\": [{\"workflow\": str, \"job\": str, \"job_id\": str, \"step\": str,
                    \"severity\": str, \"workflow_id\": str, \"job_url\": str}],
     \"fetched_at\": str}"
)
```

**Branch resolution**: After Phase 1, the orchestrator must have a branch name for use in later
phases. Resolve as follows:
- If `--branch` was provided: use that value directly.
- If `--pr` was provided (no `--branch`): run `gh pr view {N} --json headRefName --jq '.headRefName'`
  to get the branch name.
- If neither was provided: use the current branch (already known from `ci-quick-review` invocation).

### Phase 2: Slack Start Notification

If `slack_on_start: true` and Slack is available, notify:

```
ci-fix-pipeline starting
  PR/Branch: {context}
  Failures found: {N} ({critical} critical, {major} major, {minor} minor)
  Ticket: {ticket_id if provided}
```

Skip silently if Slack unavailable (non-blocking).

### Phase 3: Classify and Route Failures

For each failure:

1. **Skip check**: Does the failure `job` or `step` name match any `--skip-patterns` pattern?
   - Yes → mark as `skipped`, record reason
   - No → continue

2. **Scope check**: Does the failure `job` touch more than `max_fix_files` files?
   Determine scope by inspecting the job logs (via `gh api repos/{repo}/actions/jobs/{job_id}/logs`)
   to count affected files. If log inspection is unavailable, treat scope as within threshold.
   - Scope > max_fix_files → mark as `capped`, create Linear sub-ticket inline (see Sub-Ticket Creation below), continue to next failure
   - Scope ≤ max_fix_files → add to fix queue

Result: failures split into `skipped`, `capped`, and `to_fix` buckets.

### Phase 4: Fix Failures

Dispatch one polymorphic agent per severity group (critical first, then major, then minor) for all `to_fix` failures:

```
Task(
  subagent_type="onex:polymorphic-agent",
  description="Fix {severity} CI failures",
  prompt="**AGENT REQUIREMENT**: You MUST be a polymorphic-agent.

    Fix the following {severity} CI failures:

    {failures_list}

    Instructions:
    1. Read each affected file
    2. Apply the fix
    3. If fix_preexisting_in_touched is true: also fix any pre-existing lint/mypy
       issues in those files (only files already in scope — not a full repo scan)
    4. Do NOT commit

    Return classification for each failure:
    {\"fixed\": [failure_ids], \"architectural\": [failure_ids], \"unfixable\": [failure_ids]}"
)
```

**Post-fix architectural check**: For each failure returned as `architectural` by the fix agent:
- Send a Slack message (via `HandlerSlackWebhook` in omnibase_infra) describing the architectural
  change and asking for human approval. Include the failure description, the proposed fix, and
  the files affected. Wait for a reply (poll or webhook callback).
- Approved (human replies "approve" or "yes") → apply fix; Declined (any other reply or timeout after 10 min) → mark `escalated`

### Phase 5: Commit Fixes

**Skip this phase if `fixed` count is 0** (no code was changed — all failures were skipped, capped, or unfixable). Proceed directly to Phase 6 with `commit: null` in ModelSkillResult.

Otherwise, orchestrator stages and commits inline (no dispatch needed):

```bash
git add <changed_files>
git commit -m "fix(ci): resolve {N} {severity} failures [{ticket_id}]"
```

Commit message format: `fix(ci): resolve {N} {severity} failures [{ticket_id}]`
where `{severity}` is the highest severity fixed (e.g., `critical`, `major`, `minor`) and
`{ticket_id}` is the value from `--ticket-id` (or omitted if not provided).

### Sub-Ticket Creation

For each `capped` failure (scope > max_fix_files), created inline during Phase 3:

```python
# current_team: resolved from --ticket-id parent team (via mcp__linear-server__get_issue),
# or from the first team returned by mcp__linear-server__list_teams if no ticket is provided.
mcp__linear-server__create_issue(
    title=f"CI: {failure.job} — {failure.step} (large scope)",
    team=current_team,
    description=f"""
## CI Failure Requiring Human Review

**Job**: {failure.job}
**Step**: {failure.step}
**Severity**: {failure.severity}
**Scope**: Exceeds max_fix_files={max_fix_files} threshold
**Triggered by**: ci-fix-pipeline run for {ticket_id or branch}
**Job URL**: {failure.job_url}

## Definition of Done

- [ ] All affected files reviewed and fixed
- [ ] CI passing on {branch}
    """,
    parentId=ticket_id if ticket_id else None,
    labels=["ci-failure", "needs-human"]
)
```

### Phase 6: Slack Complete Notification

If `slack_on_complete: true`, notify with diff summary:

```
ci-fix-pipeline complete
  Fixed: {N} failures
  Skipped: {M} failures (patterns: {patterns})
  Sub-tickets created: {K} (large-scope failures)
  Escalated: {L} (architectural — declined or timed out)
  Ticket: {ticket_id if provided}
  Branch: {branch}
```

## ModelSkillResult Output

Emits to `$ONEX_STATE_DIR/skill-results/{context_id}/ci-fix-pipeline.json`
where `{context_id}` is the Claude session ID (from `$CLAUDE_SESSION_ID` env var) or `default`
if the session ID is unavailable:

```json
{
  "status": "completed|capped|escalated|failed",
  "fixed_count": 5,
  "skipped_count": 1,
  "capped_count": 2,
  "escalated_count": 0,
  "unfixable_count": 0,
  "sub_tickets": ["OMN-XXXX", "OMN-XYYY"],
  "commit": "abc1234",
  "branch": "feature/my-branch",
  "ticket_id": "OMN-1234"
}
```

**Status values**:
- `completed` — All fixable failures resolved
- `capped` — Some failures deferred to sub-tickets; fixed what was in scope
- `escalated` — One or more architectural failures declined or timed out; human review required
- `failed` — CI fetch failed or commit failed; pipeline halted

## Failure Handling

| Error | Behavior |
|-------|----------|
| CI fetch fails | Hard exit with `status: failed`, reason in output |
| Fix agent fails | Log failure, mark as `unfixable`, continue with others |
| Sub-ticket creation fails | Log warning, continue (non-blocking) |
| Slack unavailable | Skip notification, continue (non-blocking) |
| Commit fails | Exit with `status: failed`, leave changes staged |

## Sub-Ticket Threshold Policy

The `max_fix_files` threshold is a **routing decision**, not a skip:

- Failures within threshold: fixed autonomously
- Failures above threshold: sub-ticket created, pipeline continues with remaining

This ensures large-scope failures are never silently dropped — they are tracked in Linear.

## Self-Healing Mode (OMN-2829)

When `--self-heal` is enabled, the pipeline wraps the standard fix flow in a multi-attempt
retry loop orchestrated by `node_ci_repair_effect`.

### Architecture

```
CI fails
  -> _bin/ci-status.sh --pr N --repo ORG/REPO
  -> parse failure JSON
  -> node_ci_repair_effect.execute_effect(event)
  -> for attempt in 1..max_attempts:
       strategy = RepairStrategy.for_attempt(attempt)
       -> dispatch fix agent with strategy-specific prompt
       -> git add + commit + push
       -> inbox-wait for CI re-run result (not polling)
       -> if CI passing: inbox notification "repaired" -> exit
       -> if CI failing: rotate strategy, continue loop
  -> if all attempts exhausted: inbox notification "exhausted" -> exit
```

### Strategy Rotation

Each attempt uses a progressively broader fix strategy:

| Attempt | Strategy | Description |
|---------|----------|-------------|
| 1 | `targeted_fix` | Parse error logs, fix only the specific failing lines/files |
| 2 | `broad_lint_fix` | Run ruff/mypy auto-fix on all files touched by the PR |
| 3 | `regenerate_and_fix` | Rewrite failing sections, apply all auto-fixers |

### CI Status Extraction

Self-healing mode uses `_bin/ci-status.sh` instead of the heavier `ci-quick-review`
for fast, structured CI status checks between attempts:

```bash
# Fetch CI status as structured JSON
${CLAUDE_PLUGIN_ROOT}/_bin/ci-status.sh --pr 42 --repo OmniNode-ai/omniclaude
```

Output:
```json
{
  "status": "failing",
  "pr_number": 42,
  "repo": "OmniNode-ai/omniclaude",
  "branch": "jonah/omn-2829-self-healing-ci",
  "run_id": "12345678",
  "failed_jobs": [
    {
      "job_id": "56174634733",
      "job_name": "lint / ruff",
      "step": "Run ruff check",
      "conclusion": "failure",
      "log_excerpt": "..."
    }
  ],
  "failure_summary": "1 job(s) failed: lint / ruff",
  "fetched_at": "2026-02-26T13:00:00Z"
}
```

### Inbox-Wait Pattern

After each fix attempt pushes code, the pipeline waits for CI results using inbox-wait
rather than fixed-interval polling:

1. Push fix commit to branch
2. Call `node_ci_repair_effect.wait_for_ci_rerun()` which:
   - Polls `_bin/ci-status.sh` at 30s intervals
   - Detects when a new `run_id` appears (different from pre-push run)
   - Waits for terminal state (`passing` or `failing`)
   - Times out after 5 minutes (configurable)
3. If `passing`: record success, send inbox notification, exit
4. If `failing`: rotate strategy, begin next attempt

### Self-Healing Fix Dispatch

```
Task(
  subagent_type="onex:polymorphic-agent",
  description="ci-fix-pipeline: self-heal attempt {N}/{max} for PR #{pr_number}",
  prompt="**SELF-HEALING CI REPAIR -- Attempt {N}/{max}**

    Strategy: {strategy_name}
    {strategy_prompt}

    CI Failure Details:
    {failure_json}

    Branch: {branch}
    Repo: {repo}

    Instructions:
    1. Read the failure logs carefully
    2. Apply the fix strategy described above
    3. Stage and commit with message:
       fix(ci): self-heal attempt {N} -- {strategy_name} [{ticket_id}]
    4. Push to the branch

    Report: files changed, fix description, confidence level."
)
```

### ONEX Node: node_ci_repair_effect

**Tier**: EVENT_BUS+
**Type**: Effect node (external I/O)
**Location**: `plugins/onex/hooks/lib/node_ci_repair_effect.py`

Provides:
- `execute_effect(event)` -- Initialize repair run state
- `record_attempt_result(run_state, attempt, ...)` -- Record attempt outcome
- `finalize_with_error(run_state, error)` -- Record error and notify
- `fetch_ci_status(pr, repo, branch)` -- Wrapper around `_bin/ci-status.sh`
- `wait_for_ci_rerun(pr, repo, branch, prev_run_id)` -- Inbox-wait for new CI run
- `send_inbox_notification(run_state, message)` -- Write to `$ONEX_STATE_DIR/inbox/`
- `save_repair_state(run_state)` / `load_repair_state(run_id)` -- State persistence

### Self-Healing ModelSkillResult

When `--self-heal` is active, the ModelSkillResult includes additional fields:

```json
{
  "status": "repaired|exhausted|failed",
  "repair_run_id": "ci-repair-42-1740000000",
  "attempts_used": 2,
  "max_attempts": 3,
  "strategy_used": "broad_lint_fix",
  "fixed_count": 5,
  "skipped_count": 0,
  "commit": "abc1234",
  "branch": "jonah/omn-2829-self-healing-ci",
  "ticket_id": "OMN-2829",
  "inbox_notification_sent": true
}
```

**Status values (self-healing)**:
- `repaired` -- CI fixed within the retry budget
- `exhausted` -- All attempts used, CI still failing
- `failed` -- Error during repair (fetch failed, commit failed, etc.)

### Verification

To verify self-healing works end-to-end:

1. Push a deliberately failing commit (e.g., syntax error, failing test)
2. Run: `/ci-fix-pipeline --self-heal --pr <N> --ticket-id OMN-2829`
3. Confirm:
   - Attempt 1 uses `targeted_fix` strategy
   - If still failing, attempt 2 uses `broad_lint_fix`
   - If still failing, attempt 3 uses `regenerate_and_fix`
   - Inbox notification sent on success or exhaustion
   - State persisted to `$ONEX_STATE_DIR/state/ci-repair/`

## See Also

- `ci-watch` skill -- poll CI status and auto-fix (OMN-2523)
- `local-review` skill -- review and fix local code changes
- `ticket-pipeline` skill -- end-to-end ticket pipeline (Phase 4 invokes ci-watch)
- `_bin/ci-status.sh` -- lightweight CI status extraction script
- `node_ci_repair_effect` -- ONEX effect node for self-healing orchestration
- `HandlerSlackWebhook` in omnibase_infra -- Slack delivery infrastructure
