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:
| Key | Type | Description |
|---|---|---|
event |
str |
Event type identifier (see tables below) |
ts |
str |
ISO 8601 timestamp, UTC |
| (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. ReturnsLLHookResult(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.pre_compact_handoff— reads.ll/ll-precompact-state.jsonas an idempotency guard (exits0on skip if no state snapshot present); writes.ll/ll-continue-prompt.mdatomically. Returnsexit_code=2on success withfeedback="[ll] Session handoff snapshot written.",exit_code=0on idempotency skip (prompt already fresher thancompacted_at) or any error. Invoked viahooks/adapters/claude-code/precompact-handoff.sh.session_start— readstranscript_pathfrom the payload when available (Codex/OpenCode hosts; ENH-1945), falling back toPath.cwd()-based directory probing. ReturnsLLHookResult(exit_code=0, feedback=<stderr-lines>, stdout=<merged-config-json-or-None>).session_end— reads no payload keys; operates viapayload.cwdorevent.cwd, falling back toPath.cwd(). Handler reads done issue IDs viafind_issues(status_filter={"done"})and thehooks.stale_ref_fixkey from the raw config; outputs sweep findings inresult.feedback. Always exits0.
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_callback → EventBus.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.
| Field | Type | Description |
|---|---|---|
loop |
str |
Name of the FSM loop (from the loop YAML name field) |
Example:
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:
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:
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:
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:
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 |
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) |
Example (shell command):
{
"event": "action_complete",
"ts": "...",
"exit_code": 0,
"duration_ms": 1234,
"output_preview": "Build succeeded",
"is_prompt": false
}
Example (Claude prompt):
{
"event": "action_complete",
"ts": "...",
"exit_code": 0,
"duration_ms": 45000,
"output_preview": "I have completed the task...",
"is_prompt": true,
"session_jsonl": "/Users/user/.claude/projects/.../abc123.jsonl",
"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"
}
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):
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:
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:
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:
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.
| Field | Type | Description |
|---|---|---|
loop |
str |
Loop name whose interpolated action exceeded the threshold |
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": "...", "loop": "general-task", "state": "check_done", "size": 62000, "threshold": 50000, "est_tokens": 15500}
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:
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 |
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), 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.
| Field | Type | Required | Description |
|---|---|---|---|
loop |
str |
always | Name of the loop being resumed |
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):
Example (handoff resume):
{
"event": "loop_resume",
"ts": "...",
"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:
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"
}
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/completed/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/completed/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.Orchestrator
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 |
|---|---|---|
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 |
Example:
{
"event": "parallel.worker_completed",
"ts": "...",
"issue_id": "BUG-007",
"worker_name": "ll-worker-BUG-007-abc123",
"status": "success",
"duration_seconds": 142.7
}
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: scripts/little_loops/events.py:117-138 — EventBus.emit()
- Observer and transport exceptions are caught and logged.
logger.warning("EventBus observer raised an exception", exc_info=True)andlogger.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 wherefnmatch.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()exceptions are isolated per transport.scripts/little_loops/events.py:110-115catches and logs exceptions fromtransport.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: scripts/little_loops/transport.py:81-98
- No retry, no buffering, no rotation.
send()opens the file, writesjson.dumps(event) + "\n", and closes on every event. - Failures bubble to EventBus. Disk-full, permission-denied, or JSON-encoder exceptions are caught by the
EventBus.emitexcept-block (events.py:137-138) and become alogger.warning(...)line. They never reach the caller. close()is a no-op. The constructor creates the parent directory once; eachsend()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: scripts/little_loops/transport.py:115-320 · Constants at transport.py:47-54
- Full outbound queue → drop newest. If a client's outbound queue is full (
_CLIENT_QUEUE_MAXSIZE = 1024), the event is dropped anddropped_countis incremented. A rate-limitedWARNINGis logged at most once per_DROP_LOG_INTERVAL_SEC = 5.0seconds. - Connection cap exceeded → reject. When
max_clientsis reached, new connections are rejected and counted viaget_stats()["client_rejections"](transport.py:281-283). - AF_UNIX unavailable at startup →
RuntimeErrorfromwire_transports(). This is the one path in transport construction that propagates out — silently dropping a requested transport would be confusing. Oncewire_transports()returns, all subsequentsend()errors are caught byEventBus.emit. - Disconnected clients are removed from the pool. Per-client
sendallfailures are isolated in atry/except; they do not affect other clients or the FSM thread. - Accept-thread shutdown is bounded.
close()joins with_ACCEPT_THREAD_JOIN_TIMEOUT = 2.0and 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.
OTelTransport¶
Source: scripts/little_loops/transport.py:338-492
- Sub-loop events are no-ops. Events with
depth > 0(nested loop spans) are dropped after a one-time per-session warning (transport.py:385-395). Nested OTel tracing is intentionally out of scope. - Out-of-order events log a warning and skip span creation. A
state_enterwithout a priorloop_start, or anaction_startwithout a priorstate_enter, emits a warning and returns without creating a span. These warnings are the only signal that the span hierarchy is broken. close()blocks onforce_flush()+shutdown(). There is no engine-level timeout; rely on the OTel SDK's own deadlines.- Optional SDK import. The constructor raises
RuntimeErrorwith install guidance ifopentelemetry-sdkoropentelemetry-exporter-otlp-grpcare missing (transport.py:358-368). - 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: scripts/little_loops/transport.py:495-575 · Constants at transport.py:56-59
- HTTP 5xx and transport exceptions trigger exponential-backoff retry. Up to
max_retries=3retries (overridable). Backoff starts at_WEBHOOK_RETRY_BASE_S = 0.5and 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", ...)(transport.py:571-575) and the batch is discarded. Never raised to the caller. This is the documented "best-effort" guarantee. - Non-blocking
send(). Events enqueue on aQueue; a daemon thread drains and POSTs in batches every_WEBHOOK_BATCH_MS_DEFAULT = 1000ms. Queue overflow is not guarded — relies on consumer thread pacing. - Optional
httpx. The constructor raisesRuntimeErrorwith install guidance ifhttpxis missing (transport.py:513-518).
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: scripts/little_loops/session_store.py:1311-1430 (despite the path, this transport lives with the session store, not in transport.py).
- Connection failure at construction →
send()is a silent no-op forever. If the SQLite database cannot be opened,self._connstaysNoneandsend()returns early atsession_store.py:1343-1345. No error is raised to the caller. - Per-write failures are logged + swallowed.
session_store.py:1421-1422. Writes are serialized with athreading.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"})(session_store.py:133-145) plus theissue.*prefix. All other event types silentlyreturnwithout insert — there is no error envelope or warning. close()is best-effort and swallowssqlite3.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: scripts/little_loops/fsm/executor.py:1970-1983
- Emitted only when a state config defines
on_error. Ifon_erroris absent on the failing state, the exception re-raises and the top-level loop handler terminates the loop withloop_complete.terminated_by="error"and the message inloop_complete.error. Noaction_errorevent is emitted in that case — the loop just ends. - Payload schema:
{state, error, route: "on_error"}— same shape as arouteevent plus the originalerrorstring. - Ordering invariant:
action_erroris emitted after the action that failed but before the loop routes to the next state. Consumers can rely on this for sequencing (always pairaction_start↔ eitheraction_completeoraction_errorfrom the samestate).
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.
--jsonoutput with no events / no findings. Mostll-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 anullor 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 GNUtimeout(1)convention; used byll-action).130—KeyboardInterrupt(matches the conventional128 + SIGINTvalue).ll-harnessusesRunnerResult.exit_codewith a caller-supplied--exit-codethreshold (default2for timeout/exception markers,0for success).ll-sprint runusesexit_code = 1for worker failure / abort paths and130forKeyboardInterrupt.
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 |
Machine-Readable Schemas¶
Every event type listed in this document has a corresponding JSON Schema (draft-07) file committed to docs/reference/schemas/. These files can be used for programmatic validation, IDE autocomplete, and external tooling.
docs/reference/schemas/
├── action_complete.json
├── action_error.json
├── action_output.json
├── action_start.json
├── cycle_detected.json
├── evaluate.json
├── handoff_detected.json
├── handoff_spawned.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
├── parallel_worker_completed.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
├── 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 |
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:
See ll-generate-schemas in the CLI reference and the schema maintenance workflow in CONTRIBUTING.md.
Quick Reference¶
| Event | Namespace | Source |
|---|---|---|
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 |
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 |
loop_resume |
FSM Persistence | fsm/persistence.py |
state.issue_completed |
StateManager | state.py |
state.issue_failed |
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 |
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 loop span status; closes loop span | outcome → status code ("error" / "failed" / "exhausted" → ERROR, all others → OK) |
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.