Epic Team Orchestration
Backing Status () — NON-RUNNABLE via canonical event-bus dispatch
This skill has no deterministic node backing and is de-listed from event-bus / node-routed dispatch — deliberately, by design. Its omniclaude "node" (node_skill_epic_team_orchestrator) is a generated Polly-passthrough shell (maturity: stub in its contract.yaml). Its real execution mechanism, plugins/onex/skills/epic_team/run.sh, itself shells out to claude -p (headless Claude Code) — still an LLM session, not a canonical COMPUTE/EFFECT/ORCHESTRATOR/REDUCER node. Spawning live Task()/TeamCreate() subagents is inherently a Claude-session capability; there is no I/O boundary a backend node can call to create a new agent session.
This is intentional, not a gap to silently paper over: epic_team is, and should remain, session-native (Skill-tool / headless-launcher driven only). Future evaluation of a gated-EFFECT wrapper around the run.sh launch step is tracked in .
Tools Required
This skill spawns workers via Agent(team_name=...). Workers run in fresh sessions where
the Agent tool is deferred (schema not pre-loaded). Any worker that needs to dispatch
further sub-agents (e.g. a ticket-pipeline worker spawning a verifier) must call:
ToolSearch(query="select:Agent,SendMessage,TaskCreate,TaskUpdate,TaskGet", max_results=5)
Inject this as the first line of every Agent() dispatch prompt in the wave loop. The
worker must call it before any other tool use. Without it, recursive dispatch fails silently
with "Agent tool unavailable".
Mode Declaration
This skill operates in BUILD mode only.
Valid --mode values: build
If --mode is omitted or set to any value other than build, emit the following usage
error and stop — do NOT proceed to dispatch:
ERROR: /epic-team requires --mode build
Usage: /epic-team <epic_id> --mode build [options]
Valid modes:
build Orchestrate autonomous implementation of a Linear epic across repos
This skill is BUILD mode only. For close-out or reporting tasks:
Close-out: /merge-sweep --mode close-out
Reporting: /linear-insights --mode deep-dive
First output line when mode is valid must be:
[epic-team] MODE: build | epic: <epic_id>
No tool calls, file reads, or bash commands may precede this output.
Headless Mode (Overnight Pipelines)
Use plugins/onex/skills/epic_team/run.sh for overnight/unattended runs:
# Full headless run
plugins/onex/skills/epic_team/run.sh PROJ-1000 --mode build
# Dry run (no spawning)
plugins/onex/skills/epic_team/run.sh PROJ-1000 --mode build --dry-run
# Resume after context limit or crash
plugins/onex/skills/epic_team/run.sh PROJ-1000 --mode build --resume
Minimum tool allowlist for headless epic-team:
tracker.get_issue, tracker.list_issues,
tracker.save_issue, tracker.save_comment,
tracker.list_projects, tracker.get_project,
tracker.list_teams, Bash, Read, Write, Edit, Glob, Grep,
TeamCreate, TeamDelete, Agent, TaskCreate, TaskUpdate, TaskGet, TaskList, SendMessage
Failure doctrine in headless mode:
- Missing epic_id: exit 1, structured JSON error — no partial run ever started
- Missing credentials (
LINEAR_API_KEY): exit 2 immediately, no retries - Ambiguity (cannot determine epic state, conflicting lock files): write
$ONEX_STATE_DIR/epics/<id>/ambiguity_<ts>.json, exit 3 — never guess - Blocked tool (not in allowlist): log denial, exit 4 — never silently substitute
- Context limit: checkpoint to
$ONEX_STATE_DIR/epics/<id>/state.yamlbefore stopping; re-run with--resumeto continue from last completed wave
Checkpointable state:
All in-progress state is written to $ONEX_STATE_DIR/epics/<epic_id>/state.yaml before
each wave and updated after each ticket completion. A coordinator killed at any point can be
resumed by re-running with --resume against the same state.yaml.
Dispatch Surface: Agent Teams
epic-team uses Claude Code Agent Teams for all worker dispatch. The team lead (this session) creates a named team, generates task contracts, dispatches workers, monitors for stalls, and shuts down the team on completion.
Non-Blocking Foreground Contract
Agent(...) dispatches return immediately — workers run as independent background sessions.
The team lead must never await or block the foreground on any single worker. The wave
monitoring loop (see "Stall Detection in Wave Dispatch" below) polls TaskGet status and
only advances to the next wave when the current wave has reported terminal status or a stall
has been handled. This is the canonical dispatch pattern referenced by Workstream D of the
2026-04-02 Insights Plan: read epic tickets from Linear → group by repo → TeamCreate →
one Agent(team_name=...) per ticket → poll status non-blocking. No separate
/dispatch-epic skill is needed; that workflow is exactly this skill's dispatch phase.
Stall detection integrates agent_healthcheck and dispatch_watchdog: two minutes of zero
tool calls from a worker is a stall signal, and stalled workers are killed and redispatched
with narrower scope per the watchdog's escalation policy.
Lifecycle
1. TeamCreate(team_name="epic-{epic_id}")
2. For each ticket in wave:
a. Generate a ModelTaskContract from the ticket's Linear DoD and inject into the worker prompt
b. TaskCreate(subject="{ticket_id}: {title}", description=ticket_requirements)
c. Agent(name="worker-{ticket_id}", team_name="epic-{epic_id}",
prompt="Execute ticket-pipeline for {ticket_id}. Task contract: {contract_json}")
3. Workers execute in background, report progress via SendMessage(to="team-lead")
4. Team lead monitors progress, handles stalls (5-min inactivity timeout)
5. On each worker completion: verification chain runs (see below)
6. TeamDelete(team_name="epic-{epic_id}") after all tasks complete or are terminal
Task Contract Generation
Before dispatching each worker, the team lead generates a task contract:
- Fetch ticket DoD from Linear (description, acceptance criteria)
- Build
ModelTaskContractwith mechanical checks (test pass, lint clean, type-check clean) - Inject the serialized contract into the worker's dispatch prompt
- The contract travels with the worker — it is the single source of truth for what "done" means
Verification Chain (Part 2 Reference)
Each worker task must produce evidence through the full Part 2 verification chain:
- Task contract —
ModelTaskContractpersisted before dispatch - Self-check — worker runs
SelfCheckResultagainst its own contract checks - Verifier — independent verifier agent validates self-check evidence
- Quorum gate —
ModelQuorumResultevaluates self-check + verifier verdicts - Completion event —
ModelTaskCompletedEventemitted with evidence to unified event stream
A worker is not considered complete until all five artifacts are present. Missing evidence triggers a re-verification pass before the task is marked done.
Stall Detection
After dispatching workers in a wave, the team lead monitors for stalls:
- Inactivity timeout: 5 minutes with no tool calls from a worker → stall detected
- On stall: Log friction event to
.onex_state/friction/, cancel stalled worker, mark task for retry in next wave - Retry policy: Each ticket gets 1 retry. If stalled twice → mark
blocked, add Linear comment, skip - Wave timeout: 30 minutes total per wave. Remaining in-progress tasks are timed out and retried once
Failure on Dispatch
If Agent Teams dispatch fails (TeamCreate error, Agent tool unavailable, auth error): STOP immediately. Report the exact error to the user and wait for direction. Do NOT fall back to direct Bash, Read, Edit, Write, or Glob calls — falling back bypasses observability, context management, and the orchestration layer.
Session lifetime: The monitoring phase is alive only while this session runs. Use
/epic-team {epic_id} --resumeto re-enter after a disconnection.
Architecture note (v3.0.0): epic-team is a thin orchestrator. All business logic lives in independently-invocable composable sub-skills. epic-team's job is coordination, state, and routing — not implementation. Workers are Agent Teams members, not poly-agent subagents.
Overview
Decompose a Linear epic into per-repo workstreams and autonomously drive them to completion.
The team lead (this session) owns planning, dispatch, state persistence, and lifecycle
notifications. Tickets are executed by dispatching workers via Agent Teams (TeamCreate +
Agent(team_name=...)) in dependency-respecting waves.
If the epic has zero child tickets, epic-team invokes decompose-epic to create sub-tickets,
then posts a Slack LOW_RISK gate. Silence for 30 minutes = proceed.
Execution Rules
Execute end-to-end without stopping between tasks. If blocked on one task, record a skip note and continue to the next. Only pause for: (a) credentials not available in the session, (b) a destructive action not explicitly covered by the plan, or (c) an explicit user gate in the plan. Do not exit plan mode or stop to "await direction" in any other circumstance.
Wave Cap
Dispatch tickets in waves of 5 tickets maximum to prevent usage-limit interruptions. Start the next wave only after all Agent() workers in the current wave have reported back. Never dispatch more than 5 tickets in a single wave, even if more are independent.
Sequential PR Chaining
When creating PRs for sequential tasks that modify the same files:
- PR N+1 should target PR N's branch (not main) if they share modified files
- Maximum chain depth: 3 PRs
- If chain would exceed 3, wait for earlier PRs to merge before creating more
Detection: Before creating a PR, check if another open PR from the same epic modifies any of the same files:
# Get files in current change
CHANGED_FILES=$(git diff --name-only main...)
# Check open PRs from same epic for overlapping files
for PR_NUM in $(gh pr list --label "epic:<EPIC-ID>" --json number -q '.[].number'); do
PR_FILES=$(gh pr view $PR_NUM --json files -q '.files[].path')
OVERLAP=$(comm -12 <(echo "$CHANGED_FILES" | sort) <(echo "$PR_FILES" | sort))
if [[ -n "$OVERLAP" ]]; then
echo "Overlapping files with PR #$PR_NUM: $OVERLAP"
echo "Target PR #$PR_NUM's branch instead of main"
fi
done
Composable Sub-Skills
epic-team orchestrates these independently-invocable primitives:
| Sub-Skill | Purpose |
|---|---|
decompose-epic |
Analyze epic → create Linear child tickets |
slack-gate |
LOW_RISK notification gate (one-way, silence=proceed) |
ticket-pipeline |
Per-ticket pipeline (implement → review → PR → CI → merge) |
verification-sweep |
Post-orchestration endpoint/DB/DoD verification |
ticket-pipeline in turn composes:
| Sub-Skill | Purpose |
|---|---|
ticket-work |
Implement ticket (autonomous mode) |
local-review |
Review + fix loop |
ci-watch |
Poll CI, auto-fix failures |
pr-watch |
Poll PR reviews, auto-fix comments |
auto-merge |
Merge PR automatically after CI is clean |
Each layer is independently invocable:
ticket-pipelineruns standalone withoutepic-teamticket-workruns standalone withoutticket-pipelineci-watchruns standalone on any PR
If the epic has zero child tickets, epic-team automatically invokes the decompose-epic sub-skill to analyze the epic description and create sub-tickets, then posts a Slack LOW_RISK gate. Silence for 30 minutes means proceed with newly created tickets.
Usage Examples
# Dry run — see decomposition without spawning agents
/epic-team PROJ-1000 --dry-run
# Full run
/epic-team PROJ-1000
# Resume after session disconnect
/epic-team PROJ-1000 --resume
# Force restart (archive existing state; pauses if workers active)
/epic-team PROJ-1000 --force
# Force restart even with active workers (dangerous)
/epic-team PROJ-1000 --force --force-kill
# Route unmatched tickets to omniplan triage
/epic-team PROJ-1000 --force-unmatched
Orchestration Flow
epic-team <EPIC-ID>
→ Fetch child tickets from Linear
→ If 0 child tickets:
→ Dispatch decompose-epic (composable, returns skill_result)
→ Read $ONEX_STATE_DIR/skill-results/{context_id}/decompose-epic.json
→ Dispatch slack-gate (LOW_RISK, 30 min, silence=proceed)
→ Read $ONEX_STATE_DIR/skill-results/{context_id}/slack-gate.json
→ If rejected: stop
→ Re-fetch newly created tickets
→ Assign tickets to repos via repo_manifest
→ Build dependency waves:
Wave 0: independent tickets + cross-repo Part 1 splits (run in parallel)
Wave 1: cross-repo Part 2 splits (run after Wave 0 completes)
→ For each wave: dispatch ticket-pipeline per ticket as Agent() workers in parallel
→ Start stall-detection watchdog for the wave
→ Await all Agent() workers in wave to report via SendMessage before starting next wave
→ If watchdog detects stall: cancel stalled worker, log to state, retry in next wave
→ Collect results (status, pr_url, branch) from SendMessage reports
→ Post-wave integration check: run gap cycle --no-fix per repo touched
→ GREEN/YELLOW/RED per repo → post to Slack epic thread
→ Write integration_check section to state.yaml (non-blocking — always advances)
→ Post-orchestration verification sweep: invoke /verification-sweep --epic {epic_id}
→ Verify dashboard endpoints return HTTP 200 with real data
→ Verify database tables contain expected rows
→ Verify dod_evidence rendered_output items have passing receipts
→ Write receipts to .onex_state/verification-receipts/ (non-blocking — always advances)
→ DoD compliance gate: invoke /dod-sweep {epic_id} (targeted mode)
→ If FAIL: do NOT mark epic Done; follow-ups auto-created; post Slack block
→ If PASS: proceed to mark epic Done; post Slack clean notification
→ If UNKNOWN (all exempt): proceed (rollout accommodation); post review warning
→ Send Slack lifecycle notifications (started, ticket done, epic done)
→ Persist state to $ONEX_STATE_DIR/epics/{epic_id}/state.yaml
Dispatch: decompose-epic
When epic has 0 child tickets:
Agent(
name="decompose-{epic_id}",
team_name="epic-{epic_id}",
prompt="The epic {epic_id} has no child tickets.
Invoke: Skill(skill=\"onex:decompose_epic\", args=\"{epic_id}\")
Read result from $ONEX_STATE_DIR/skill-results/{context_id}/decompose-epic.json
Report back via SendMessage: created_tickets (list of IDs and titles), count."
)
Pre-Dispatch Verification Preamble
Before dispatching any ticket in a wave, the team lead performs a lightweight verification pass to catch misaligned tickets early -- before they consume a full pipeline run.
Worker Model Enforcement
All workers dispatched by epic-team MUST use Sonnet (not Opus). Do NOT dispatch workers with
model: opus or leave the model unspecified in environments where Opus is the default.
Add to every Agent() dispatch prompt:
MODEL: claude-sonnet-4-6
Rationale: Opus is reserved for orchestrator-level reasoning. Worker cost scales with the number of tickets; Sonnet is sufficient for implementation tasks.
ONEX Pattern Gate (pre-dispatch static review)
DISABLED: hostile_reviewer is currently disabled pending eval framework validation. Skip this gate entirely. Do NOT invoke. Proceed to Verification Steps directly.
Before dispatching any ticket in a wave, run hostile_reviewer --static on each task
description to catch ONEX anti-patterns before they consume a full pipeline run.
Invoke:
Skill(skill="onex:hostile_reviewer", args="--static --text \"<task_description>\"")
Block dispatch if any of the following patterns are detected:
| Anti-pattern | Example | Action |
|---|---|---|
| Docker service design in a ticket | "Create a new Docker service for..." | BLOCK |
| String field types (untyped primitives) | name: str, id: str instead of typed models |
BLOCK |
| Hardcoded topic strings | "onex.evt.foo.v1" literal in task description |
BLOCK |
On block: Mark the ticket as pattern_gate_failed in state.yaml with the violation detail.
Add a Linear comment: "[epic-team] Pattern gate blocked dispatch: {violation}". Do NOT dispatch.
Escalate to user if more than 2 tickets in a wave are blocked by the pattern gate.
On pass: Record pattern_gate: passed in the ticket's contract before Agent() dispatch.
Verification Steps (per ticket, before Agent() dispatch)
ONEX pattern gate:
RunDISABLED — skip this step, proceed to step 2.hostile_reviewer --staticon task descriptionTicket readiness check: Fetch ticket via
tracker.get_issueand verify:- Description is non-empty and contains actionable content
- Repo target is identifiable (from title, description, or labels)
- No blocking dependencies are in non-Done state
Approach sanity check: For each ticket, state in the dispatch prompt:
- What the ticket requires (1-sentence summary)
- Which repo and approximate files will be modified
- Any known constraints or patterns to follow
Skip unready tickets: If a ticket fails verification:
- Mark as
skippedinstate.yamlwith reason:"verification_failed: {detail}" - Add a Linear comment: "Skipped by epic-team: {reason}"
- Do NOT dispatch -- move to next ticket in wave
- Unready tickets are re-evaluated in the next wave (dependency may have resolved)
- Mark as
Verification in the Dispatch Prompt
The verification context is injected directly into each Agent() dispatch prompt so the ticket-pipeline agent starts with pre-validated understanding rather than re-deriving it.
Dispatch: Ticket-Pipeline per Ticket (Agent Teams Pattern)
For each ticket in a wave, generate a task contract and dispatch a worker via Agent Teams:
# 1. Generate task contract from Linear DoD
contract = generate_task_contract(ticket_id, ticket_description, ticket_acceptance_criteria)
# Persist contract to $ONEX_STATE_DIR/epics/{epic_id}/contracts/{ticket_id}.json
# 2. Create a task for tracking
TaskCreate(subject="{ticket_id}: {title}", description=ticket_requirements)
# 3. Dispatch worker as Agent Teams member
Agent(
name="worker-{ticket_id}",
team_name="epic-{epic_id}",
prompt="FIRST ACTION — fetch deferred dispatch tool schemas:
ToolSearch(query=\"select:Agent,SendMessage,TaskCreate,TaskUpdate,TaskGet\", max_results=5)
You are executing ticket {ticket_id} for epic {epic_id}.
Ticket: {ticket_id} - {title}
URL: {url}
Repo: {repo} at {repo_path}
Epic: {epic_id} Run: {run_id}
TASK CONTRACT (source of truth for done):
{contract_json}
VERIFIED CONTEXT (from epic-team pre-dispatch check):
- Summary: {verified_summary}
- Target files: {verified_file_targets}
- Pattern to follow: {verified_pattern_reference}
- Dependencies met: {dependency_status}
VERIFICATION CHAIN — you must produce ALL of these before reporting done:
1. Run self-check against the task contract (SelfCheckResult)
2. Evidence is verified by an independent verifier agent
3. Quorum gate evaluates self-check + verifier verdicts (ModelQuorumResult)
4. Emit ModelTaskCompletedEvent with all evidence
Invoke: Skill(skill=\"onex:ticket_pipeline\", args=\"{ticket_id}\")
After ticket-pipeline completes, report back via SendMessage(to=\"team-lead\"):
- ticket_id: {ticket_id}
- status: (merged/failed/blocked)
- pr_url: (if available)
- branch: (branch name used)
- verification_verdict: (PASS/FAIL/INCOMPLETE)"
)
Wave parallelism: Agent Teams workers run as independent concurrent Claude sessions. Dispatch all Agent() calls for a wave before monitoring. Do NOT dispatch tickets sequentially within a wave.
Wave serialization: Wave N+1 starts only after all workers from Wave N have reported back via SendMessage or been timed out by stall detection.
Stall Detection in Wave Dispatch
After dispatching all Agent() calls in a wave, the team lead monitors for stalled agents
using the dispatch_watchdog skill. This prevents a single stalled agent from blocking
the entire wave indefinitely.
Monitoring Loop
# After dispatching wave N tasks:
wave_start_time = now()
stall_timeout = 300 # 5 minutes with no tool calls
while any_task_still_running(wave_tasks):
# Check each running task
for task in wave_tasks:
if task.status == "in_progress":
elapsed_since_last_activity = now() - task.last_tool_call_time
if elapsed_since_last_activity > stall_timeout:
# Stall detected
log("[epic-team] STALL: {task.ticket_id} idle for {elapsed}s")
write_stall_event(epic_id, task.ticket_id, elapsed)
# Recovery: mark as failed, retry in next wave
task.status = "stalled"
task.retry_in_next_wave = true
# Check wave-level timeout (30 minutes total)
if now() - wave_start_time > 1800:
log("[epic-team] WAVE TIMEOUT: cancelling remaining tasks")
for task in wave_tasks:
if task.status == "in_progress":
task.status = "timeout"
task.retry_in_next_wave = true
break
sleep(60) # Check every minute
Stall State in state.yaml
waves:
- wave_id: 0
dispatched_at: "2026-03-29T10:00:00Z"
completed_at: "2026-03-29T10:15:00Z"
tasks:
- ticket_id: PROJ-1001
status: merged
pr_url: "https://github.com/..."
- ticket_id: PROJ-1002
status: stalled
stall_detected_at: "2026-03-29T10:08:00Z"
idle_seconds: 480
retry_wave: 1
Retry Policy
- Stalled tasks are retried once in the next wave
- If a task stalls twice: mark as
blocked, add Linear comment, skip - Timeout tasks (wave-level) are always retried once
- Maximum retry count per ticket: 1 (prevents infinite retry loops)
Integration with dispatch_watchdog Skill
The monitoring logic above is the inline version for the team-lead session.
For more sophisticated monitoring (e.g., headless overnight runs), invoke
the dispatch_watchdog skill as a composable sub-skill:
Skill(skill="onex:dispatch_watchdog", args="--epic-id {epic_id} --timeout 300 --action report")
Skill Result Communication
All sub-skills write their output to $ONEX_STATE_DIR/skill-results/{context_id}/:
| Sub-Skill | Output File | Key Fields |
|---|---|---|
decompose-epic |
decompose-epic.json |
status, created_tickets, count |
slack-gate |
slack-gate.json |
status (accepted/rejected/timeout) |
ticket-pipeline |
ticket-pipeline.json |
status, ticket_id, pr_url |
ticket-work |
ticket-work.json |
status, pr_url, phase_reached |
local-review |
local-review.json |
status, iterations_run |
ci-watch |
ci-watch.json |
status, fix_cycles_used |
pr-watch |
pr-watch.json |
status, fix_cycles_used |
auto-merge |
auto-merge.json |
status, merge_commit |
Skill Result Input Contract
Input contract: All sub-skill result files conform to ModelSkillResult from omnibase_core.models.skill.
Note: This contract reference is behavioral guidance for the LLM executing this skill. Runtime validation not yet implemented.
Load result files and check outcomes as follows:
result = ModelSkillResult.from_json(path.read_text())
# Check if the sub-skill completed successfully (success, partial, or dry_run)
if result.is_success_like:
# Proceed with next wave or action
pass
# Check for hard failure
elif result.status == EnumSkillResultStatus.FAILED:
# Record failure, apply circuit breaker (max 2 retries per ticket)
pass
# Check for blocking states
elif result.status == EnumSkillResultStatus.GATED:
# Human approval is pending — do not advance wave
pass
# Access skill-specific fields via extra dict (not direct attribute access).
# Use .get() with a default — extra may be empty on non-success paths.
created_tickets = result.extra.get("created_tickets", []) # decompose-epic result
iterations_run = result.extra.get("iterations_run", 0) # local-review result
Behaviorally significant extra_status values by sub-skill:
| Sub-Skill | extra_status |
Orchestrator action |
|---|---|---|
decompose-epic |
null |
Normal — read extra.get("created_tickets", []) |
slack-gate |
"accepted" |
Silence-proceed gate passed — continue |
slack-gate |
"rejected" |
Gate rejected — stop (cancel orchestration) |
slack-gate |
"timeout" |
Gate timed out — apply configured timeout policy |
ticket-pipeline |
"merged" |
Ticket fully merged — record as done |
ticket-pipeline |
"held" |
Merge gate open — non-terminal, do not retry yet |
auto-merge |
"merged" |
PR merged — record wave ticket as complete |
auto-merge |
"timeout" |
Merge gate expired — retryable with new pipeline run |
Promotion rule for extra fields: If any orchestrator consumer (epic-team, ticket-pipeline) branches on extra["x"], that field MUST be promoted to a first-class field in ModelSkillResult. extra is a migration bridge, not a permanent schema extension mechanism.
State Persistence
Runtime state is persisted to $ONEX_STATE_DIR/epics/{epic_id}/state.yaml:
epic_id: PROJ-XXXX
run_id: f084b6c3
status: monitoring # queued | monitoring | done | failed
checkpoint:
schema_version: 1
last_completed_wave: 0
waves:
- wave_id: 0
tickets: [PROJ-1001, PROJ-1002]
status: completed # pending | in_progress | completed | failed
completed_at: "2026-03-06T..."
- wave_id: 1
tickets: [PROJ-1003]
status: pending
open_prs:
PROJ-1001: {pr_number: 45, repo: "omniclaude", branch: "<user>/omn-2001-..."}
failures:
PROJ-1003: {class: "ci_failure_ruff", attempts: 1, last_error: "..."}
last_update_utc: "2026-03-06T..."
workers:
- repo: omniclaude
tickets: [PROJ-1001, PROJ-1002]
status: running # running | done | failed
ticket_status:
PROJ-1001: merged
PROJ-1002: running
Use --resume to re-enter monitoring from persisted state after session disconnect.
Empty Epic Auto-Decompose
When epic has 0 child tickets:
[LOW_RISK] epic-team: Auto-decomposed PROJ-XXXX
Epic had no child tickets. Created N sub-tickets:
- PROJ-YYYY: [title]
- PROJ-ZZZZ: [title]
...
Reply "reject" within 30 minutes to cancel. Silence = proceed with orchestration.
--dry-run behavior for empty epic
Invoke decompose-epic --dry-run (returns plan, no tickets created). Print plan. Do not post
Slack gate.
Repo Manifest
Ticket-to-repo assignment uses plugins/onex/skills/epic-team/repo_manifest.yaml:
MIN_TOP_SCORE: 4
repos:
- name: omniclaude
path: ~/Code/omniclaude
keywords: [hooks, skills, agents, claude, plugin, ticket-pipeline]
- name: omnibase_core
path: ~/Code/omnibase_core
keywords: [nodes, contracts, runtime, onex]
- name: omnibase_infra
path: ~/Code/omnibase_infra
keywords: [kubernetes, deploy, infra, helm]
Keyword matching is case-insensitive. Tickets with no keyword match are UNMATCHED.
Use --force-unmatched to route them to omniplan as TRIAGE tasks.
Stacked Branch Execution
When Wave N+1 tickets depend on Wave N (via cross-repo Part 2 splits or file-overlap
chains), the downstream ticket branches from the Wave N branch tip instead of main.
This avoids merge conflicts and ensures later tickets see earlier changes without
waiting for merge.
How it works:
- After Wave N completes, collect
{ticket_id: branch_name}from worker results - When dispatching Wave N+1, check if any ticket has a dependency on a Wave N ticket
(via
chain_targetsfromdetect_file_overlap_chainsor cross-repo Part 2 mapping) - If a dependency exists and the upstream ticket succeeded, pass
--base-branch <branch>to the downstream ticket'sticket-pipelineinvocation ticket-pipelinecreates its worktree from the specified base branch and opens its PR targeting that base branch
Fallback: If the upstream ticket failed or returned no branch, fall back to main.
Chain depth limit: Maximum 3 levels of stacking. Beyond that, tickets target main
independently to avoid deep rebase chains.
State tracking: The ticket_results dict in state.yaml records branch per ticket,
making stacked branch resolution available across waves and on --resume.
Worktree Policy
Workers create isolated git worktrees at:
$ONEX_STATE_DIR/worktrees/{epic_id}/{run_id}/{ticket_id}/
Stale worktrees are cleaned up automatically after merge when auto_cleanup_merged_worktrees: true
(default).
Architecture
epic-team is a thin composition layer. It owns:
- Epic decomposition (via
decompose-epic) - Ticket-to-repo assignment (via repo_manifest)
- Wave construction (group tickets by dependency into parallel waves)
- Agent Teams dispatch of ticket-pipeline per ticket (TeamCreate + Agent with team_name)
- State persistence (
$ONEX_STATE_DIR/epics/{epic_id}/state.yaml) - Slack lifecycle notifications (started, ticket done, epic done)
It does NOT own:
- Ticket implementation (delegated to
ticket-pipeline→ticket-work) - Code review (delegated to
local-review) - CI polling (delegated to
ci-watch) - PR review polling (delegated to
pr-watch) - Merge execution (delegated to
auto-merge)
Execution Model
Agent Teams dispatch from team-lead session is the authoritative execution pattern:
- Team-lead creates the team via
TeamCreate(team_name="epic-{epic_id}") - Team-lead constructs waves of tickets grouped by dependency
- For each wave, team-lead dispatches
Agent()workers in parallel withteam_nameparameter - Workers execute in background and report completion via
SendMessage(to="team-lead") - Team-lead monitors SendMessage reports and stall detection before starting the next wave
- After all waves complete, team-lead cleans up via
TeamDelete()
Circuit Breaker / Heartbeat Timeout
Every Agent() dispatch has a configurable timeout to prevent stalled agents from blocking the
entire wave indefinitely. If an agent produces no output for DISPATCH_TIMEOUT_MINUTES
(default: 30, configurable via EPIC_TEAM_DISPATCH_TIMEOUT_MINUTES env var), the circuit
breaker trips:
- The Agent() dispatch returns a timeout error
- The ticket is marked
AGENT_TIMEOUTin state.yaml - A Kafka event
onex.evt.omniclaude.agent-circuit-breaker.v1is emitted for observability - The wave continues with remaining tickets (timeout is non-blocking)
This prevents the first autopilot close-out failure mode where a release dispatch stalled for 1+ hour with zero output.
Agent Health-Check Integration
The agent_healthcheck skill provides more sophisticated stall detection beyond the simple
timeout circuit breaker. During wave monitoring, epic-team checks agent health using three
heuristics:
- Inactivity: No tool calls for 10 minutes (configurable)
- Context overflow: Context window usage > 80% (preemptive recovery before hard limit)
- Rate limits: Agent encounters rate-limit errors
On stall detection, the health-check module:
- Snapshots progress to a checkpoint file (using the checkpoint protocol from an earlier pass)
- Summarizes completed vs remaining work
- Relaunches a fresh agent with the summary and remaining tasks only
See @skills/agent_healthcheck/SKILL.md for the full detection and recovery protocol.
Failure Taxonomy and Recovery Strategies
| Failure Class | Symptoms | Recovery Strategy |
|---|---|---|
rate_limit |
Sub-agent exits with rate limit error | Wait 60s, retry the ticket via ticket-pipeline sequentially |
context_limit |
Sub-agent hits max context length mid-ticket | Spawn fresh sub-agent; previous work is in the branch |
ci_failure_uv |
CI fails with lock file or uv version error | Verify CI uv version; regenerate lock with matching version |
ci_failure_ruff |
CI fails with lint/format error | Run uv run ruff check --fix + uv run ruff format, recommit |
stale_branch |
PR fails to merge — "main has moved" | git rebase origin/main, re-push, re-enable auto-merge |
wrong_repo |
Ticket worked in wrong repo | Look up target repo in Linear ticket repo field; re-dispatch |
blocker_unresolved |
Ticket blocked by another in-progress ticket | Move to end of queue; complete blocking ticket first |
pr_template_blocked |
Mergeability gate rejected — missing PR body sections | Update PR body with required sections (Summary/Risk/Test Evidence/Rollback), rerun gate |
agent_timeout |
Sub-agent produces no output for >30 min | Circuit breaker trips; ticket marked AGENT_TIMEOUT; retry in next run |
unknown |
Failure doesn't match above patterns | Escalate to user: ticket ID, last command output, branch state |
Failure Attempt Definition
A failure attempt is counted when ticket-pipeline returns non-zero exit OR the summary contains an explicit failure class. Partial completions (branch exists, some steps done) count as one attempt.
Self-Healing Rules
- Attempt retry up to 2 times within the same run only. On interruption + re-run, counts reset.
- After 2 failed attempts: produce failure report and stop dispatching new tickets.
- Escalation = log the failure report and continue with remaining unblocked tickets.
DoD Compliance Gate
After all ticket-pipeline waves complete and before marking the epic as Done:
- Invoke
/dod-sweep {epic_id}(targeted mode) - If
overall_status == FAIL:- Do NOT mark the epic Done
- Follow-up tickets are auto-created by dod_sweep
- Post Slack notification: "Epic {epic_id} blocked by DoD gaps: {failed} failed, {exempted} exempted, {passed} passed"
- Leave epic in current state for manual resolution
- If
overall_status == PASS:- Proceed to mark epic Done
- Post Slack notification: "Epic {epic_id} DoD gate cleared: {passed} passed, {exempted} exempted, {failed} failed"
- If
overall_status == UNKNOWN(e.g., all tickets exempted):- Post Slack notification: "Epic {epic_id} DoD gate: no evidence-backed passes -- {exempted} exempted, review recommended"
- Proceed to mark epic Done (non-blocking) but flag for review
- NOTE: Allowing all-exempted epics to proceed under UNKNOWN is a rollout accommodation, not the intended long-term steady state. This allow-through must sunset after the cutoff exemption review trigger fires (4 weeks or warning volume <10%, whichever first).
Notifications must always include passed/failed/exempted counts separately -- never imply all tickets passed when exemptions are present.
See Also
prompt.md— full orchestration logic, state machine, and error handling referenceticket-pipelineskill — per-ticket pipeline invoked by workersticket-workskill — implementation phase (autonomous mode)local-reviewskill — review + fix loopci-watchskill — CI polling and auto-fixpr-watchskill — PR review polling and auto-fixauto-mergeskill — merge gatedecompose-epicskill — empty epic auto-decomposeslack-gateskill — LOW_RISK notification gateverification-sweepskill — post-orchestration endpoint/DB/DoD verificationplugins/onex/skills/epic-team/repo_manifest.yaml— repo keyword mapping- Linear MCP tools (
tracker.*) — epic and ticket access
Container / Degraded Environment
When running without the full omniclaude plugin (e.g., container-based Claude Code sessions):
general-purposeis used as a safety-net fallback when the preferred skill is unavailable or returns an error. ONEX intelligence integration, action logging, and observability will be inactive. Skill instructions still execute correctly — only metadata and telemetry are affected.- Cross-skill dispatch (
Skill(skill="onex:...")) requires the plugin's skill registry. If skills are not registered, dispatch calls will fail. Verify: check if the skill appears in the system-reminder skills list. - Hook enforcement (poly_enforcer, authorization_shim, bash_guard) will not be active.
- Linear MCP may not be available. See
--localmode (if applicable) for offline ticket management.