---
name: halo-action-pipeline
description: A generic, tenant-agnostic plan→apply pipeline for ANY Halo write operation at scale. Compose every MCP call to an immutable action file first; a dumb executor agent reads it, resolves id references, pushes one call, and appends progress; a coordinator routes each phase to the right agent and runs the apply loop. File-based, idempotent, splittable, resumable, self-contained. Load whenever planning or executing a multi-write Halo operation — bulk creates/updates, document/asset ingestion, reconciliations. Deep operational doctrine (hang model, lease sizing, watchdog, ledger integrity, halo-apply mechanics) lives in reference-docs/halo-action-pipeline-operations.md.
---

# halo-action-pipeline — plan → apply for Halo writes

## References

| Reference | Type | Location | When to Use |
|---|---|---|---|
| `grcitsm` | Skill | `plugins/grcitsm-analyst/skills/grcitsm/` | Halo MCP catalog routing for the tools that the executor actually calls |
| `halo-bulk-update` | Skill | `plugins/grcitsm-analyst/skills/halo-bulk-update/` | Six-phase methodology that invokes this pipeline at scale |
| `pipeline.py` | File | `skills/halo-action-pipeline/pipeline.py` | Deterministic bookkeeping helper for `ready` / `resolve` / `status` / `stalls` against the JSONL contract |
| `halo-apply` | Workflow | shipped at `workflows/halo-apply.js`, invoked as `grcitsm-analyst:halo-apply` | The preferred apply-loop workflow that runs rounds deterministically (ready set, leases, dead-executor reclaim) — auto-surfaced by the plugin, no project copy |
| Pipeline operations | Reference | `reference-docs/halo-action-pipeline-operations.md` | Hang model, executor cost/tiering, lease sizing, coordinator watchdog, ledger integrity + seeding recipe, planner authoring traps, `halo-apply` workflow args + resume mechanics |
| Write semantics | Reference | `reference-docs/halo-platform/write-semantics.md` | Payload shapes the planner encodes: null-handling (§2), framework-CF ordering (§5-6), payload cliff (§8), structural writes (§12), bulk shape rules (§14) |
| API/MCP semantics | Reference | `reference-docs/halo-platform/api-mcp-semantics.md` | Relay semantics (§5), read-tool caps + stage-to-disk (§6) |

A reusable two-process model (plus a coordinator role) that makes every Halo write file-first, resumable, and recoverable. It is **generic**: the consuming workflow supplies the domain knowledge; this skill supplies the execution machinery.

**Why file-first:** write state held in context is fragile — the file makes a hang cost one call, keeps progress inspectable, and lets any fresh agent resume. The model recovers executors that RETURN, not ones that HANG; full hang model + defenses: `reference-docs/halo-action-pipeline-operations.md`.

## The three roles

| Role | Runs as | Writes Halo? | Owns |
|---|---|---|---|
| **Planner** | `halo-action-planner` subagent | NO | composes `actions-<step>.jsonl` — every MCP call fully specified |
| **Executor** | `halo-action-executor` subagent | YES | reads ready actions, resolves `$ref`s, calls ONE tool, appends progress |
| **Coordinator** | **the MAIN session thread** (role spec: `halo-pipeline-coordinator.md`) | NO (dispatches) | routes each phase to the right agent AND drives the apply loop (lease → heartbeat → kill+reassign → advance) |

Hard separation: the planner never calls a write tool; the executor never composes a payload (it only resolves `$ref`s and dispatches). All domain knowledge lives in the action file.

**The coordinator is a ROLE, not a dispatchable subagent** — subagents cannot spawn agents; the main thread loads the role spec and drives the loop, directly or via the `halo-apply` workflow. It routes: judgment/authoring → the consuming workflow's specialist agents (the workflow owns the phase→agent map); mechanical application → the executor fleet. This skill defines only the executor contract and the apply loop.

**The script (`pipeline.py`) is an accelerator, not a gate** — the CONTRACT is the JSONL files; if the script fails, agents derive the ready-set and refs by hand — degraded to slower, never to blocked-or-lost. Rationale: `reference-docs/halo-action-pipeline-operations.md`.

## Files (one run folder)

All run state lives under one folder in `outputs/` (one run = one folder — the convention shared with `halo-bulk-update` and the SSP flows). Bare cross-plugin action files (`inputs/halo-actions/*.jsonl`) get a run folder via `pipeline.py bootstrap` before the apply loop. The `runFolder` arg below points at its `state/` subfolder:

```
outputs/<run>/state/
  actions-<step>.jsonl   immutable — written ONCE by the planner, one line per MCP call
  progress.jsonl         append-only, hash-chained — written ONLY via pipeline.py append-event (see Progress event)
```

