Skip to content

EventBus Event Types and Payload Schemas

This document catalogs every event type emitted by little-loops subsystems. It is the primary reference for extension authors, external consumers (e.g. loop-viz), and internal development.

Related Documentation: - API Reference — EventBus and LLExtension — bus registration, transports, filter patterns - Architecture Overview — Event persistence patterns and FSM executor design


Wire Format

All events are emitted as flat Python dicts and serialized to JSON:

{
  "event": "<event-type>",
  "ts": "2026-04-02T12:00:00.123456",
  "run_id": "2026-04-02T120000-my-loop",
  "loop": "my-loop",
  "<field>": "<value>"
}
Key Type Description
event str Event type identifier (see tables below)
ts str ISO 8601 timestamp, UTC
run_id str Run-scoped identity, stable across a run (including pause/resume). Present on every event emitted through FSMExecutor._emit() (ENH-3345) and, additively, on every parallel.* event (ENH-3346) — required in the schema for the parallel.* namespace specifically; issue.* emitters don't yet stamp it. For FSM-path events, derived once per run from started_at + loop name (see below) — cannot be split on -: the date portion keeps its own - separators and loop names may themselves contain dashes, so consumers must group on the full string. Known limitation: two concurrent runs of the same loop started in the same second collide (the derivation truncates to second precision); this is an accepted limitation also present in .history/<run_id>-<loop> archive folder naming. parallel.*'s run_id is ParallelOrchestrator.run_id (a uuid4().hex by default), a distinct derivation from the FSM path's.
loop str Loop name. Same presence/requiredness caveat as run_id.
producer_pid int FEAT-3323. The emitting process's own os.getpid(), stamped onto a copy of every event by UnixSocketTransport.send() (never mutating the caller's dict, so other transports on the same bus never see this key). Always present on socket-relayed live frames. On a bridge-built state_change seed frame (see below) it is LoopState.pid instead — the loop's owning process, since the bridge and not a producer emitted that frame — and is omitted, never null, when the state recorded no pid. Not pid (already taken by handoff_spawned's spawned-child pid and by state_change's LoopState.pid) and not run_id (ENH-3346); never recovered from the socket filename.
(payload fields) varies Type-specific fields documented per event

When received by an LLExtension, the raw dict is wrapped into an LLEvent dataclass:

event.type      # the "event" key
event.timestamp # the "ts" key
event.payload   # all remaining keys as a dict

Hook intents — sibling type

LLEvent covers pub/sub bus events. Hook intents (PreCompact, SessionStart, PreToolUse, …) are request/response and use a sibling dataclass LLHookEvent, with handler responses modeled as LLHookResult. Adapters under hooks/adapters/<host>/ translate between each host's native hook protocol and these host-agnostic types; the dispatcher lives in little_loops.hooks.main_hooks and is invoked as python -m little_loops.hooks <intent>.

LLHookEvent fields

Source of truth: scripts/little_loops/hooks/types.py.

Key Type Description
host str Host agent identifier (e.g. "claude-code", "opencode", "codex"). Adapters set this; the CLI reads LL_HOOK_HOST (default "claude-code").
intent str Hook intent name matching the handler module (e.g. pre_compact, session_start).
ts str ISO 8601 UTC timestamp. Field name differs from wire key: stored as timestamp on the dataclass, serialized as ts by to_dict(). from_dict() accepts either ts or timestamp.
payload object Host-supplied event data. Schema is intent-specific (see per-intent notes below).
session_id str (optional) Host session identifier. Omitted from the wire dict when None.
cwd str (optional) Working directory the host was operating in. Omitted from the wire dict when None.

LLHookResult fields

Key Type Description
exit_code int Always emitted. 0 = pass; 2 = block and surface feedback to the model. Non-Claude hosts map this to their own permit/deny semantics.
feedback str (optional) Human-readable message. Claude Code writes this to stderr when exit_code == 2. Omitted from the wire dict when None.
decision str (optional) Permission decision for permission-checking intents (allow / deny / ask). Omitted from the wire dict when None.
data object Additional structured data returned to the host. Omitted from the wire dict when empty.
stdout str (optional) Raw payload written to the host's stdout (e.g. SessionStart's merged config JSON). Omitted from the wire dict when None.

Wire-format example

{
  "host": "claude-code",
  "intent": "pre_compact",
  "ts": "2026-05-12T14:00:00Z",
  "payload": {"transcript_path": "/tmp/session.jsonl"},
  "cwd": "/Users/me/project"
}

Round-trip note: to_dict() emits the timestamp under the key ts; from_dict() accepts both ts and timestamp. A dict produced by to_dict() round-trips cleanly through from_dict().

Per-intent payload notes

  • pre_compact — reads exactly one payload key, transcript_path (falls back to ""). Writes .ll/ll-precompact-state.json and returns LLHookResult(exit_code=2, feedback="[ll] Task state preserved before context compaction. Check .ll/ll-precompact-state.json if resuming work.") to surface a state-preservation notice on context compaction. Gated by the SELFCOMPACT rubric (ENH-2341): when hooks.pre_compact.rubric.enabled is set and a transcript_path is available, handle() first evaluates the recent trajectory against the rubric's four conditions. If the trajectory fails, it returns exit_code=0 instead — no state file is written and no feedback is surfaced. The rubric is disabled by default, so the unconditional exit_code=2 path is what most projects see.
  • pre_compact_handoff — reads .ll/ll-precompact-state.json as an idempotency guard, proceeding to write when no state snapshot is present; writes .ll/ll-continue-prompt.md atomically. Returns exit_code=2 on success with feedback="[ll] Session handoff snapshot written.", exit_code=0 on idempotency skip (prompt already fresher than compacted_at) or any error. Invoked via hooks/adapters/claude-code/precompact-handoff.sh.
  • session_start — reads transcript_path from the payload when available (Codex/OpenCode hosts; ENH-1945), falling back to Path.cwd()-based directory probing. Returns LLHookResult(exit_code=0, feedback=<stderr-lines>, stdout=<merged-config-json-or-None>).
  • session_end — reads one payload key, cwd (falling back to event.cwd, then Path.cwd()). Handler reads done issue IDs via find_issues(status_filter={"done"}) and the hooks.stale_ref_fix key from the raw config; outputs sweep findings in result.feedback. Always exits 0.

This intent is bound to Claude Code's SessionStart event, not SessionEnd. Claude Code enforces a hard ~1.5s ceiling on SessionEnd hooks before killing them on every exit path (Ctrl+C, Ctrl+D, /exit), regardless of the configured timeout — an unfixed upstream bug (anthropics/claude-code#32712, #41577). The stale-ref sweep's full-tree issue scan exceeds that ceiling on repos with a few thousand issue files, so it was being killed on nearly every exit. It now runs once at the start of the next session, with the same detection value and no exit-teardown race. Only the hooks/hooks.json event binding moved; the adapter file and intent name (session-end.shsession_end) are unchanged.


Naming Conventions

Namespace Pattern Source
FSM executor bare names (loop_start, state_enter, …) fsm/executor.py
FSM persistence bare names (loop_resume) fsm/persistence.py
StateManager state.* state.py
Issue lifecycle issue.* issue_lifecycle.py
Parallel orchestrator parallel.* parallel/orchestrator.py

Use these namespaces in event_filter patterns when registering observers:

# Subscribe only to FSM events
bus.register(callback, filter="state_*")

# Subscribe only to issue lifecycle events
bus.register(callback, filter="issue.*")

# Subscribe to multiple namespaces
bus.register(callback, filter=["issue.*", "parallel.*"])

Subsystem: FSM Executor

Source: little_loops.fsm.executor.FSMExecutor
Path: scripts/little_loops/fsm/executor.py
Flow: FSMExecutor._emit()event_callbackEventBus.emit()

These events use bare names (no dot namespace) for historical compatibility.

loop_start

Emitted once at the very beginning of loop execution, before any state is entered.

loop (and run_id) are universal envelope fields (see Wire Format) — no event-type-specific fields.

Example:

{"event": "loop_start", "ts": "2026-04-02T12:00:00Z", "run_id": "2026-04-02T120000-my-loop", "loop": "my-loop"}


state_enter

Emitted when the executor enters a state, before the state's action is executed.

Field Type Description
state str Name of the state being entered
iteration int Step count (1-based); increments on every state entry regardless of loop passes
iteration_count int Full-pass (maintain-mode) restart count (0-based); increments after each complete loop pass via a terminal state; always 0 for loops that do not use maintain

Example:

{"event": "state_enter", "ts": "...", "state": "build", "iteration": 1, "iteration_count": 0}


route

Emitted when the executor selects the next state after an evaluation.

Field Type Required Description
from str always Source state name
to str always Destination state name
reason str optional "maintain" when the loop is in maintain mode; "host_pressure" when the host memory-pressure guard routes a prompt state; absent otherwise

Example:

{"event": "route", "ts": "...", "from": "build", "to": "test"}


action_start

Emitted immediately before executing the current state's action.

Field Type Description
action str The resolved action string (interpolated prompt text or shell command)
is_prompt bool true if the action is a Claude prompt; false if a shell command

Example:

{"event": "action_start", "ts": "...", "action": "Run tests", "is_prompt": true}


action_output

Emitted for each line of streaming output produced by the action. High-frequency event — may fire hundreds of times per state.

Field Type Description
line str A single line of output from the running action

Example:

{"event": "action_output", "ts": "...", "line": "✓ 42 tests passed"}


action_complete

Emitted after the action finishes, regardless of success or failure.

