/onex:self_healing_dispatch — Self-Healing Orchestration Wrapper
Skill ID: onex:self_healing_dispatch
Workstream: B Phase 3 — built on earlier stall-detection and dispatch-enforcement foundations
Purpose
Single orchestration entrypoint that combines:
- Phase 1 signals — stall detection and bounded redispatch from
agent_healthcheck - Phase 2 enforcement — TeamCreate dispatch (not advisory) from the dispatch-mode guardrail
Handles workloads of ≤10 tickets across ≤4 repos without human intervention.
Invocation
/onex:self_healing_dispatch --tickets PROJ-1234,PROJ-5678,PROJ-9012
/onex:self_healing_dispatch --epic-id PROJ-7253
/onex:self_healing_dispatch --epic-id PROJ-7253 --dry-run
Announce at start:
[self_healing_dispatch] Starting orchestration run <run_id> | tickets: N | mode: <live|dry-run>
Execution Protocol
Phase 0 — Input Resolution
- If
--ticketsgiven: parse and validate each asOMN-\d+. Fail fast on invalid IDs. - If
--epic-idgiven: calldecompose_epicto get child ticket list. Fail fast if decompose returns empty. - Exactly one of
--tickets/--epic-idmust be provided. Emit usage error and stop if neither or both.
ERROR: Provide exactly one of --tickets or --epic-id.
Usage:
/onex:self_healing_dispatch --tickets PROJ-1234,PROJ-5678
/onex:self_healing_dispatch --epic-id PROJ-7253
Phase 1 — Repo Grouping
Call the Python orchestrator:
from omniclaude.hooks.self_healing_orchestrator import orchestrate, group_by_repo
result = orchestrate(
ticket_ids=["PROJ-1234", "PROJ-5678"],
epic_id="PROJ-7253", # or None
repo_hints={"PROJ-1234": "omniclaude"}, # or None
run_id="orch-20260405T120000Z",
)
The orchestrator emits an orchestration_planned NDJSON event and returns a OrchestratorResult.
Grouping rules:
- Tickets with repo hints land in that repo's group.
- Tickets without hints: resolve from Linear ticket metadata (
labelsfield orteamfield). - Tickets that cannot be resolved land in the
unassignedgroup — surface a warning and stop.
WARNING: Cannot resolve repo for PROJ-9999. Provide --repo-hints or set the ticket's repo label in Linear before orchestrating.
Phase 2 — TeamCreate Dispatch (enforced)
For each dispatch group, spawn a named worker via TeamCreate. Never use a bare Agent() call — the dispatch-mode guardrail will fire an advisory, and this skill enforces TeamCreate unconditionally.
# Pseudo-code — executed by the skill entrypoint, not the Python module
team_name = f"orch-{run_id}"
for group in result.groups:
prompt = build_team_dispatch_prompt(group, epic_id=result.epic_id)
Agent(
name=f"worker-{group.repo}-{run_id}",
team_name=team_name,
prompt=prompt,
)
Log a worker_dispatched event per group:
{"timestamp_utc":"...","event":"worker_dispatched","run_id":"...","repo":"omniclaude","tickets":["PROJ-1234"]}
Phase 3 — Monitoring and Stall Recovery
After dispatching all workers, enter a polling loop. Use onex:agent_healthcheck as a composable sub-skill to check each active worker:
Skill(skill="onex:agent_healthcheck", args="--ticket-id PROJ-1234 --agent-id worker-omniclaude-orch-... --timeout-minutes 2 --max-redispatches 2")
On stall detected (status == stalled or failed):
from omniclaude.hooks.self_healing_orchestrator import record_stall_recovery, build_stall_recovery_prompt
should_redispatch, attempt = record_stall_recovery(
group=group,
ticket_id=stalled_ticket_id,
run_id=run_id,
completed_tickets=completed_so_far,
)
if should_redispatch:
prompt = build_stall_recovery_prompt(group, stalled_ticket_id, attempt, completed_so_far)
Agent(
name=f"recovery-{stalled_ticket_id}-attempt-{attempt}",
team_name=team_name,
prompt=prompt,
)
else:
# Exceeded MAX_REDISPATCHES (2) — escalate
linear.save_issue(identifier=stalled_ticket_id, status="Blocked",
comment=f"Auto-blocked: stalled {attempt} times. See dispatch log.")
Recovery prompt rules:
- Include only remaining (uncompleted) tickets — never re-scope to the full original list.
- Include the checkpoint path if written by
agent_healthcheck. - Cap recovery at
MAX_REDISPATCHES = 2. Third stall → escalate to Blocked.
Phase 4 — Completion
Exit when all workers have returned completed or escalated status.
Emit final orchestration_complete event:
{
"timestamp_utc": "...",
"event": "orchestration_complete",
"run_id": "...",
"total_tickets": 5,
"stalls_recovered": 1,
"escalated": [],
"elapsed_seconds": 430
}
Print final summary:
[self_healing_dispatch] Run <run_id> complete.
Tickets: 5 | Recovered stalls: 1 | Escalated: 0
Log: $ONEX_STATE_DIR/dispatch-log/2026-04-05.ndjson
Constraints
| Constraint | Value |
|---|---|
| Max tickets | 10 |
| Max repos | 4 |
| Max redispatches per ticket | 2 |
| Stall threshold | 2 minutes (delegated to agent_healthcheck) |
| Dispatch mode | TeamCreate only — bare Agent() forbidden |
Event Log Schema
All events append to $ONEX_STATE_DIR/dispatch-log/{YYYY-MM-DD}.ndjson. One JSON object per line, never pretty-printed.
| Event | Key Fields |
|---|---|
orchestration_planned |
run_id, epic_id, ticket_count, group_count, repos |
worker_dispatched |
run_id, repo, tickets |
stall_recovery_dispatched |
run_id, ticket_id, redispatch_attempt, max_redispatches |
escalated_to_blocked |
run_id, ticket_id, attempt_count |
orchestration_complete |
run_id, total_tickets, stalls_recovered, escalated, elapsed_seconds |
Dry-Run Mode
When --dry-run is set:
- Run Phases 0 and 1 (resolve + group).
- Print the planned dispatch groups.
- Emit
orchestration_plannedevent. - Do NOT launch any workers (skip Phases 2–4).
[self_healing_dispatch] DRY RUN — no workers launched.
Planned groups:
omniclaude: PROJ-1234, PROJ-5678
omnibase_core: PROJ-9012
Log: $ONEX_STATE_DIR/dispatch-log/2026-04-05.ndjson
Backing Module
All grouping, prompt generation, stall accounting, and log emission logic lives in:
src/omniclaude/hooks/self_healing_orchestrator.py
Do not duplicate this logic inline. Import and call the module functions.
See Also
onex:agent_healthcheck— stall detection sub-skillonex:dispatch_watchdog— epic-level watchdog for larger wave runsonex:epic_team— full epic orchestration (use for >10 tickets or >4 repos)