### Action line (one atomic MCP call)
```json
{"id":"row1.create","tool":"create_new_ticket","args":{"user_email":"...","ticket_type":"...","summary":"...","description":"..."},"needs":[],"returns":"ticket_id","guard":{"search":"<distinctive text or inventory_number>","kind":"ticket"}}
{"id":"row1.structure","tool":"update_ticket_fields","args":{"payload":{"id":"$ref:row1.create.ticket_id","site_id":1,"category_1":"..."}},"needs":["row1.create"]}
```
- `id` — stable, unique; convention `<row>.<call>`.
- `tool` — bare MCP tool name (no server prefix; the executor adds the grcitsm MCP server's prefix).
- `args` — the literal params the tool takes, with `$ref:<action_id>.<return_key>` for any value not known until a prior call returns. One exception: a structured `payload_obj`, which `resolve` serialises to the tool's `payload` string AFTER `$ref` substitution (mechanics: `reference-docs/halo-action-pipeline-operations.md`).
- `needs` — action ids that must be `done` first. The dependency graph lives here, not in an agent's head.
- `returns` — name of the id this call yields (executor stores it on the done event; creates typically yield `ticket_id`/`asset_id`).
- `guard` (create-kind only) — search-or-skip for idempotency. Before a create, the executor searches; a match → record `done` with the found id instead of re-creating.

### Progress event
```json
{"ts":"<iso>","id":"row1.create","status":"start|done|fail|superseded","owner":"exec-A","ids":{"ticket_id":1234},"note":"<only on fail>","seq":12,"prev":"<hash>","hash":"<hash>"}
```
Written ONLY via `pipeline.py append-event` (chained `seq`/`prev`/`hash` under an exclusive lock; `verify-chain` detects post-hoc deletion or edits). `--ids` = the RETURNED entity ids, never action ids; a `done` with no prior `start` and no `--ids` is rejected. A verified-false `done` POISONS the ledger — quarantine those ids (remediate main-thread or on a fresh ledger; `doneIds` from tenant read-back) and append `superseded` (`--note` = read-back evidence): it retracts the done for `ready`/`$ref` resolution.
The event `id` is the action id **copied verbatim from the action line** — never abbreviate to the row prefix (`"row1"` for `"row1.create"`). A mis-keyed event is invisible to `status`/`ready`/`stalls`, so the lease doesn't register and the action can double-lease.

## pipeline.py — the deterministic helper (executor + coordinator use it; never hand-parse unless it fails)
```
python3 <skill>/pipeline.py ready    --actions outputs/<run>/state/actions-<step>.jsonl --progress outputs/<run>/state/progress.jsonl --limit 5 --owner-free
   → action ids whose needs are all done and which aren't done/leased — the next work
python3 <skill>/pipeline.py resolve  --actions ... --progress ... --id row1.structure
   → {"id","tool","args":<$refs substituted>,"returns","guard"} — ready to call
python3 <skill>/pipeline.py status   --actions ... --progress ...      → {total,done,start,fail,superseded,pending}
python3 <skill>/pipeline.py stalls   --progress ... --minutes 10       → [{id,owner,since}] leased >10min, no done
python3 <skill>/pipeline.py bootstrap --file inputs/halo-actions/<f>.jsonl
   → scaffold a run folder from a BARE action file: outputs/<run>/state/ with actions-01.jsonl + empty progress.jsonl
python3 <skill>/pipeline.py append-event --actions ... --progress ... --id row1.create --status done --ids '{"asset_id":123}'
   → the ONLY sanctioned ledger writer (hash-chained, flock-serialized); --ids = RETURNED entity ids,
     never action ids. ALWAYS pass --actions: a done on a returns-carrying action is rejected without
     non-empty --ids. Seeding recipe: reference-docs/halo-action-pipeline-operations.md
     § Seeding a mixed manual/fleet handoff.
python3 <skill>/pipeline.py verify-chain --actions ... --progress ...
   → recompute the chain + fabrication audit (--actions covers every returns-carrying action);
     exit non-zero on any break. Completion gate + watchdog sampling: same doc.
```

## Executor contract (`halo-action-executor`)

Executors run on a low-cost model tier at low effort — the stage is mechanical and carries nearly all pipeline tokens; model/effort selection lives at the dispatch site, never in this skill (full rationale + safety conditions: `reference-docs/halo-action-pipeline-operations.md`).

**Tenant-tool access (must hold, or the fleet applies 0/N):** executors inherit the session's authenticated connectors; `halo-action-executor` deliberately omits `tools:` and restricts via `disallowedTools:` (no glob matches an MCP server segment) — do NOT tighten it back. `scripts/resolve-tenant-prefix.sh` verifies reachability at apply time (blocks on zero, refuses to guess among several). The run folder's `state/tenant-witness.json` — captured at apply start by reading the pinned tenant's own identity — is emitted by `resolve` as `expect_tenant` on every action, and the executor fails on a missing witness or a write response that doesn't match it: ids alone are not proof of tenant. Full mechanism + headless-consent caveat: `reference-docs/required-mcp-tools.md` § "Connector auth and subagent / executor inheritance".

Given a lease of action ids (or "take the next N ready"):
1. `pipeline.py ready` → claim ids. Append the start event (`append-event --status start --owner <me>`) BEFORE calling.
2. `pipeline.py resolve --id <id> --prefix <tenant-prefix>` (returns the tool FULLY QUALIFIED — call it verbatim, never assemble a tool name) → tool + resolved args.
3. Guarded create: search first; a match = EXACT summary+type (ticket) / full `inventory_number` (asset) — then append `done` with the existing id, SKIP the call. Creates are the ONLY non-idempotent kind.
4. Call the tool. Two write-response shapes, discriminated by what the DEPLOYED tool returns, never by name: **slim projection** (ids + client/site NAMES + `inactive` + `written[]`; `update_asset` v1.0.0+ and the two `*_from_library` tools) → the response IS the receipt: read it whole, assert the names against `expect_tenant`, `inactive` = duplicate-create soft-fail, `written[]` confirms the payload landed — a read-back is redundant. **Full object** (inline OR auto-spilled to a file) → extract ONLY the `returns` id, never read or echo the body; need a written field → targeted read-back. Shapes, sizes, bloat mechanism: same doc as item 6.
5. Append `done` with `--ids` via `append-event`. On error, unresolved `$ref`, or a tool-signature mismatch: append `fail` with a note and STOP that action — an action's `args` are applied VERBATIM; repairing or reshaping a malformed action is a planner bug executed as fabricated evidence, never a favor.
6. **Lease sizing:** guarded creates 2–4; slim-projection follow-ups 15–20; full-object writes 6–10. Size to the DEPLOYED runbook, not the repo spec — a tenant still on pre-v1.0.0 `update_asset` returns the fat object and stays at 6–10. Full rationale: `reference-docs/halo-action-pipeline-operations.md` § Lease sizing.

## Coordinator contract (main-thread role; spec in `halo-pipeline-coordinator.md`)
1. Route the phase (Job 1 in the role spec): authoring/judgment → the workflow's designated specialist agents; mechanical application → the executor fleet; payload composition → the planner.
2. Apply loop (Job 2) — **preferred: run the `halo-apply` workflow** (the plugin's frozen `grcitsm-analyst:halo-apply`), which executes the loop deterministically. Manual fallback loop + concurrency caps: `reference-docs/halo-action-pipeline-operations.md`.
3. **Drive `halo-apply` under a FOREGROUND watchdog for large or mechanical runs** — no per-agent timeout exists, so a hung executor freezes the run silently. Full procedure (poll cadence, TaskStop + `doneIds` relaunch, verify-chain sampling): `reference-docs/halo-action-pipeline-operations.md`.
4. NEVER spawn executors as headless `claude -p` shell children (no TaskStop, silent death, credential echo).
5. **Completion gate:** a run is not complete while `pipeline.py verify-chain --actions …` reports broken links or fabrication findings — surface coverage (chained/total), never only the boolean. The ledger is a RESUME AID, not evidence: **the tenant is the only reliable witness** — verify via tenant reads, never by counting ledger dones. Mechanics + rationale: `reference-docs/halo-action-pipeline-operations.md` § Ledger integrity; procedure: `commands/halo-apply.md` § post-run population check.

`halo-apply` workflow args, parse guard, permissions, resume mechanics: `reference-docs/halo-action-pipeline-operations.md` § The halo-apply workflow.

## Idempotency + resume
- Re-running the whole apply is safe: `done` actions skip; guarded creates search-or-skip; updates are naturally idempotent.
- Resume after any crash: point a fresh coordinator at the same `outputs/<run>/state/` — the ledger is the memory.
- `$ref` resolution reads the LATEST done event, so a reclaimed create updates downstream refs automatically.

## Authoring traps the planner encodes as structure (Halo-generic)
The planner encodes tenant-agnostic Halo traps as dependency edges and payload shape, never prose: replace-semantics arrays (model the union as its own action, or prefer omit-preserve), asynchronous derive-and-revert event runbooks (`needs`-follow the trigger; never combine trigger and override in one action), ordered multi-pass fields (filter-source action precedes the dependent, else silent drop), the content-filter hang (defang-lint all free-text before sealing the file — describe, never quote), and the payload cliff (truncated create + long-form follow-up). Full trap list with encodings and shape-rule citations: `reference-docs/halo-action-pipeline-operations.md` § Planner authoring traps. The consuming workflow extends it with domain-specific traps; this skill carries only the tenant-agnostic core.

## Cross-references
This skill is self-contained; consuming workflows reference IT (not vice-versa).

- **The most reliable defense against the hang is not needing the fleet at all.** For a uniform mechanical write (same op across every row, no per-row reasoning), prefer a single server-side **Array-Iteration runbook** (`auto_action:12/13`; Native SQL `auto_action:18` is READ-ONLY — the loop's query, never the write) over an action file and executor fleet. Decision gate: `[[halo-bulk-update]]` Phase 0, before building any action file for a mechanical run.