Field Type Required Description
exit_code int always Exit code of the action (0 = success)
duration_ms int always Wall-clock execution time in milliseconds
output_preview str \| null always Last 2 000 characters of the action's output; null if no output was produced
stderr_preview str \| null always Last 2 000 characters of the action's stderr; null if no stderr was produced (ENH-2469)
is_prompt bool always true for Claude prompt actions, false for shell commands
state str FSM-emitted only FSM state whose action produced this event (ENH-3240); absent on runs archived before this field was added and on the non-FSM ll-action emitter (cli/action.py)
iteration int FSM-emitted only Executor step count when this action ran (ENH-3240); same absence caveats as state
session_jsonl str \| null prompt only Absolute path to the Claude session JSONL file for this prompt run; null if path cannot be determined
input_tokens int prompt only Input tokens consumed by the host CLI invocation
output_tokens int prompt only Output tokens generated by the host CLI invocation
cache_read_tokens int prompt only Cache-read tokens consumed
cache_creation_tokens int prompt only Cache-creation tokens written
model str prompt only Model ID reported by the host CLI (e.g. claude-sonnet-4-5)
effort str prompt only Reasoning effort level applied to the invocation (ENH-2885)
is_batch bool prompt only true if the host CLI invocation was a batch request (FEAT-2716)

Example (shell command):

{
  "event": "action_complete",
  "ts": "...",
  "exit_code": 0,
  "duration_ms": 1234,
  "output_preview": "Build succeeded",
  "is_prompt": false,
  "state": "build",
  "iteration": 3
}

Example (Claude prompt):

{
  "event": "action_complete",
  "ts": "...",
  "exit_code": 0,
  "duration_ms": 45000,
  "output_preview": "I have completed the task...",
  "is_prompt": true,
  "state": "verify_issue",
  "iteration": 10,
  "session_jsonl": "/Users/user/.claude/projects/.../abc123.jsonl", // ll-private-ok: generic placeholder path, not a real machine path
  "input_tokens": 1234,
  "output_tokens": 567,
  "cache_read_tokens": 890,
  "cache_creation_tokens": 0,
  "model": "claude-sonnet-4-5"
}


action_error

Emitted when an action raises an unhandled exception that is routed to the state's on_error target. Only emitted when on_error is defined; if absent, the exception propagates to the top-level loop handler and terminates execution instead.

Field Type Required Description
state str always Name of the state whose action raised
error str always String representation of the raised exception
route str always Route taken in response (always "on_error")

Example:

{
  "event": "action_error",
  "ts": "...",
  "state": "fetch_data",
  "error": "ConnectionError: timed out after 30s",
  "route": "on_error"
}


messages_append

Emitted when a state's append_to_messages field is set and the state's action completes. The interpolated message is appended to the executor's in-memory messages list and mirrored onto the event bus.

Field Type Description
message str The interpolated message text that was appended
state str Name of the state whose append_to_messages fired

Example:

{"event": "messages_append", "ts": "...", "message": "Implemented auth middleware", "state": "implement"}


evaluate

Emitted after the evaluator runs to determine the next routing decision.

Field Type Description
type str Evaluation type: "default" (exit-code based) or the custom type declared in the state's evaluate config (e.g. "llm")
verdict str Evaluator verdict (e.g. "pass", "fail", "yes", "no", "retry", "error")
(detail fields) varies Additional evaluator-specific fields (e.g. score, reason for LLM evaluators)

action_stall evaluator detail fields:

Field Type Description
stall_count int Number of consecutive identical-hash iterations so far
max_repeat int Configured threshold before stall verdict
hash_changed bool Whether the hash of tracked context values changed this iteration
tracked_keys list[str] Context keys that were hashed (default ["action"])
repeated_hash str (only on verdict="no") The MD5 hex digest that repeated

Example (default exit-code evaluation):

{"event": "evaluate", "ts": "...", "type": "default", "verdict": "pass"}

Example (action_stall stall detected):

{"event": "evaluate", "ts": "...", "type": "action_stall", "verdict": "no",
 "stall_count": 2, "max_repeat": 2, "hash_changed": false,
 "tracked_keys": ["action"], "repeated_hash": "a1b2c3d4e5f6"}


retry_exhausted

Emitted when a state exceeds its max_retries limit and the executor transitions to on_retry_exhausted.

Field Type Description
state str Name of the state that exhausted its retry budget
retries int Number of retries that were attempted
next str Name of the on_retry_exhausted target state

Example:

{"event": "retry_exhausted", "ts": "...", "state": "test", "retries": 3, "next": "fail"}


stall_detected

Emitted when the FSM stall detector (FEAT-1637) observes window consecutive iterations producing an identical (state, exit_code, verdict) triple. Configured via the top-level circuit.repeated_failure block. On firing, the executor either terminates the run with terminated_by="stall_detected" (when on_repeated_failure: "abort") or routes to the configured recovery state.

Field Type Description
state str Name of the state whose repeated entry triggered the stall
exit_code int The repeating action exit code (timeouts surface as 124)
verdict str The repeating evaluator verdict (e.g. "no", "error")
consecutive int Number of consecutive identical triples observed
recurrent int (recurrent-window path only; ENH-2245) Total non-consecutive occurrences of the (state, exit_code, verdict) triple that fired the detector. Mutually exclusive with consecutive on the recurrent firing path.
action str Resolved action: literal "abort" or "route:<state>"

Example:

{"event": "stall_detected", "ts": "...", "state": "check_semantic_vision", "exit_code": 124, "verdict": "error", "consecutive": 3, "action": "abort"}


cycle_detected

Emitted when the same edge (from_state->to_state) is traversed more than max_edge_revisits times, indicating a tight cycle. The executor terminates the run with terminated_by="cycle_detected".

Field Type Description
edge str Edge key (from_state->to_state) that triggered detection
from str Source state of the cyclic edge
to str Target state of the cyclic edge
count int Number of times this edge was traversed
max int Configured max_edge_revisits limit

Example:

{"event": "cycle_detected", "ts": "...", "edge": "build->test", "from": "build", "to": "test", "count": 6, "max": 5}


rate_limit_exhausted

Emitted when the wall-clock rate-limit budget is spent across the short-burst and long-wait retry tiers and the executor transitions to on_rate_limit_exhausted (or on_error). See rate_limit_max_wait_seconds and rate_limit_long_wait_ladder on StateConfig for budget configuration.

Field Type Description
state str Name of the state that exhausted rate-limit retries
retries int Total rate-limit retries attempted across both tiers (short_retries + long_retries)
short_retries int Retries attempted in the short-burst tier (before entering long-wait)
long_retries int Retries attempted in the long-wait tier (ladder-based)
total_wait_seconds number Accumulated wall-clock seconds spent sleeping in rate-limit waits
next str \| null Name of the on_rate_limit_exhausted target state, or null

Example:

{"event": "rate_limit_exhausted", "ts": "...", "state": "implement", "retries": 7, "short_retries": 3, "long_retries": 4, "total_wait_seconds": 21600.0, "next": "halt"}


rate_limit_storm

Emitted when consecutive rate_limit_exhausted events across any states reach the storm threshold (3). The counter resets on any successful non-rate-limited state transition.

Field Type Description
state str Name of the state that triggered the storm threshold
count int Consecutive rate_limit_exhausted count at emission time

Example:

{"event": "rate_limit_storm", "ts": "...", "state": "implement", "count": 3}


infra_retry / infra_retry_exhausted

BUG-2731: emitted by _handle_infra_retry when a headless claude -p action exits 143 after already emitting a stream-json result event — the CLI SIGTERM-reaping a still-running subagent process group at end-of-turn, not a genuine implementation failure. infra_retry fires on each in-place retry attempt; infra_retry_exhausted fires once _DEFAULT_INFRA_RETRY_RETRIES (2) attempts are spent and the executor falls through to normal verdict routing. Flat backoff (_DEFAULT_INFRA_RETRY_BACKOFF, 5s), same shape as api_error_retry/api_error_exhausted but no long-wait tier — this is a re-run of an already-completed action, not a wait for an external service.

Field Type Description
state str Name of the state that hit (or exhausted) the infra-retry path
attempt / retries int infra_retry: attempt number just made. infra_retry_exhausted: total retries attempted before exhaustion
backoff int (infra_retry only) Flat backoff seconds before the retry

Example:

{"event": "infra_retry", "ts": "...", "state": "refine_issue", "attempt": 1, "backoff": 5}
{"event": "infra_retry_exhausted", "ts": "...", "state": "refine_issue", "retries": 2}


rate_limit_waiting

Emitted periodically (every _RATE_LIMIT_HEARTBEAT_INTERVAL ≈ 60s) by the FSM executor during long-wait tier sleeps between 429 retry attempts. The short-burst tier does not emit this event. Provides heartbeat visibility into in-progress waits so dashboards and analysis tooling can surface progress toward the wall-clock budget defined by rate_limit_max_wait_seconds.

Field Type Description
state str Name of the state currently retrying
elapsed_seconds number Wall-clock seconds elapsed in the current sleep window
next_attempt_at number Unix timestamp (seconds, float) at which the next retry will fire
total_waited_seconds number Accumulated wall-clock seconds across all 429 waits for this state
budget_seconds number Configured rate_limit_max_wait_seconds budget
tier str Current retry tier (always "long_wait" — short-burst tier does not emit this event)

Example:

{"event": "rate_limit_waiting", "ts": "...", "state": "implement", "elapsed_seconds": 60.0, "next_attempt_at": 1744890896.0, "total_waited_seconds": 180.0, "budget_seconds": 21600, "tier": "long_wait"}


throttle_warn

Emitted when a state's tool-call count reaches warn_max within a single state visit.

Field Type Description
state str State name where throttle warning was triggered
count int Current tool-call count at time of emission
normal_max int Configured normal_max threshold for this state
warn_max int Configured warn_max threshold for this state
hard_max int Configured hard_max threshold for this state

Example:

{"event": "throttle_warn", "ts": "...", "state": "implement", "count": 8, "normal_max": 3, "warn_max": 8, "hard_max": 12}


throttle_hard

Emitted when a state's tool-call count reaches hard_max, triggering transition to on_throttle_hard.

Field Type Description
state str State name where hard throttle was triggered
count int Current tool-call count at time of emission
hard_max int Configured hard_max threshold for this state
next str Target state (on_throttle_hard or on_error, or null)

Example:

