fabro/docs-internal/events-strategy.md
Bryan Helmkamp 24532c2385 Fold CheckpointSaved into CheckpointCompleted
Remove the separate CheckpointSaved event — CheckpointCompleted now fires in
both git and non-git paths. git_commit_sha is Optional (None when git is
disabled or for start nodes). The CheckpointSaved hook event is preserved
unchanged for backward compat with user hook configs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:43:09 -04:00

306 lines
14 KiB
Markdown

# Fabro Events Strategy
Fabro emits structured **workflow run events** during execution for observability. Events write to `progress.jsonl` (one JSON object per line) and `live.json` (latest event snapshot) inside the run's log directory. Events are the primary record of what happened during a run — they feed the retro system, CLI verbose output, and live monitoring.
Events are distinct from tracing logs (see `logging-strategy.md`). Tracing is developer diagnostics; events are the structured audit trail consumed by tooling.
## Architecture
```
Engine/Handler → WorkflowRunEvent enum → EventEmitter
├─ .trace() → tracing log line (automatic)
├─ flatten_event() → progress.jsonl + live.json
└─ on_event() callbacks → CLI output, cost tracking, etc.
```
**Three layers:**
1. **Rust enum** (`WorkflowRunEvent` in `event.rs`) — the source of truth for event structure. Variants use Rust naming and types. `AgentEvent` and `SandboxEvent` from `fabro-agent` are wrapped as `Agent { stage, event }` and `Sandbox { event }`.
2. **Flattening** (`flatten_event()` in `event.rs`) — serializes the enum via serde, then restructures nested/tagged variants into `(event_name, flat_fields_map)`. Wrapper variants use dot notation: `Agent.ToolCallStarted`, `Sandbox.Initializing`.
3. **Field renaming** (`rename_fields()` in `event.rs`) — post-processes the flat fields to give them self-describing names for JSONL output. This avoids changing the Rust enum while making the external format unambiguous.
## JSONL Envelope
Every line in `progress.jsonl` has three envelope fields, then the event's own fields merged at the top level:
```json
{"ts":"2025-06-15T12:00:00.123Z","run_id":"01J...","event":"StageCompleted","node_id":"plan","node_label":"Plan","stage_index":0,"duration_ms":5000,"status":"success",...}
```
| Envelope field | Type | Description |
|---|---|---|
| `ts` | ISO 8601 string | UTC timestamp with millisecond precision |
| `run_id` | string | ULID for this workflow run |
| `event` | string | Event name (matches Rust variant, dot-separated for wrapped types) |
The envelope is built in `cli/run.rs`. Field names from the event that collide with envelope keys (`ts`, `run_id`, `event`) are dropped — the `run_id` from `WorkflowRunStarted` populates the envelope itself.
## Node Terminology
- **`node_id`** — programmatic identifier (the id from the DOT graph). Stable, used for matching.
- **`node_label`** — display name (from the DOT `label` attribute, defaults to `node_id`). Human-readable.
Every event that references a graph node should include both. Stage events carry both from the Rust enum. Events that only have an id (Agent, ParallelBranch, etc.) get `node_label` defaulted to `node_id` by `rename_fields()`.
## Field Naming Conventions
### Rules
1. **Self-describing** — a field name should be unambiguous without knowing the event type. Use `node_id` not `name`, `stage_index` not `index`, `sandbox_provider` not `provider`.
2. **`_id` suffix** for identifiers — `node_id`, `from_node_id`, `to_node_id`, `start_node_id`, `tool_call_id`, `agent_id`.
3. **`_ms` suffix** for durations — `duration_ms`, `delay_ms`. Always milliseconds.
4. **`_count` suffix** for counts — `branch_count`, `command_count`, `tool_call_count`.
5. **No prefix** for fields that are already unambiguous — `error`, `status`, `command`, `question`, `answer`, `model`.
6. **`snake_case`** for all field names.
### Rename Table (Rust enum → JSONL)
The Rust enum uses short field names for ergonomics. `rename_fields()` transforms them for the JSONL output:
| Rust field | JSONL field | Events | Reason |
|---|---|---|---|
| `name` | `workflow_name` | WorkflowRunStarted | Disambiguate |
| `name` | `node_label` | Stage* | Display name |
| `name` | `snapshot_name` | Sandbox.Snapshot* | Disambiguate |
| `index` | `stage_index` | Stage* | Disambiguate |
| `index` | `branch_index` | ParallelBranch* | Disambiguate |
| `index` | `command_index` | SetupCommand*, SetupFailed, DevcontainerLifecycleCommand*, DevcontainerLifecycleFailed | Disambiguate |
| `stage` | `node_id` | Agent.*, Interview*, Prompt | Unify terminology |
| `branch` | `node_id` | ParallelBranch* | Consistent |
| `node` | `node_id` | StallWatchdogTimeout | Consistent |
| `from_node` | `from_node_id` | EdgeSelected, LoopRestart | `_id` suffix |
| `to_node` | `to_node_id` | EdgeSelected, LoopRestart | `_id` suffix |
| `start_node` | `start_node_id` | SubgraphStarted | `_id` suffix |
| `provider` | `sandbox_provider` | Sandbox.* | Disambiguate |
| `text` | `prompt_text` | Prompt | Disambiguate |
| _(inserted)_ | `node_label` | Agent.*, ParallelBranch*, etc. | Defaults to `node_id` |
Fields not in this table pass through unchanged.
## Adding a New Event
### Step 1: Add to the Rust enum
Add a variant to `WorkflowRunEvent` in `event.rs`. Use the short Rust field names (they'll be renamed in step 3).
```rust
MyNewEvent {
node_id: String, // if it references a graph node
name: String, // if it has a display label (will become node_label)
duration_ms: u64,
// ...
},
```
For events that wrap `AgentEvent` or `SandboxEvent`, add the variant to those enums in `fabro-agent` instead — they're automatically wrapped by the existing `Agent { stage, event }` and `Sandbox { event }` variants.
### Step 2: Add a trace() match arm
Add a match arm in `WorkflowRunEvent::trace()`. Choose the tracing level per `logging-strategy.md`:
- INFO for lifecycle boundaries (started/completed at the workflow level)
- DEBUG for individual steps (stage started, tool call, etc.)
- WARN for retries and degraded behavior
- ERROR for terminal failures
```rust
Self::MyNewEvent { node_id, duration_ms, .. } => {
debug!(node_id, duration_ms, "My new event happened");
}
```
### Step 3: Add rename rules (if needed)
If your event has fields that need renaming (ambiguous `name`, `index`, `stage`, etc.), add a branch in `rename_fields()` in `event.rs`. If your event references a graph node and only has `node_id`, call `default_node_label(fields)` to insert `node_label`.
### Step 4: Emit from engine or handler
Emit via the `EventEmitter`:
```rust
self.services.emitter.emit(&WorkflowRunEvent::MyNewEvent {
node_id: node.id.clone(),
name: node.label().to_string(),
duration_ms: elapsed,
});
```
### Step 5: Update format_event_summary
Add a match arm in `format_event_summary()` in `cli/mod.rs` for `-v` verbose output:
```rust
WorkflowRunEvent::MyNewEvent { node_id, duration_ms, .. } => {
format!("[MY_NEW_EVENT] node_id={node_id} duration={duration_ms}ms")
}
```
### Step 6: Update tests
- Add a serialization test in `event.rs` (serde round-trip)
- Add a `rename_fields` test if you added rename rules
- Update integration test patterns in `integration.rs` if matching on the new event
## Complete Event Reference
### Workflow lifecycle
| Event | JSONL fields |
|---|---|
| `WorkflowRunStarted` | `workflow_name`, `run_id`, `base_sha`?, `run_branch`?, `worktree_dir`? |
| `WorkflowRunCompleted` | `duration_ms`, `artifact_count`, `total_cost`?, `final_git_commit_sha`? |
| `WorkflowRunFailed` | `error`, `duration_ms`, `git_commit_sha`? |
### Stage execution
| Event | JSONL fields |
|---|---|
| `StageStarted` | `node_id`, `node_label`, `stage_index`, `handler_type`?, `attempt`, `max_attempts` |
| `StageCompleted` | `node_id`, `node_label`, `stage_index`, `duration_ms`, `status`, `preferred_label`?, `suggested_next_ids`, `usage`?, `failure_reason`?, `notes`?, `files_touched`, `attempt`, `max_attempts`, `failure_class`? |
| `StageFailed` | `node_id`, `node_label`, `stage_index`, `error`, `will_retry`, `failure_reason`?, `failure_class`? |
| `StageRetrying` | `node_id`, `node_label`, `stage_index`, `attempt`, `max_attempts`, `delay_ms` |
### Parallel execution
| Event | JSONL fields |
|---|---|
| `ParallelStarted` | `branch_count`, `join_policy`, `error_policy` |
| `ParallelBranchStarted` | `node_id`, `node_label`, `branch_index` |
| `ParallelBranchCompleted` | `node_id`, `node_label`, `branch_index`, `duration_ms`, `status` |
| `ParallelCompleted` | `duration_ms`, `success_count`, `failure_count` |
| `ParallelEarlyTermination` | `reason`, `completed_count`, `pending_count` |
### Graph navigation
| Event | JSONL fields |
|---|---|
| `EdgeSelected` | `from_node_id`, `to_node_id`, `label`?, `condition`? |
| `LoopRestart` | `from_node_id`, `to_node_id` |
| `SubgraphStarted` | `node_id`, `node_label`, `start_node_id` |
| `SubgraphCompleted` | `node_id`, `node_label`, `steps_executed`, `status`, `duration_ms` |
### Checkpoints and git
| Event | JSONL fields |
|---|---|
| `CheckpointCompleted` | `node_id`, `node_label`, `status`, `git_commit_sha` (optional) |
| `CheckpointFailed` | `node_id`, `node_label`, `error` |
| `GitCommit` | `node_id` (optional), `node_label` (optional), `sha` |
| `GitPush` | `branch`, `success` |
| `GitBranch` | `branch`, `sha` |
| `GitWorktreeAdd` | `path`, `branch` |
| `GitWorktreeRemove` | `path` |
| `GitFetch` | `branch`, `success` |
| `GitReset` | `sha` |
### Human interaction
| Event | JSONL fields |
|---|---|
| `InterviewStarted` | `question`, `node_id`, `node_label`, `question_type` |
| `InterviewCompleted` | `question`, `answer`, `duration_ms` |
| `InterviewTimeout` | `question`, `node_id`, `node_label`, `duration_ms` |
| `Prompt` | `node_id`, `node_label`, `prompt_text` |
### Setup
| Event | JSONL fields |
|---|---|
| `SetupStarted` | `command_count` |
| `SetupCommandStarted` | `command`, `command_index` |
| `SetupCommandCompleted` | `command`, `command_index`, `exit_code`, `duration_ms` |
| `SetupCompleted` | `duration_ms` |
| `SetupFailed` | `command`, `command_index`, `exit_code`, `stderr` |
### Devcontainer
| Event | JSONL fields |
|---|---|
| `DevcontainerResolved` | `dockerfile_lines`, `environment_count`, `lifecycle_command_count`, `workspace_folder` |
| `DevcontainerLifecycleStarted` | `phase`, `command_count` |
| `DevcontainerLifecycleCommandStarted` | `phase`, `command`, `command_index` |
| `DevcontainerLifecycleCommandCompleted` | `phase`, `command`, `command_index`, `exit_code`, `duration_ms` |
| `DevcontainerLifecycleCompleted` | `phase`, `duration_ms` |
| `DevcontainerLifecycleFailed` | `phase`, `command`, `command_index`, `exit_code`, `stderr` |
### Stall detection
| Event | JSONL fields |
|---|---|
| `StallWatchdogTimeout` | `node_id`, `node_label`, `idle_seconds` |
### Agent events (prefixed `Agent.`)
All agent events include `node_id` and `node_label`.
| Event | Additional JSONL fields |
|---|---|
| `Agent.SessionStarted` | _(none)_ |
| `Agent.SessionEnded` | _(none)_ |
| `Agent.UserInput` | `text` |
| `Agent.AssistantTextStart` | _(none)_ |
| `Agent.AssistantMessage` | `text`, `model`, `usage` (object), `tool_call_count` |
| `Agent.TextDelta` | `delta` |
| `Agent.ToolCallStarted` | `tool_name`, `tool_call_id`, `arguments` |
| `Agent.ToolCallOutputDelta` | `delta` |
| `Agent.ToolCallCompleted` | `tool_name`, `tool_call_id`, `output`, `is_error` |
| `Agent.Error` | `error` |
| `Agent.ContextWindowWarning` | `estimated_tokens`, `context_window_size`, `usage_percent` |
| `Agent.LoopDetected` | _(none)_ |
| `Agent.TurnLimitReached` | `max_turns` |
| `Agent.SkillExpanded` | `skill_name` |
| `Agent.SteeringInjected` | `text` |
| `Agent.CompactionStarted` | `estimated_tokens`, `context_window_size` |
| `Agent.CompactionCompleted` | `original_turn_count`, `preserved_turn_count`, `summary_token_estimate`, `tracked_file_count` |
| `Agent.LlmRetry` | `provider`, `model`, `attempt`, `delay_secs`, `error` |
| `Agent.SubAgentSpawned` | `agent_id`, `depth`, `task` |
| `Agent.SubAgentCompleted` | `agent_id`, `depth`, `success`, `turns_used` |
| `Agent.SubAgentFailed` | `agent_id`, `depth`, `error` |
| `Agent.SubAgentClosed` | `agent_id`, `depth` |
| `Agent.SubAgentEvent.*` | `agent_id`, `depth`, `nested_event` (JSON) |
| `Agent.McpServerReady` | `server_name`, `tool_count` |
| `Agent.McpServerFailed` | `server_name`, `error` |
### Sandbox events (prefixed `Sandbox.`)
| Event | JSONL fields |
|---|---|
| `Sandbox.Initializing` | `sandbox_provider` |
| `Sandbox.Ready` | `sandbox_provider`, `duration_ms` |
| `Sandbox.InitializeFailed` | `sandbox_provider`, `error`, `duration_ms` |
| `Sandbox.CleanupStarted` | `sandbox_provider` |
| `Sandbox.CleanupCompleted` | `sandbox_provider`, `duration_ms` |
| `Sandbox.CleanupFailed` | `sandbox_provider`, `error` |
| `Sandbox.SnapshotPulling` | `snapshot_name` |
| `Sandbox.SnapshotPulled` | `snapshot_name`, `duration_ms` |
| `Sandbox.SnapshotEnsuring` | `snapshot_name` |
| `Sandbox.SnapshotCreating` | `snapshot_name` |
| `Sandbox.SnapshotReady` | `snapshot_name`, `duration_ms` |
| `Sandbox.SnapshotFailed` | `snapshot_name`, `error` |
| `Sandbox.GitCloneStarted` | `url`, `branch` |
| `Sandbox.GitCloneCompleted` | `url`, `duration_ms` |
| `Sandbox.GitCloneFailed` | `url`, `error` |
## Error Fields
Error information is stored as plain strings. The `error` field contains the human-readable message; `failure_class` contains the machine-readable classification.
| Field | Type | Events | Purpose |
|---|---|---|---|
| `error` | string | StageFailed, WorkflowRunFailed, Agent.Error, Agent.LlmRetry, Sandbox.*Failed, etc. | Human-readable error message |
| `failure_reason` | string? | StageFailed, StageCompleted | Outcome-level failure description |
| `failure_class` | string? | StageFailed, StageCompleted | Machine classification: `transient_infra`, `deterministic`, `budget_exhausted`, `compilation_loop`, `canceled`, `structural` |
`failure_class` is derived from `FabroError::failure_class()` for handler errors, or from handler hints in `context_updates["failure_class"]` for outcome-based failures. See `error.rs` for the classification logic.
## Consumers
| Consumer | Reads | Purpose |
|---|---|---|
| `retro.rs` `extract_stage_durations()` | `node_label`, `duration_ms` from `StageCompleted` | Build retro report |
| `cli/run.rs` non-verbose listener | `name`, `duration_ms`, `status`, `usage` from `StageCompleted/Failed` | CLI progress output |
| `cli/mod.rs` `format_event_summary()` | All events | `-v` verbose output |
| `cli/run.rs` cost accumulator | `usage` from `StageCompleted` | Total cost tracking |
| `cli/run.rs` git SHA tracker | `git_commit_sha` from `CheckpointCompleted` | Final SHA for `conclusion.json` |
| External tooling | `progress.jsonl` | Live monitoring, dashboards |