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.jsonand 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. Gated by the SELFCOMPACT rubric (ENH-2341): whenhooks.pre_compact.rubric.enabledis set and atranscript_pathis available,handle()first evaluates the recent trajectory against the rubric's four conditions. If the trajectory fails, it returnsexit_code=0instead — no state file is written and no feedback is surfaced. The rubric is disabled by default, so the unconditionalexit_code=2path is what most projects see.pre_compact_handoff— reads.ll/ll-precompact-state.jsonas an idempotency guard, proceeding to write when no state snapshot is 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 one payload key,cwd(falling back toevent.cwd, thenPath.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.
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.sh → session_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_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.
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:
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 |
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):
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:
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:
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:
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:
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:
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 anab_comparisonevent for it. A consumer counting events will therefore see fewer than the number of items compared, and will never observe anerrorfield 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:
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:
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:
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_started — worker_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)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_transports()exceptions are isolated per transport.EventBus.close_transports()inscripts/little_loops/events.pycatches 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: JsonlTransport in scripts/little_loops/transport.py
- 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
EventBus.emit()'s except-block 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: 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 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"]. - 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.
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_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. - 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=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", ...)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.
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._connstaysNoneandsend()returns early. No error is raised to the caller. - Per-write failures are logged + swallowed (
writers.py'sSQLiteTransport.send()). 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"})(defined insession_store/schema.py, re-exported at thesession_storepackage root) 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: the action_error emit site in scripts/little_loops/fsm/executor.py
- 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).exit_code == 2alone does not distinguish a timeout from a runner error (bad target, MCP config, scope resolution) — both branches store2. OnlyRunnerResult.timed_outis persisted toharness_events;RunnerResult.errorhas no column.--retry-of's admissibility gate (ENH-3407) therefore reads the persistedtimed_outcolumn, notexit_code— a retry of a genuine timeout is admissible, a retry of any other2(or a graded1) is refused until a persisted runner-error signal exists. ENH-3415: a stochastic runner (skill/prompt) graded over N>1 samples writes oneharness_eventsrow per sample (attempt_kind='repetition') instead of one row per invocation —RunnerResult.exit_code/timed_outabove 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 authoritativerepetitionrows 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_hashandconditions_fpare 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 onhead_sha(provenance only: the incumbent and the candidate share a HEAD in the meta-loop flow) and never oncell_key(both arms share it). The candidate rows a--compare-baselinerun writes become the baseline for that content once accepted — the measurement is paid once (bidirectional store).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 |
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:
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_terminal → fsm.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.