{"event": "throttle_hard", "ts": "...", "state": "implement", "count": 12, "hard_max": 12, "next": "throttle_recovery"}


throttle_stop

Emitted when a state's tool-call count exceeds hard_max with no on_throttle_hard target, causing a hard stop.

Field Type Description
state str State name where stop throttle was triggered
count int Current tool-call count at time of emission
hard_max int Configured hard_max threshold for this state

Example:

{"event": "throttle_stop", "ts": "...", "state": "implement", "count": 13, "hard_max": 12}


prompt_size_warn

Emitted when a fully-interpolated action's character size reaches the per-loop prompt_size_guard.warn_chars threshold (ENH-2486). WARN-only — it does not route; it surfaces loops that silently re-embed monotonically growing captured outputs/artifacts so the ballooning is observable in <run>.events.jsonl. Disable per-run with --no-prompt-size-guard.

loop (and run_id) are universal envelope fields (see Wire Format).

Field Type Description
state str State name where the oversized action was assembled
size int Fully-interpolated action size in characters
threshold int Configured prompt_size_guard.warn_chars threshold
est_tokens int Estimated tokens (size // 4, the repo's 4-chars/token convention)

Example:

{"event": "prompt_size_warn", "ts": "...", "run_id": "2026-04-02T120000-general-task", "loop": "general-task", "state": "check_done", "size": 62000, "threshold": 50000, "est_tokens": 15500}


human_approval_requested

FEAT-1794: emitted exactly once by FSMExecutor._execute_human_approval_state after adapter.send_alert() returns for an action_type: human_approval state. The executor is the sole emitter — adapters never emit this event themselves, avoiding a duplicate with complementary missing fields.

Field Type Description
state str Name of the human_approval state
alert_id str Adapter-assigned alert identifier, passed to await_response()/cancel_alert()
prompt str Rendered (${captured.*}-interpolated) prompt text sent to the operator
timeout number Effective wait budget in seconds: state.timeout, else hitl.default_timeout, clamped to the loop's remaining fsm.timeout budget when set
deadline_ts str Wall-clock ISO 8601 deadline; the monotonic deadline used internally for the wait loop is not meaningful to out-of-process consumers
captured_context object Extra context passed to the adapter, minus the internal monotonic deadline key

Example:

{"event": "human_approval_requested", "ts": "...", "run_id": "2026-09-05T120000-ll-auto", "loop": "ll-auto", "state": "check_human", "alert_id": "3f9c...", "prompt": "The execute step modified 240 lines...", "timeout": 1800, "deadline_ts": "2026-09-05T12:30:00Z", "captured_context": {}}


human_approval_resolved

FEAT-1794: emitted after FSMExecutor._execute_human_approval_state routes a human_approval state's verdict.

Field Type Description
state str Name of the human_approval state
alert_id str \| null Adapter-assigned alert identifier; null on the headless short-circuit (no alert was sent)
verdict str approve | reject | edit | timeout | shutdown
elapsed_seconds number Wall-clock seconds spent waiting for a verdict
route str \| null Resolved next state, or null if no route matched
reason str \| null Reject reason from AdapterResponse.reason; "headless" on the no-TTY short-circuit; null otherwise

Example:

{"event": "human_approval_resolved", "ts": "...", "run_id": "2026-09-05T120000-ll-auto", "loop": "ll-auto", "state": "check_human", "alert_id": "3f9c...", "verdict": "approve", "elapsed_seconds": 42.1, "route": "advance", "reason": null}


human_response

FEAT-3384: emitted by FSMExecutor._drain_inbound() when an inbound item declares event: "human_response" with an alert_id — an out-of-process verdict posted via POST /{token}/interaction under ll-loop run --serve (or an in-process emitter calling EventBus.emit() directly). Re-emitted under its own name with a whitelisted payload (never the raw POST body spread over the envelope), so EventBusAdapter's bus observer can resolve the pending alert it names. Inbound-origin, like artifact_interaction.

Field Type Description
alert_id str Alert identifier the verdict answers
verdict str approve | reject | edit
edited_text str \| null Replacement text for an edit verdict; null otherwise
reason str \| null Reject reason; null otherwise

Example:

{"event": "human_response", "ts": "...", "run_id": "2026-09-05T120000-ll-auto", "loop": "ll-auto", "alert_id": "3f9c...", "verdict": "approve", "edited_text": null, "reason": null}


learning_target_proven

Emitted when a target's learning-tests registry record is found with status='proven'. The state continues to the next target (or to on_yes when all targets are proven).

Field Type Description
state str State name executing the learning dispatch
target str Target identifier (e.g. "Anthropic SDK streaming")

learning_target_stale

Emitted when a target's registry record is missing or has status='stale', immediately before /ll:explore-api fires.

Field Type Description
state str State name executing the learning dispatch
target str Target identifier
cause str "missing" or "stale"

learning_explore_invoked

Emitted just before the learning state invokes /ll:explore-api <target>. Pairs with action_start/action_complete from the underlying skill invocation.

Field Type Description
state str State name executing the learning dispatch
target str Target identifier being explored
attempt int Attempt number (1-based), capped by learning.max_retries

learning_target_refuted

Emitted when a target's record has status='refuted'. Routes to on_blocked / on_no.

Field Type Description
state str State name executing the learning dispatch
target str Target identifier

learning_complete

Emitted when every target in a learning state has been proven. The state transitions via on_yes.

Field Type Description
state str State name executing the learning dispatch
targets list[str] Targets that were all proven

learning_blocked

Emitted when a learning state cannot advance: a target is refuted, or /ll:explore-api retries are exhausted without proving the target.

Field Type Description
state str State name executing the learning dispatch
target str Target that blocked progress
reason str "refuted" or "retries_exhausted"

handoff_detected

Emitted when the executor detects a handoff signal in the action output, indicating the loop needs to be paused and resumed in a fresh session.

Field Type Description
state str Current state name when the handoff was detected
iteration int Current iteration count
continuation str The continuation prompt payload extracted from the handoff signal

Example:

{
  "event": "handoff_detected",
  "ts": "...",
  "state": "implement",
  "iteration": 3,
  "continuation": "Continue from: implement auth middleware..."
}


handoff_spawned

Emitted when the handoff handler spawns a new child process to continue the loop.

Field Type Description
pid int PID of the spawned child process
state str Current state name at the time of spawning

Example:

{"event": "handoff_spawned", "ts": "...", "pid": 98765, "state": "implement"}


host_pressure

Emitted when the host guard observes memory pressure above the configured threshold and decides to act. The event names the decision taken; the matching route or abort follows immediately. Constants for this family live beside the other host-guard event names in scripts/little_loops/fsm/host_guard.py.

Field Type Description
state str Name of the state that was executing when pressure was observed
used_pct float Memory used as a percentage, rounded to one decimal place
action str Decision taken: "route:<target-state>" when on_pressure: route, or "abort" when on_pressure: abort

Example:

{"event": "host_pressure", "ts": "...", "state": "implement", "used_pct": 91.4, "action": "route:cool_down"}


host_pressure_relieved

Emitted when memory use falls back below the pressure threshold after a host_pressure event, so a consumer can close the pressure interval it opened.

Field Type Description
state str Name of the state executing when pressure cleared
used_pct float Memory used as a percentage, rounded to one decimal place

Example:

{"event": "host_pressure_relieved", "ts": "...", "state": "implement", "used_pct": 62.8}


host_pressure_abort

Emitted when on_pressure: abort is configured and the guard is tearing the run down. The subsequent loop_complete carries terminated_by="host_pressure_abort".

Field Type Description
state str Name of the state that was executing when the abort fired
used_pct float Memory used as a percentage, rounded to one decimal place

Example:

{"event": "host_pressure_abort", "ts": "...", "state": "implement", "used_pct": 96.2}


host_cooldown

Emitted when on_pressure: cool_down is configured and the guard is pausing before continuing, rather than routing or aborting.

Field Type Description
state str Name of the state that was executing when the cooldown started
used_pct float Memory used as a percentage, rounded to one decimal place
cooldown_seconds float How long the executor will pause before resuming

Example:

{"event": "host_cooldown", "ts": "...", "state": "implement", "used_pct": 90.1, "cooldown_seconds": 30.0}


host_subproc_rss

Emitted as the executor tracks host-subprocess resident memory against the configured budget. This is the measurement event; host_budget_exceeded is the enforcement event.

Field Type Description
state str Name of the state whose subprocess was measured
peak_rss_mb float Peak resident set size for this subprocess, in MB
cumulative_mb float Running total across subprocesses in this run, in MB
budget_mb float Configured budget the cumulative figure is measured against

Example:

{"event": "host_subproc_rss", "ts": "...", "state": "implement", "peak_rss_mb": 812.5, "cumulative_mb": 2410.0, "budget_mb": 4096.0}


host_budget_exceeded

Emitted when cumulative subprocess RSS crosses the configured budget. Like host_pressure, the action field records whether the guard routed or aborted; on abort, loop_complete carries terminated_by="host_budget_exceeded".

Field Type Description
state str Name of the state executing when the budget was exceeded
cumulative_mb float Cumulative subprocess RSS across the run, in MB
budget_mb float Configured budget that was exceeded
action str Decision taken: "route:<target-state>" or "abort"

Example:

{"event": "host_budget_exceeded", "ts": "...", "state": "implement", "cumulative_mb": 4210.0, "budget_mb": 4096.0, "action": "abort"}


request_path_downgrade

Emitted when a state requested a non-CLI request_path (sdk or batch) that could not be satisfied, and the executor fell back to cli. Also printed to stderr as a Warning: line.

Latched — fires at most once per run. The executor sets a flag on the first downgrade, so a loop whose every state requests sdk on a machine without the anthropic package emits exactly one of these, not one per state. Consumers must not treat the event count as a downgrade count.

Field Type Description
requested str The request_path that was asked for ("sdk" or "batch")
reason str Why it could not be honored (e.g. "anthropic package not importable")

Example:

{"event": "request_path_downgrade", "ts": "...", "requested": "sdk", "reason": "anthropic package not importable"}


sub_loop_worktree_attached

Emitted when a state.worktree-configured sub-loop call (ENH-2609) successfully sets up a dedicated git worktree for the child loop, immediately after setup_worktree() succeeds and before the child executor runs. Pairs with sub_loop_worktree_detached when the child finishes; mutually exclusive with sub_loop_worktree_error for the same call (whichever of setup_worktree()'s outcomes actually occurred fires exactly one of the two).

Field Type Description
branch str Interpolated state.worktree branch name
path str Filesystem path to the created worktree

Example:

{"event": "sub_loop_worktree_attached", "ts": "...", "branch": "sub-implement-BUG-042", "path": "/repo/.worktrees/20260402-153000-subloop-sub-implement-BUG-042"}


sub_loop_worktree_detached

Emitted after the child loop finishes and its dedicated worktree is torn down via cleanup_worktree(). Only the worktree checkout is removed — the branch itself is never auto-deleted.

Field Type Description
branch str Interpolated state.worktree branch name
path str Filesystem path of the worktree that was torn down

Example:

{"event": "sub_loop_worktree_detached", "ts": "...", "branch": "sub-implement-BUG-042", "path": "/repo/.worktrees/20260402-153000-subloop-sub-implement-BUG-042"}


sub_loop_worktree_error

Emitted when setup_worktree() raises a RuntimeError while attaching a per-state worktree for a sub-loop call. The state then routes via on_error if defined, else on_no.

Field Type Description
branch str Interpolated state.worktree branch name that failed to attach
error str String representation of the raised RuntimeError

Example:

{"event": "sub_loop_worktree_error", "ts": "...", "branch": "sub-implement-BUG-042", "error": "RuntimeError: worktree already exists at .worktrees/..."}


prepatch_check_flagged

Emitted when a state's prepatch_check guard runs in "warn" policy mode and the pre-patch evidence check returns a "flagged" verdict. "warn" policy never blocks the run — this event is the lightweight signal that a flag occurred; the full evidence bundle is written to a _prepatch_check context list and, when run_dir/issue_id are available, a prepatch_evidence_<issue_id>.json sidecar plus a session_store record.

Field Type Description
state str Name of the state whose prepatch check flagged
policy str Configured prepatch_check policy (always "warn" when this event fires)
outcomes int Count of outcomes recorded in the evidence bundle (len(evidence.outcomes))

Example:

{"event": "prepatch_check_flagged", "ts": "...", "state": "implement", "policy": "warn", "outcomes": 2}


ab_comparison

Emitted once per compared item during an A/B baseline run (ll-loop run <loop> --baseline), carrying the per-item harness-vs-baseline result. A sibling baseline_complete event reports the raw per-arm cost/timing totals for the same item, and a run-level ab_summary event reports the aggregate across all items.

Field Type Description
index int Zero-based index of the compared item
harness_pass bool Whether the harness arm passed for this item
baseline_pass bool Whether the single-shot baseline arm passed
harness_tokens int Tokens consumed by the harness arm
baseline_tokens int Tokens consumed by the baseline arm
harness_duration_ms int Wall-clock duration of the harness arm
baseline_duration_ms int Wall-clock duration of the baseline arm
confidence float Judge confidence in the comparison
reason str Judge rationale for the verdict
raw object Raw comparison payload from the judge

Failed comparisons are not emitted. When judging an item raises, the executor records an entry carrying an additional error: "evaluation_error" key into its in-memory results, but does not emit an ab_comparison event for it. A consumer counting events will therefore see fewer than the number of items compared, and will never observe an error field on the wire.

Example:

{
  "event": "ab_comparison", "ts": "...", "index": 3,
  "harness_pass": true, "baseline_pass": false,
  "harness_tokens": 18400, "baseline_tokens": 5200,
  "harness_duration_ms": 42000, "baseline_duration_ms": 9000,
  "confidence": 0.82, "reason": "harness output satisfied all criteria; baseline missed two",
  "raw": {}
}


baseline_complete

Emitted once per compared item during an A/B baseline run, immediately after the harness arm and baseline arm both finish executing in parallel (_execute_with_baseline) — before the blind comparator runs. Reports the raw per-arm cost/timing totals for the item; the qualitative pass/fail verdict is reported separately by the sibling ab_comparison event once the blind comparator has judged the pair.

Field Type Description
harness_duration_ms int Wall-clock duration of the harness arm, in milliseconds
baseline_duration_ms int Wall-clock duration of the baseline arm, in milliseconds
harness_tokens int Total tokens (input + output) consumed by the harness arm
baseline_tokens int Total tokens (input + output) consumed by the baseline arm

Example:

{"event": "baseline_complete", "ts": "...", "harness_duration_ms": 42000, "baseline_duration_ms": 9000, "harness_tokens": 18400, "baseline_tokens": 5200}


ab_summary

Emitted once, when the FSM executor finishes a run that collected any ab_comparison results (FEAT-1822), immediately after ab.json is written to run_dir via write_ab_json(). Reports the run-level aggregate; each individual item's comparison was already reported by ab_comparison. Best-effort — wrapped in a try/except so a failure computing or writing the summary never fails the loop run itself; on that path no ab_summary event is emitted at all.

Field Type Description
harness_pass_rate float Fraction of items where the harness arm passed (0-1)
baseline_pass_rate float Fraction of items where the baseline arm passed (0-1)
delta float Pass-rate difference (harness_pass_rate - baseline_pass_rate)
item_count int Number of items included in the summary (len(summary.per_item))

Example:

{"event": "ab_summary", "ts": "...", "harness_pass_rate": 0.82, "baseline_pass_rate": 0.61, "delta": 0.21, "item_count": 25}


api_error_retry

Emitted each time a state's host call fails with a retryable API error and the executor schedules another attempt. See also the infra_retry section, which references this event by name.

Field Type Description
state str Name of the state whose call failed
attempt int Retry count so far for this state
backoff float Seconds the executor will wait before retrying

Example:

{"event": "api_error_retry", "ts": "...", "state": "implement", "attempt": 2, "backoff": 4.0}


api_error_exhausted

Emitted when a state's retryable API errors exhaust the retry allowance. Terminal for that state's attempt sequence.

Field Type Description
state str Name of the state whose retries were exhausted
retries int Total retries attempted before giving up

Example:

{"event": "api_error_exhausted", "ts": "...", "state": "implement", "retries": 5}


cost_ceiling_unknown

Emitted by the post-action per-state cost-ceiling check (BUG-3360, _check_cost_ceiling) when a state with cost_ceiling configured cannot have its actual cost evaluated — because usage.jsonl is missing/empty for the run, or because the state's usage rows reference an unpriceable model. Logged at most once per state name per run. Unknown cost is never treated as under budget — the ceiling simply cannot be enforced for that visit.

Field Type Description
state str Name of the state whose cost could not be evaluated
reason str "usage.jsonl unavailable" or "unpriceable model"

Example:

{"event": "cost_ceiling_unknown", "ts": "...", "state": "implement", "reason": "usage.jsonl unavailable"}


cost_ceiling_warn

Emitted when a state's actual cost (summed from usage.jsonl) reaches or exceeds its configured cost_ceiling.cost_warn_at threshold. WARN-only — does not route or abort. Logged at most once per state name per run.

Field Type Description
state str Name of the state whose cost crossed the warn threshold
cost_usd float State's actual cost in USD, rounded to 4 decimal places
cost_warn_at float Configured cost_ceiling.cost_warn_at threshold

Example:

{"event": "cost_ceiling_warn", "ts": "...", "state": "implement", "cost_usd": 1.2345, "cost_warn_at": 1.0}


cost_ceiling_exceeded

Emitted when a state's actual cost exceeds its configured cost_ceiling.cost_ceiling_per_state hard limit. The executor finishes the run via self._finish("cost_ceiling_exceeded", ...), mirroring the abort branch of host_budget_exceeded; loop_complete carries terminated_by="cost_ceiling_exceeded".

Field Type Description
state str Name of the state whose cost exceeded the hard ceiling
cost_usd float State's actual cost in USD, rounded to 4 decimal places
cost_ceiling_per_state float Configured hard-ceiling threshold that was exceeded
action str Always "abort"

Example:

{"event": "cost_ceiling_exceeded", "ts": "...", "state": "implement", "cost_usd": 5.5, "cost_ceiling_per_state": 5.0, "action": "abort"}


loop_complete

Emitted once when the executor finishes, regardless of how it terminated.

Field Type Description
final_state str Name of the state at termination. Usually the last state entered; when terminated_by="timeout" this may be a state that was routed to but never entered. Exception (BUG-1226): when that pending state is a shell action, the executor flushes it — emitting state_enter with flushed: true and running its action — before honoring the timeout, so state_enter for final_state is always emitted before loop_complete. Slash commands and sub-loops are not flushed.
iterations int Total number of iterations completed
failure_terminal bool Emitted unconditionally (ENH-2814). true only when terminated_by="terminal" and the reached terminal state declares failure: true — the single source of truth for "did this run fail?", keyed on the flag rather than the state's name. Absent in run archives predating ENH-2814.
terminated_by str Reason for termination: "signal" (OS signal), "error" (no valid transition or unhandled error), "timeout" (wall-clock timeout elapsed), "terminal" (a terminal state was reached), "stall_detected" (FEAT-1637 circuit fired with on_repeated_failure: "abort"), "cycle_detected" (same edge traversed more than max_edge_revisits times), "max_steps" (step cap reached), "max_iterations_reached" (full-pass cap reached), "user_stopped", "system_signal", "interrupted", "host_pressure_abort" (ENH-2452 memory pressure), "host_budget_exceeded" (ENH-2453 subprocess RSS budget), "cost_ceiling_exceeded" (BUG-3360 per-state cost ceiling), or "handoff" (ContextLimitHandoff handler)
error str only when terminated_by="error"

Example (normal termination):

{
  "event": "loop_complete",
  "ts": "...",
  "final_state": "done",
  "iterations": 5,
  "terminated_by": "terminal"
}

Example (error termination):

{
  "event": "loop_complete",
  "ts": "...",
  "final_state": "cua_observe",
  "iterations": 2,
  "terminated_by": "error",
  "error": "Loop file not found: cua-fix-verify"
}


max_steps_summary

Emitted when the step cap fires and on_max_steps is set on the loop. Signals that the executor is about to run the summary state before terminating. Always immediately precedes the state_enter for the summary state. loop_complete fires after the summary state completes with terminated_by="max_steps".

Field Type Description
summary_state str Name of the state the executor will transition to
iterations int Step count at which the cap fired

Example:

{
  "event": "max_steps_summary",
  "ts": "...",
  "summary_state": "summarize_partial",
  "iterations": 100
}


max_iterations_reached_summary

Emitted when the full-pass cap fires and on_max_iterations is set on the loop. Signals that the executor is about to run the summary state before terminating. Always immediately precedes the state_enter for the summary state. loop_complete fires after the summary state completes with terminated_by="max_iterations_reached".

Field Type Description
summary_state str Name of the state the executor will transition to
iteration_count int Full-pass count at which the cap fired

Example:

{
  "event": "max_iterations_reached_summary",
  "ts": "...",
  "summary_state": "summarize_passes",
  "iteration_count": 10
}


Subsystem: FSM Persistence

Source: little_loops.fsm.persistence.PersistentExecutor
Path: scripts/little_loops/fsm/persistence.py

loop_resume

Emitted when a paused or interrupted loop is resumed. Occurs after the executor state is restored from disk, before execution continues. loop and run_id are universal envelope fields (see Wire Format); run_id here is derived from the restored started_at, so it matches the run_id of every event from before the pause (ENH-3345).

Field Type Required Description
from_state str always State to resume from (as saved in the state file)
iteration int always Iteration count at the time of resume
from_handoff bool optional true when resuming from a handoff_detected pause; absent otherwise
continuation_prompt str optional The continuation prompt (only present when from_handoff is true)

Example (normal resume):

{"event": "loop_resume", "ts": "...", "run_id": "2026-04-02T120000-my-loop", "loop": "my-loop", "from_state": "test", "iteration": 2}

Example (handoff resume):

{
  "event": "loop_resume",
  "ts": "...",
  "run_id": "2026-04-02T120000-my-loop",
  "loop": "my-loop",
  "from_state": "implement",
  "iteration": 3,
  "from_handoff": true,
  "continuation_prompt": "Continue from: implement auth middleware..."
}

Transport behavior

loop_resume is emitted via EventBus.emit() and therefore fans out to every registered observer and every registered transport (FEAT-1322 / FEAT-1323). In ll-loop resume (cli/loop/lifecycle.py:cmd_resume), wire_transports() is called immediately after wire_extensions(), so transports configured under events.transports in ll-config.json see loop_resume for resumed runs the same way ll-loop run sees loop_start for fresh runs. Earlier builds wired transports only on cmd_run, which meant resumed loops bypassed the transport layer; that gap is closed by FEAT-1323. Teardown happens in a try/finally around the resume call: executor.close_transports() runs even on KeyboardInterrupt so any buffered loop_resume (and downstream) events are flushed before the process exits.


Subsystem: StateManager

Source: little_loops.state.StateManager
Path: scripts/little_loops/state.py
Flow: StateManager._emit()EventBus.emit()
Filter pattern: "state.*"

These events track per-run issue processing state for ll-auto and ll-sprint.

state.issue_completed

Emitted when an issue is marked as completed in the sequential run state.

Field Type Description
issue_id str Issue identifier (e.g. "BUG-001")
status str Always "completed"

Example:

{"event": "state.issue_completed", "ts": "...", "issue_id": "BUG-001", "status": "completed"}


state.issue_failed

Emitted when an issue is marked as failed in the sequential run state.

Field Type Description
issue_id str Issue identifier
reason str Human-readable failure reason
status str Always "failed"

Example:

{
  "event": "state.issue_failed",
  "ts": "...",
  "issue_id": "BUG-002",
  "reason": "Command exited with code 1",
  "status": "failed"
}


state.issue_skipped

Emitted when an issue is marked as skipped in the sequential run state — the mark-skipped path in StateManager. Structurally identical to state.issue_failed apart from the status literal; a skip is a deliberate pass-over (e.g. a held lock or an unmet precondition), not a failure, so consumers computing failure rates should exclude these.

Field Type Description
issue_id str Issue identifier
reason str Human-readable reason the issue was skipped
status str Always "skipped"

Example:

{
  "event": "state.issue_skipped",
  "ts": "...",
  "issue_id": "BUG-003",
  "reason": "file lock held by another worker",
  "status": "skipped"
}


Subsystem: Issue Lifecycle

Source: little_loops.issue_lifecycle
Path: scripts/little_loops/issue_lifecycle.py
Filter pattern: "issue.*"

These events are emitted by the standalone lifecycle functions used by ll-auto, ll-sprint, and ll-parallel.

issue.failure_captured

Emitted when a new bug issue is automatically created from a failed parent issue.

Field Type Description
issue_id str ID of the newly created bug issue
file_path str Absolute path to the new bug issue file
parent_issue_id str ID of the parent issue that triggered this capture
captured_at string \| null ISO 8601 timestamp from the issue's frontmatter; null for issues created before ENH-1839.
session_id str \| None always

Example:

{
  "event": "issue.failure_captured",
  "ts": "...",
  "issue_id": "BUG-042",
  "file_path": "/path/to/.issues/bugs/P1-BUG-042-....md",
  "parent_issue_id": "ENH-025"
}


issue.closed

Emitted when an issue is closed without being implemented (e.g. invalid, duplicate, or already fixed).

Field Type Description
issue_id str Issue identifier
file_path str Absolute path to the issue file in completed/
close_reason str Reason code, e.g. "already_fixed", "invalid_ref", "duplicate", "unknown"
captured_at string \| null ISO 8601 timestamp from the issue's frontmatter; null for issues created before ENH-1839.
session_id str \| None always

Example:

{
  "event": "issue.closed",
  "ts": "...",
  "issue_id": "BUG-015",
  "file_path": "/path/to/.issues/bugs/P2-BUG-015-....md",
  "close_reason": "already_fixed"
}


issue.completed

Emitted when an issue successfully completes its full lifecycle and is moved to completed/.

Field Type Description
issue_id str Issue identifier
file_path str Absolute path to the issue file in completed/
captured_at string \| null ISO 8601 timestamp from the issue's frontmatter; null for issues created before ENH-1839.
session_id str \| None always

Example:

{
  "event": "issue.completed",
  "ts": "...",
  "issue_id": "ENH-025",
  "file_path": "/path/to/.issues/enhancements/P3-ENH-025-....md"
}


issue.deferred

Emitted when an issue is moved to the deferred pool.

Field Type Description
issue_id str Issue identifier
file_path str Absolute path to the issue file in deferred/
reason str Human-readable reason for deferral
captured_at string \| null ISO 8601 timestamp from the issue's frontmatter; null for issues created before ENH-1839.
session_id str \| None always

Example:

{
  "event": "issue.deferred",
  "ts": "...",
  "issue_id": "FEAT-099",
  "file_path": "/path/to/.issues/deferred/P2-FEAT-099-....md",
  "reason": "Blocked on external dependency"
}


issue.skipped

Emitted when an issue is skipped during automated processing (e.g., by ll-auto when the issue does not meet filter criteria or is explicitly excluded).

Field Type Description
issue_id str Issue identifier
file_path str Absolute path to the issue file
reason str Human-readable reason for skipping
captured_at string \| null ISO 8601 timestamp from the issue's frontmatter; null for issues created before ENH-1839.
session_id str \| None always

Example:

{
  "event": "issue.skipped",
  "ts": "...",
  "issue_id": "BUG-042",
  "file_path": "/path/to/.issues/bugs/P2-BUG-042-....md",
  "reason": "Issue type excluded by --type filter"
}


issue.started

Emitted when a deferred issue is undeferred and returned to active status (via undefer_issue()).

Field Type Description
issue_id str Issue identifier
file_path str Absolute path to the issue file
reason str Human-readable reason for restarting
captured_at string \| null ISO 8601 timestamp from the issue's frontmatter; null for issues created before ENH-1839.
session_id str \| None always

Example:

{
  "event": "issue.started",
  "ts": "...",
  "issue_id": "FEAT-099",
  "file_path": "/path/to/.issues/features/P2-FEAT-099-....md",
  "reason": "Unblocked after dependency resolved"
}


Subsystem: Parallel Orchestrator

Source: little_loops.parallel.orchestrator.ParallelOrchestrator
Path: scripts/little_loops/parallel/orchestrator.py
Filter pattern: "parallel.*"

parallel.worker_completed

Emitted when a parallel worker finishes processing an issue in its isolated git worktree.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run (ENH-3346)
issue_id str Issue identifier processed by this worker
worker_name str Name of the git worktree directory used by this worker
status str "success" if the worker succeeded, "failure" otherwise
duration_seconds float Wall-clock time in seconds for the entire worker run

Pairing caveat: parallel.worker_completed can arrive with no preceding parallel.worker_startedworker_started fires after worktree creation inside WorkerPool._process_issue, but _on_worker_complete emits worker_completed on failure too, including a worktree-setup failure that never reached the worker_started emission point. Consumers must tolerate a completed event with no matching started event.

Example:

{
  "event": "parallel.worker_completed",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "issue_id": "BUG-007",
  "worker_name": "ll-worker-BUG-007-abc123",
  "status": "success",
  "duration_seconds": 142.7
}

parallel.epic_branch_stale

Emitted by WorkerPool when a reused (already-existing, local-hit) EPIC integration branch is found behind its resolved fork base and is warned/merged/conflict-degraded (ENH-3302). Not emitted for N == 0 (fresh) or refresh_on_reuse: off. The checkout_epic_branch state of auto-refine-and-implement.yaml shares the same underlying worktree_utils.ensure_epic_branch() helper but has no EventBus — it writes ${context.run_dir}/epic-branch-stale.txt with the same fields instead.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run (ENH-3346)
branch str EPIC integration branch name
base str Resolved fork base the branch was measured/merged against
commits_behind int git rev-list --count <branch>..<base> — commits base has that branch lacks
mode str Configured parallel.epic_branches.refresh_on_reuse value (warn/merge)
action str "warned" | "merged" | "merge_conflict"

Example:

{
  "event": "parallel.epic_branch_stale",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "branch": "epic/epic-3041-host-agnostic-advisor",
  "base": "main",
  "commits_behind": 448,
  "mode": "merge",
  "action": "merged"
}

parallel.worker_started

Added in ENH-3346. Emitted by WorkerPool._process_issue immediately after worktree creation, when a worker begins processing an issue. Not emitted from submit() at dispatch time, because worktree_path/branch don't exist until worktree creation completes — "claimed but not yet running" is already visible via parallel.queue_changed's pending/active counts.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run
worker_id str Worker identifier, aliased to issue_id (stable for the worker's lifetime)
issue_id str Issue identifier this worker is processing
worktree_path str Filesystem path to the worker's git worktree
branch str Git branch created for this worker

Example:

{
  "event": "parallel.worker_started",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "worker_id": "BUG-007",
  "issue_id": "BUG-007",
  "worktree_path": "/repo/.worktrees/worker-bug-007-20260829-120000",
  "branch": "parallel/bug-007-20260829-120000"
}

parallel.worker_blocked

Added in ENH-3346. Emitted by ParallelOrchestrator._process_parallel when an issue is deferred to _deferred_issues on an overlap conflict, before any worktree exists for it — the only real blocked transition in parallel/ today. Does not cover an already-dispatched worker wedged mid-execution (no code path in parallel/ detects that; see Scope Boundaries in ENH-3346).

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run
worker_id str Worker identifier, aliased to issue_id
issue_id str Issue identifier that was deferred
reason str Why the issue was blocked; "overlap" today (WorkerBlockedReason, extensible)

Example:

{
  "event": "parallel.worker_blocked",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "worker_id": "BUG-008",
  "issue_id": "BUG-008",
  "reason": "overlap"
}

parallel.worker_unblocked

Added in ENH-3346. Paired resume for parallel.worker_blocked, emitted by ParallelOrchestrator._requeue_deferred_issues only when the deferred issue is successfully re-queued (IssuePriorityQueue.requeue() returns True) — never on a rejected/no-op re-add.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run
worker_id str Worker identifier, aliased to issue_id
issue_id str Issue identifier that was re-queued

Example:

{
  "event": "parallel.worker_unblocked",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "worker_id": "BUG-008",
  "issue_id": "BUG-008"
}

parallel.merge_started

Added in ENH-3346. Emitted by MergeCoordinator._process_merge at the very top of the method, before the circuit-breaker check — not at the MergeStatus.IN_PROGRESS assignment, because the paused-circuit-breaker path calls _handle_failure before IN_PROGRESS is ever set, which would break 1:1 pairing with parallel.merge_completed on that path. Gated on request.retry_count == 0, so a MergeStatus.RETRYING re-entry of the same request does not re-emit.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run
worker_id str Worker identifier, aliased to issue_id
issue_id str Issue identifier whose merge is starting
branch str Worker branch being merged

Example:

{
  "event": "parallel.merge_started",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "worker_id": "BUG-007",
  "issue_id": "BUG-007",
  "branch": "parallel/bug-007-20260829-120000"
}

parallel.merge_completed

Added in ENH-3346. Emitted by MergeCoordinator._finalize_merge (success) or _handle_failure (failure/conflict/circuit-breaker skip). _handle_failure is terminal — it fires at most once per request — so parallel.merge_completed fires exactly once per merge request, always paired 1:1 with a preceding parallel.merge_started.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run
worker_id str Worker identifier, aliased to issue_id
issue_id str Issue identifier whose merge finished
outcome str "merged" | "failed" (MergeOutcome)
error str \| null Failure detail; null when outcome == "merged"

Example:

{
  "event": "parallel.merge_completed",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "worker_id": "BUG-007",
  "issue_id": "BUG-007",
  "outcome": "merged",
  "error": null
}

parallel.queue_changed

Added in ENH-3346. Emitted from inside IssuePriorityQueue's mutators (add, get, mark_completed, mark_failed, mark_skipped, requeue, plus load_completed/load_failed) after every counter-changing operation — not from orchestrator call sites, so both the parallel and sequential dispatch paths are covered from a single choke point. Run-scoped, not worker-scoped: no worker_id/issue_id field, matching parallel.epic_branch_stale's existing precedent.

Conditional-emit rules: get() emits only on a successful dequeue (the main loop polls get(block=False) every iteration — an unconditional emit would flood the bus); add() only when it returns True (a rejected duplicate changes no counter); add_many() and the two resume-path bulk loaders (load_completed/load_failed) each emit once per batch, not once per item; requeue() only when it returns True (no emission on a rejected/no-op re-add).

Ordering: the payload carries a monotonic seq: int, incremented under the queue's internal lock alongside the counter snapshot. Because emit() runs after the lock is released, concurrent mutators (orchestrator main loop, merge-coordinator thread, worker threads) can deliver their snapshots out of order — consumers must apply last-writer-wins by seq, not arrival order.

Not exhaustive: overlap-deferred issues live in the orchestrator's _deferred_issues list, outside the queue, and are not reflected by these counters — reconstruct the blocked set from parallel.worker_blocked/ parallel.worker_unblocked events instead.

Field Type Description
run_id str Opaque ID shared by every issue in this top-level run
seq int Monotonic counter incremented under the queue's lock alongside the snapshot
pending int Issues waiting in the queue (qsize())
active int Issues currently in progress
completed int Issues completed successfully
failed int Issues that failed
skipped int Issues skipped

Example:

{
  "event": "parallel.queue_changed",
  "ts": "...",
  "run_id": "3f9c2a1e8b7d4c6a9f0e1d2c3b4a5968",
  "seq": 42,
  "pending": 3,
  "active": 2,
  "completed": 10,
  "failed": 1,
  "skipped": 0
}


Error Handling Contract

This section documents, for every event-emitter surface, what JSON callers can expect when the underlying emit path fails. All event delivery in little-loops is best-effort by design: the bus never propagates exceptions to the caller, and each transport has its own failure surface. Consumers should treat absent or partial output as success-with-soft-fail and never rely on emission of any specific event as a hard control-flow signal.

Related Documentation: - API Reference — EventBus and LLExtension — bus registration, transports, filter patterns - Architecture Overview — Event persistence — how events flow from emit sites to transports and persistence

EventBus.emit() dispatch contract

Source: EventBus.emit() in scripts/little_loops/events.py

  • Observer and transport exceptions are caught and logged. logger.warning("EventBus observer raised an exception", exc_info=True) and logger.warning("EventBus transport raised an exception", exc_info=True) swallow every failure. No exit-code bump, no error envelope, and no exception is propagated to the caller. A failing sink never blocks the others.
  • Filtered observers silently skip non-matching events. Observers registered with a filter=... pattern only see events where fnmatch.fnmatch(event_type, p) matches at least one pattern; non-matching events are skipped with no notification.
  • Dispatch order is deterministic. Observers are iterated in registration order first, then transports in registration order. There is no priority, preemption, or backpressure.
  • close_transports() exceptions are isolated per transport. EventBus.close_transports() in scripts/little_loops/events.py catches and logs exceptions from transport.close() so one misbehaving transport cannot prevent others from shutting down.

Caller implications: Treat bus.emit(event) as fire-and-forget. To assert that an event reached a sink, query the sink itself (e.g. transport.get_stats() for the Unix-socket transport) — do not rely on emit() return value (it returns None).

JsonlTransport

Source: JsonlTransport in scripts/little_loops/transport.py

  • No retry, no buffering, no rotation. send() opens the file, writes json.dumps(event) + "\n", and closes on every event.
  • Failures bubble to EventBus. Disk-full, permission-denied, or JSON-encoder exceptions are caught by EventBus.emit()'s except-block and become a logger.warning(...) line. They never reach the caller.
  • close() is a no-op. The constructor creates the parent directory once; each send() is self-contained.

Caller implications: A missing or unwritable path produces no events and no error to the caller — only log lines at WARNING level on stderr. Inspect the log or the file directly to confirm delivery.

UnixSocketTransport

Source: UnixSocketTransport in scripts/little_loops/transport.py · module-level queue/timeout constants in the same file

  • Full outbound queue → drop newest. If a client's outbound queue is full (_CLIENT_QUEUE_MAXSIZE = 1024), the event is dropped and dropped_count is incremented. A rate-limited WARNING is logged at most once per _DROP_LOG_INTERVAL_SEC = 5.0 seconds.
  • Connection cap exceeded → reject. When max_clients is reached, new connections are rejected and counted via get_stats()["client_rejections"].
  • AF_UNIX unavailable at startup → RuntimeError from wire_transports(). This is the one path in transport construction that propagates out — silently dropping a requested transport would be confusing. Once wire_transports() returns, all subsequent send() errors are caught by EventBus.emit.
  • Disconnected clients are removed from the pool. Per-client sendall failures are isolated in a try/except; they do not affect other clients or the FSM thread.
  • Accept-thread shutdown is bounded. close() joins with _ACCEPT_THREAD_JOIN_TIMEOUT = 2.0 and per-client threads with _CLIENT_THREAD_JOIN_TIMEOUT = 1.0; the total close path is bounded by _CLOSE_TOTAL_TIMEOUT = 10.0.

Caller implications: Under load, expect silent drops. Inspect get_stats()["dropped_count"] and get_stats()["client_rejections"] after a run to detect backpressure.

state_change — connection seed event

On accepting a new client, the socket seed callback in UnixSocketTransport replays one state_change record per currently-running loop so the client starts with a full picture instead of waiting for the next live event.

Field Type Description
event str Always "state_change"
(loop state fields) varies The running loop's serialized state, spread inline — the keys are whatever LoopState.to_dict() produces

This event does not travel the EventBus. It is serialized straight onto the new client's queue, so unlike every other event in this document it carries no ts key, is not visible to LLExtension observers or any other transport, and is silently dropped if the client's queue is already full. Treat it as a connection-time snapshot, not part of the event stream.

FEAT-3323: the SSE bridge (ll-artifact serve) owns its own seeding and filters this event on the way in. SseBridge fans in every producer socket in the project; with N producers it would otherwise receive N unstamped copies of the same state_change seed set. Instead it drops every socket-side state_change line and rebuilds the seed itself (_sse_bridge_seed_frames) on every SSE connect (including reconnects) from list_running_loops(Path(".loops")), writing one frame per state with producer_pid set to that state's LoopState.pid when not None. Since to_dict() already emits pid when set, a bridge-built seed frame carries both pid and producer_pid with the same value — the redundancy is deliberate (one demux key, producer_pid, on every frame regardless of source). The seed covers FSM loops only (ll-sprint/ll-parallel write no state files, matching today's socket seed); see CONFIGURATION.md § events.bridge for the no-replay reconnect contract and the seed-then-live duplicate-not-loss contract.

OTelTransport

Source: OTelTransport in scripts/little_loops/transport.py

  • Sub-loop events are no-ops. Events with depth > 0 (nested loop spans) are dropped after a one-time per-session warning. Nested OTel tracing is intentionally out of scope.
  • Out-of-order events log a warning and skip span creation. A state_enter without a prior loop_start, or an action_start without a prior state_enter, emits a warning and returns without creating a span. These warnings are the only signal that the span hierarchy is broken.
  • close() blocks on force_flush() + shutdown(). There is no engine-level timeout; rely on the OTel SDK's own deadlines.
  • Optional SDK import. The constructor raises RuntimeError with install guidance if opentelemetry-sdk or opentelemetry-exporter-otlp-grpc are missing.
  • Errors are caught at the EventBus level. Span-export failures (network, auth, throttling) do not surface to the caller.

Caller implications: Run with OTEL_SDK_DISABLED=true to skip this transport in environments without a collector. The dropped sub-loop events are expected — do not treat the warning as a bug.

WebhookTransport

Source: WebhookTransport in scripts/little_loops/transport.py · module-level retry/backoff constants in the same file

  • HTTP 5xx and transport exceptions trigger exponential-backoff retry. Up to max_retries=3 retries (overridable). Backoff starts at _WEBHOOK_RETRY_BASE_S = 0.5 and doubles up to a cap of _WEBHOOK_RETRY_MAX_S = 8.0. Non-5xx HTTP responses (< 500) are treated as success.
  • Retry exhaustion → batch dropped with warning. logger.warning("WebhookTransport: giving up after %d retries posting to %r", ...) and the batch is discarded. Never raised to the caller. This is the documented "best-effort" guarantee.
  • Non-blocking send(). Events enqueue on a Queue; a daemon thread drains and POSTs in batches every _WEBHOOK_BATCH_MS_DEFAULT = 1000 ms. Queue overflow is not guarded — relies on consumer thread pacing.
  • Optional httpx. The constructor raises RuntimeError with install guidance if httpx is missing.

Caller implications: Configure a webhook receiver that returns 2xx for accepted events and 5xx (or times out) for retriable failures. There is no caller-visible signal for dropped batches — log scraping is the only failure-detection path.

SQLiteTransport

Source: SQLiteTransport in scripts/little_loops/session_store/writers.py (despite the package name, this transport lives with the session store, not in transport.py; ENH-2890 split the former flat session_store.py into a session_store/ package).

  • Connection failure at construction → send() is a silent no-op forever. If the SQLite database cannot be opened, self._conn stays None and send() returns early. No error is raised to the caller.
  • Per-write failures are logged + swallowed (writers.py's SQLiteTransport.send()). Writes are serialized with a threading.Lock.
  • Recognises a closed set of event types only. _LOOP_EVENT_TYPES = frozenset({"loop_start", "loop_resume", "loop_complete", "state_enter", "route", "retry_exhausted", "cycle_detected", "max_steps_summary", "max_iterations_reached_summary"}) (defined in session_store/schema.py, re-exported at the session_store package root) plus the issue.* prefix. All other event types silently return without insert — there is no error envelope or warning.
  • close() is best-effort and swallows sqlite3.Error.

Caller implications: Treat the SQLite transport as an indexed history of FSM and issue events only. Other event types are intentionally not persisted; the absence of a row is not a failure.

action_error event contract

Source: the action_error emit site in scripts/little_loops/fsm/executor.py

  • Emitted only when a state config defines on_error. If on_error is absent on the failing state, the exception re-raises and the top-level loop handler terminates the loop with loop_complete.terminated_by="error" and the message in loop_complete.error. No action_error event is emitted in that case — the loop just ends.
  • Payload schema: {state, error, route: "on_error"} — same shape as a route event plus the original error string.
  • Ordering invariant: action_error is emitted after the action that failed but before the loop routes to the next state. Consumers can rely on this for sequencing (always pair action_start ↔ either action_complete or action_error from the same state).

Caller implications: Consumers MUST treat action_error as a first-class event type. Without it, a thrown exception in a state with on_error would be silently absorbed by the routing layer — the only externally visible signal that something went wrong is the action_error event itself. If your consumer is interested in failures, register a filter for action_error and treat it as equivalent to a non-zero exit code at the state level.

CLI exit-code conventions

The following conventions apply to little-loops CLI tools that emit JSON output. They are not redefined here in full — see API.md — CLI Conventions for the per-tool table.

  • --json output with no events / no findings. Most ll-verify-* tools emit a JSON envelope of the shape {"errors": [...], "warnings": [...], "data": ...}. An empty result is an empty array or empty object inside the envelope — not a null or a thrown error.
  • Exit codes (typical pattern):
  • 0 — success (or "findings present but tool ran successfully" for verify-style tools).
  • 1 — tool failure (could not read inputs, crashed, etc.).
  • 2 — validation failure (e.g. schema lint, audit gate).
  • 124 — timeout (matches the GNU timeout(1) convention; used by ll-action).
  • 130KeyboardInterrupt (matches the conventional 128 + SIGINT value).
  • ll-harness uses RunnerResult.exit_code with a caller-supplied --exit-code threshold (default 2 for timeout/exception markers, 0 for success). exit_code == 2 alone does not distinguish a timeout from a runner error (bad target, MCP config, scope resolution) — both branches store 2. Only RunnerResult.timed_out is persisted to harness_events; RunnerResult.error has no column. --retry-of's admissibility gate (ENH-3407) therefore reads the persisted timed_out column, not exit_code — a retry of a genuine timeout is admissible, a retry of any other 2 (or a graded 1) is refused until a persisted runner-error signal exists. ENH-3415: a stochastic runner (skill/prompt) graded over N>1 samples writes one harness_events row per sample (attempt_kind='repetition') instead of one row per invocation — RunnerResult.exit_code/timed_out above still describe a single sample's row; the invocation-level verdict (PASS/FAIL/ABSTAIN/ERROR/INCONCLUSIVE) is derived by banding the tally of those per-sample rows and is not itself persisted as a distinct row or column. ENH-3435: baseline-arm repetitions (--measure-baseline / --compare-baseline) land in this same table under this same convention — a baseline is the set of authoritative repetition rows for the incumbent content under matching conditions. Baseline-path rows additionally carry the v50 condition columns (timeout_s, host_cli, subject_model, input_hash, conditions_fp); input_hash and conditions_fp are never NULL on those rows, so pre-baseline rows are excluded from baseline matching by construction. Baselines are matched on (runner, target, input_hash, target_content_hash, conditions_fp) — never on head_sha (provenance only: the incumbent and the candidate share a HEAD in the meta-loop flow) and never on cell_key (both arms share it). The candidate rows a --compare-baseline run writes become the baseline for that content once accepted — the measurement is paid once (bidirectional store).
  • ll-sprint run uses exit_code = 1 for worker failure / abort paths and 130 for KeyboardInterrupt.

Caller implications: When scripting against ll-verify-* tools, parse the JSON envelope first and treat non-zero exit as "tool failed" (separate from "tool ran and found issues"). Do not assume 0 means "no problems found" — see the tool's documentation for the meaning of its exit codes.

Summary table — failure surfaces at a glance

Surface Failure mode Caller-visible signal
EventBus.emit() observer/transport exception none (logged at WARNING)
JsonlTransport IO / permission / encoder none (logged at WARNING)
UnixSocketTransport queue full / client disconnect get_stats() counters; rate-limited log
OTelTransport SDK missing / out-of-order RuntimeError (construction); warning (out-of-order)
WebhookTransport retry exhaustion none (logged at WARNING)
SQLiteTransport connection / write none (logged at WARNING); silent return on unrecognised event type
action_error event absent on_error on state loop terminates via loop_complete.terminated_by="error"; no action_error event
CLI tools (--json) tool failure non-zero exit code; JSON envelope errors[] populated

Reserved Event Names

Some event names are reserved ahead of a mechanism that will emit them, so that future implementations converge on one name rather than each inventing one. artifact_interaction was reserved this way and now has a real emitter — FSMExecutor._drain_inbound(), wired by ll-loop run --serve's LocalBridgeTransport (ENH-3351) — so it appears in the Quick Reference table below like any other emitted event; it is listed here too because the payload contract it must satisfy is defined by ARTIFACT_CONTROL_LEVELS.md, not by this file.

Event Reserved for Meaning
artifact_interaction Level-3 (host-owned) artifact re-entry — see ARTIFACT_CONTROL_LEVELS.md A user interaction with a rendered artifact that the FSM executor consumes directly, delivered unchanged to the executor's inbound channel. Carries artifact_id (str), level (str, one of "notify" | "ask-to-run-prompt" | "host-owned"), action (str), and optional payload (object), composing with the standard event/ts envelope.

Machine-Readable Schemas

Every emitted event type listed in this document has a corresponding JSON Schema (draft-07) file committed to docs/reference/schemas/, with one exception: artifact_interaction (ENH-3351) is emitted but has no generated schema file yet — its payload contract lives in ARTIFACT_CONTROL_LEVELS.md instead, since generate_schemas.py's registry hasn't been extended for it (a follow-up, not part of this issue). These files can be used for programmatic validation, IDE autocomplete, and external tooling.

docs/reference/schemas/
├── ab_summary.json
├── action_complete.json
├── action_error.json
├── action_output.json
├── action_start.json
├── baseline_complete.json
├── cost_ceiling_exceeded.json
├── cost_ceiling_unknown.json
├── cost_ceiling_warn.json
├── cycle_detected.json
├── evaluate.json
├── handoff_detected.json
├── handoff_spawned.json
├── human_approval_requested.json
├── human_approval_resolved.json
├── human_response.json
├── infra_retry.json
├── infra_retry_exhausted.json
├── issue_closed.json
├── issue_completed.json
├── issue_deferred.json
├── issue_failure_captured.json
├── issue_skipped.json
├── issue_started.json
├── learning_blocked.json
├── learning_complete.json
├── learning_explore_invoked.json
├── learning_target_proven.json
├── learning_target_refuted.json
├── learning_target_stale.json
├── loop_complete.json
├── loop_resume.json
├── loop_start.json
├── max_iterations_reached_summary.json
├── max_steps_summary.json
├── messages_append.json
├── parallel_epic_branch_stale.json
├── parallel_merge_completed.json
├── parallel_merge_started.json
├── parallel_queue_changed.json
├── parallel_worker_blocked.json
├── parallel_worker_completed.json
├── parallel_worker_started.json
├── parallel_worker_unblocked.json
├── prepatch_check_flagged.json
├── rate_limit_exhausted.json
├── prompt_size_warn.json
├── rate_limit_storm.json
├── rate_limit_waiting.json
├── retry_exhausted.json
├── route.json
├── stall_detected.json
├── state_enter.json
├── state_issue_completed.json
├── state_issue_failed.json
├── sub_loop_worktree_attached.json
├── sub_loop_worktree_detached.json
├── sub_loop_worktree_error.json
├── throttle_hard.json
├── throttle_stop.json
└── throttle_warn.json

Naming Convention

Event type identifiers map to filenames by replacing dots with underscores:

Event type Schema file
loop_start loop_start.json
issue.completed issue_completed.json
state.issue_completed state_issue_completed.json
parallel.worker_completed parallel_worker_completed.json
parallel.epic_branch_stale parallel_epic_branch_stale.json
parallel.worker_started parallel_worker_started.json
parallel.worker_blocked parallel_worker_blocked.json
parallel.worker_unblocked parallel_worker_unblocked.json
parallel.merge_started parallel_merge_started.json
parallel.merge_completed parallel_merge_completed.json
parallel.queue_changed parallel_queue_changed.json

Schema Format

Each file is a self-contained JSON Schema (draft-07) object. All schemas set "additionalProperties": true so forward-compatible extensions to event payloads do not break validation. Example (loop_start.json):

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "little-loops://event-loop_start.json",
  "title": "Loop Start",
  "description": "Emitted when an FSM loop begins execution.",
  "type": "object",
  "required": ["event", "ts", "loop"],
  "properties": {
    "event": { "type": "string", "description": "Event type identifier" },
    "ts":    { "type": "string", "format": "date-time", "description": "ISO 8601 timestamp" },
    "loop":  { "type": "string", "description": "Loop name" }
  },
  "additionalProperties": true
}

Programmatic Validation

Use the jsonschema library to validate event dicts against the generated files:

import json
import jsonschema
from pathlib import Path

schema = json.loads(Path("docs/reference/schemas/loop_start.json").read_text())
event = {"event": "loop_start", "ts": "2026-04-04T12:00:00Z", "loop": "my-loop"}
jsonschema.validate(event, schema)  # raises jsonschema.ValidationError on failure

To resolve a schema path from an event type at runtime:

def schema_path(event_type: str, base: Path) -> Path:
    return base / f"{event_type.replace('.', '_')}.json"

Regenerating

To regenerate all schema files after adding or modifying an event type, run:

ll-generate-schemas

See ll-generate-schemas in the CLI reference and the schema maintenance workflow in CONTRIBUTING.md.


Quick Reference

Event Namespace Source
artifact_interaction FSM fsm/executor.py (_drain_inbound, ENH-3351)
loop_start FSM fsm/executor.py
state_enter FSM fsm/executor.py
route FSM fsm/executor.py
action_start FSM fsm/executor.py
action_output FSM fsm/executor.py
action_complete FSM fsm/executor.py
action_error FSM fsm/executor.py
evaluate FSM fsm/executor.py
retry_exhausted FSM fsm/executor.py
infra_retry FSM fsm/executor.py
infra_retry_exhausted FSM fsm/executor.py
stall_detected FSM fsm/executor.py
rate_limit_exhausted FSM fsm/executor.py
rate_limit_storm FSM fsm/executor.py
rate_limit_waiting FSM fsm/executor.py
handoff_detected FSM fsm/executor.py
handoff_spawned FSM fsm/executor.py
loop_complete FSM fsm/executor.py
max_steps_summary FSM fsm/executor.py
max_iterations_reached_summary FSM fsm/executor.py
throttle_warn FSM fsm/executor.py
throttle_hard FSM fsm/executor.py
throttle_stop FSM fsm/executor.py
prompt_size_warn FSM fsm/executor.py
learning_target_proven FSM fsm/executor.py
learning_target_stale FSM fsm/executor.py
learning_explore_invoked FSM fsm/executor.py
learning_target_refuted FSM fsm/executor.py
learning_complete FSM fsm/executor.py
learning_blocked FSM fsm/executor.py
host_pressure FSM fsm/host_guard.py + fsm/executor.py
host_pressure_relieved FSM fsm/host_guard.py + fsm/executor.py
host_pressure_abort FSM fsm/host_guard.py + fsm/executor.py
host_cooldown FSM fsm/host_guard.py + fsm/executor.py
host_subproc_rss FSM fsm/host_guard.py + fsm/executor.py
host_budget_exceeded FSM fsm/host_guard.py + fsm/executor.py
request_path_downgrade FSM fsm/executor.py
sub_loop_worktree_attached FSM fsm/executor.py
sub_loop_worktree_detached FSM fsm/executor.py
sub_loop_worktree_error FSM fsm/executor.py
prepatch_check_flagged FSM fsm/executor.py
messages_append FSM fsm/executor.py
ab_comparison FSM fsm/executor.py
baseline_complete FSM fsm/executor.py
ab_summary FSM fsm/executor.py
api_error_retry FSM fsm/executor.py
api_error_exhausted FSM fsm/executor.py
cost_ceiling_unknown FSM fsm/executor.py
cost_ceiling_warn FSM fsm/executor.py
cost_ceiling_exceeded FSM fsm/executor.py
human_approval_requested FSM fsm/executor.py (_execute_human_approval_state, FEAT-1794)
human_approval_resolved FSM fsm/executor.py (_execute_human_approval_state, FEAT-1794)
human_response FSM fsm/executor.py (_drain_inbound, FEAT-3384)
loop_resume FSM Persistence fsm/persistence.py
state.issue_completed StateManager state.py
state.issue_failed StateManager state.py
state.issue_skipped StateManager state.py
issue.failure_captured Issue Lifecycle issue_lifecycle.py
issue.closed Issue Lifecycle issue_lifecycle.py
issue.completed Issue Lifecycle issue_lifecycle.py
issue.deferred Issue Lifecycle issue_lifecycle.py
issue.skipped Issue Lifecycle issue_lifecycle.py
issue.started Issue Lifecycle issue_lifecycle.py
parallel.worker_completed Parallel parallel/orchestrator.py
parallel.epic_branch_stale Parallel parallel/worker_pool.py
parallel.worker_started Parallel parallel/worker_pool.py
parallel.worker_blocked Parallel parallel/orchestrator.py
parallel.worker_unblocked Parallel parallel/orchestrator.py
parallel.merge_started Parallel parallel/merge_coordinator.py
parallel.merge_completed Parallel parallel/merge_coordinator.py
parallel.queue_changed Parallel parallel/priority_queue.py
state_change Socket seed (not on the EventBus) transport.py

OTel Transport Field Mapping

When OTelTransport is active (events.transports: ["otel"]), the following event fields are used to construct OpenTelemetry spans and span events. All other fields are serialized as span event attributes (str(value)).

Span-opening events

Event OTel action Field used
loop_start Opens root span (trace) loop_name (falls through to default "ll-loop" — real payload key is loop)
loop_resume Closes all open spans; opens new root span loop_name (falls through to default "ll-loop" — real payload key is loop)
state_enter Opens child span of loop span state → span name
action_start Opens grandchild span of state span action → span name

Span-closing events

Event OTel action Field used
action_complete Closes action span
loop_complete Closes state + action spans; sets ll.terminated_by / ll.final_status attributes; sets loop span status; closes loop span terminated_by and failure_terminalfsm.persistence.map_final_status() bucket → status code. session_store/writers.py uses the same mapping for the loop_events.state column (BUG-3066).

map_final_status bucket → OTel StatusCode:

map_final_status bucket OTel StatusCode
failed ERROR
timed_out ERROR
completed OK
interrupted UNSET
awaiting_continuation UNSET

Span event records

These events are added as OTel span events on the innermost open span (action > state > loop):

evaluate, route, retry_exhausted, cycle_detected, stall_detected, handoff_detected, handoff_spawned, action_output

All fields except "event" are included as span event attributes (string-coerced).

Sub-loop events

Events with depth > 0 are no-ops. A single WARNING is logged per OTelTransport session. Full nested-trace support is out of scope.