Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Arquitectura de registro

ZeroClaw tiene exactamente una superficie de logging: la macro zeroclaw_log::record!. Cada emisión en el workspace, actividad del bucle del agente, E/S de canales, ejecuciones de cron, llamadas a herramientas, operaciones de memoria, ciclo de vida de sesiones, errores, fluye a través de ella. La macro dispara un evento de tracing que el subscriber instalado alimenta a dos capas hermanas: la capa fmt de stderr (salida de terminal) y la LogCaptureLayer. La capa fmt imprime líneas coloreadas con prefijo de alias en stderr (silenciadas a menos que se use --verbose). La LogCaptureLayer materializa un LogEvent estructurado y lo distribuye, a través de writer::record_event, a:

  1. El puente de Observer opcional (observer_bridge::forward) para el subconjunto de acciones que se asignan a eventos tipados de Prometheus / OTel, pero solo cuando un llamador ha instalado un enlace con set_observer_bridge. El arranque de producción actual no instala ninguno.
  2. El canal de difusión a nivel de proceso para suscriptores en tiempo real, como la secuencia SSE del panel.
  3. El escritor JSONL asíncrono para <workspace>/state/runtime-trace.jsonl (cuando [observability] log_persistence es "rolling", "full" o "rotating").

Cuando el puente Observer está enlazado, su proyección y el envío de difusión ocurren antes del intento de encolado de la persistencia. Esos tres destinos ofrecen garantías distintas de completitud y durabilidad; compartir un LogEvent no los hace intercambiables.

Lea esto primero: atribución no es attrs

Cada evento de log lleva dos canales completamente separados de datos estructurados. Confundirlos es el error más común en un punto de llamada, así que interioriza la separación antes que cualquier otra cosa:

Atribución (zeroclaw.*)Attrs (attributes.*)
RespuestasQuién lo hizo y en qué contexto¿Qué pasó específicamente
Ejemploschannel, agent_alias, model_provider, tool, session_key, cron_job_idbytes_received, tokens_used, status_code, cargas útiles de error
FuenteSpans. Se abren en los puntos de entrada y son recorridos por la capa.El sitio de llamada. Event::with_attrs(json!({...})).
¿Aparece en el sitio de llamada?Nunca. No es un argumento de record!.Sí, ese es el único lugar del que puede venir.

La regla que se deriva de esto: si un valor identifica a quién o a qué ámbito pertenece un evento, proviene de un span y nunca debe aparecer en el punto de llamada. La atribución fluye automáticamente desde los wrappers attribution_span! / scope! abiertos más arriba en la pila; la capa recorre el ámbito del span de hoja→raíz cuando se dispara un evento y combina cada contribución en el bloque zeroclaw.* del evento. El punto de llamada que dispara record! no nombra nada de esto.

Dado que la atribución es la mitad estructural de esta división y la mitad que confunde a la gente, va primero.

Atribución: todo proviene de los spans

La atribución nunca es un argumento en el punto de llamada. Léelo de nuevo. Channel composite, agent_alias, model_provider, tool, session_key, cron_job_id: ninguno de estos se escribe jamás en una llamada a record!. Fluyen a través de spans de tracing abiertos en los puntos de entrada y recorridos por la capa cuando se dispara un evento. Si te encuentras queriendo pasar agent_alias o tool a record!, detente: el valor ya está en el alcance a través de un span, o debería estarlo, y la solución es abrir o corregir el span, no propagar el valor hasta el punto de llamada.

El mecanismo, de principio a fin:

  1. Una “cosa” (canal, proveedor, agente, herramienta, tarea cron, backend de memoria, …) implementa Attributable una vez, junto a su struct.
  2. Su punto de entrada envuelve el trabajo en attribution_span!(self), que abre un span de tracing que lleva el rol y el alias de ese elemento.
  3. Cada record! disparado en cualquier lugar dentro de ese span, directamente o anidado a cualquier profundidad, hereda la atribución automáticamente.
  4. Cuando se dispara el evento, la capa recorre el ámbito del span de hoja a raíz, fusiona la contribución de cada Attributable y escribe el bloque zeroclaw.* fusionado. El sitio de llamada no nombró nada de esto.

Este es el objetivo principal del diseño: el código de logging por elemento es cero. Implementas el trait una vez y envuelves el punto de entrada una vez; cada emisión por debajo queda atribuida gratis.

El trait Attributable

Reside en crates/zeroclaw-api/src/attribution.rs para que cada crate pueda implementarlo sin depender de zeroclaw-log:

#![allow(unused)]
fn main() {
pub trait Attributable {
    fn role(&self) -> Role;
    fn alias(&self) -> &str;
}
}

Cada “elemento” del workspace (un TelegramChannel, un AnthropicModelProvider, un Agent, un cron job, una herramienta, un backend de memoria, un grupo de peers, un bundle de skills, un bundle de MCP, una sesión) implementa Attributable una vez junto a su struct.

La taxonomía de Role

Enum anidado cerrado:

#![allow(unused)]
fn main() {
pub enum Role {
    Swarm,
    Agent,
    Channel(ChannelKind),       // Telegram, Discord, Slack, Matrix, Lark, ...
    Tool(ToolKind),             // Shell, HttpRequest, FetchUrl, ...
    Cron(CronKind),             // Interval, At, Cron, Once
    Provider(ProviderKind),     // Model, Tts, Transcription, Tunnel
    Memory(MemoryKind),         // Sqlite, Json, InMemory, Markdown, Qdrant, ...
    PeerGroup,
    Skill,
    Mcp,
    Sop,
    Session,
    System,
}
}

ChannelKind, ToolKind, CronKind, MemoryKind y los cuatro subenumerados de ProviderKind (ModelProviderKind, TtsProviderKind, TranscriptionProviderKind, TunnelProviderKind) son todos cerrados. La forma snake_case de la variante mediante strum::IntoStaticStr es la porción canónica <type> del compuesto <type>.<alias>. Para agregar una nueva implementación: extiende el enumerado Kind correspondiente, eso es todo.

Abriendo un span, haz esto en cada punto de entrada

Envuelve el trabajo de un punto de entrada con attribution_span!(thing). La macro devuelve un Span que lleva el rol y el alias del elemento como campos estructurados. Aplica .instrument(span) al future (o let _g = span.entered() en código síncrono). Una tarea generada que no restablece el span pierde la atribución: cada cuerpo de tokio::spawn que emita debe llevar el mismo attribution_span! / scope! que usó el padre, o sus emisiones quedarán sin atribuir.

#![allow(unused)]
fn main() {
use zeroclaw_log::Instrument;

let span = zeroclaw_log::attribution_span!(self);  // self implementa Attributable
async move {
    // ¡cada record! lleva automáticamente los campos asociados al alias
    record!(INFO, Event::new(module_path!(), Action::Start), canal en línea);
    self.poll_loop().await
}.instrument(span).await
}

La capa recorre el ámbito del span de hoja→raíz cuando se dispara un evento, fusiona la contribución de cada Attributable en el bloque de atribución zeroclaw.* del evento, y emite el compuesto (channel = "telegram.clamps", channel_type = "telegram", channel_alias = "clamps") sin que el sitio de llamada nombre ninguna de esas claves.

La macro scope!, contexto sin rol

attribution_span! es para elementos Attributable con roles. Para identificadores por ámbito que no están vinculados a uno (sender id, message id, turn id, request id), use scope!:

#![allow(unused)]
fn main() {
zeroclaw_log::scope!(
    sender: msg.sender.as_str(),
    message_id: msg.id.as_str(),
    => async move { process_message(msg).await }
).await
}

scope! se sitúa deliberadamente a ambos lados de la línea entre atribución y attrs: las claves de campo que coinciden con ATTRIBUTION_FIELDS / COMPOSITE_PREFIXES vinculados por alias (en crates/zeroclaw-log/src/event.rs) terminan en el slot de atribución tipado zeroclaw.*; todo lo demás termina en el mapa attributes del evento para cada emisión descendiente. En cualquier caso, el valor viaja en cada record! anidado sin ser un argumento del punto de llamada.

La macro record! y su contrato en el punto de llamada

El crate tracing es un detalle de implementación de zeroclaw-log: las macros record! / scope! / attribution_span! se expanden a zeroclaw_log::__private::tracing, de modo que un sitio de llamada nunca nombra un tipo de tracing. Las propias macros de eventos de log (tracing::{trace,debug,info,warn,error}, log::*, std::dbg, además de anyhow::anyhow! directo) están estrictamente prohibidas en todo el workspace como disallowed-macros en clippy.toml. Con -D warnings en CI, cualquier uso directo de tracing::info!, etc., hace fallar la compilación, con un mensaje de clippy que indica ::zeroclaw_log::record! como reemplazo. Esto no es una convención; se aplica de forma obligatoria.

Las únicas exenciones son los pocos archivos dentro de crates/zeroclaw-log/ que inicializan el pipeline y llevan un #![allow(clippy::disallowed_macros)] local. Un puñado de crates (zeroclaw-api, zeroclaw-spawn, zeroclaw-providers, zeroclaw-hardware, zeroclaw-log) todavía lista tracing / tracing-subscriber en Cargo.toml, pero solo para la infraestructura de spans y subscribers, no para emitir macros de logging. Que la dependencia esté presente no autoriza llamar a las macros prohibidas. (tokio::spawn está prohibido de la misma manera mediante disallowed-methods; usa ::zeroclaw_spawn::spawn! para que las tareas generadas hereden el span de atribución del llamador.)

La macro es de forma fija: toma un nivel, una única expresión Event y un literal de mensaje.

#![allow(unused)]
fn main() {
use zeroclaw_log::{record, Event, Action, EventCategory, EventOutcome};

record!(INFO, Event::new(module_path!(), Action::Start), paso inicial);
record!(WARN, Event::new(module_path!(), Action::Fail).with_outcome(EventOutcome::Failure).with_attrs(serde_json::json!({"exit_code": 137})), error en la herramienta);
}

module_path!() es la fuente canónica del nombre del evento: es la ruta de módulo de Rust del punto de llamada (p. ej., zeroclaw_channels::telegram), por lo que los eventos son buscables, permiten saltar al código fuente y es imposible escribirlos mal. La misma convención se usa en cada punto de record! del workspace.

La macro inyecta file!() y line!() automáticamente. El LogCaptureLayer los adjunta al mapa attributes del evento como _file y _line para que los operadores salten al código fuente desde un visor de registros.

Contrato del punto de llamada

Cada llamada a record! es una sola línea de código que indica qué sucedió, no quién lo hizo ni en qué contexto.

  • El único argumento posicional después del nivel es una expresión Event.
  • El siguiente argumento es un literal de cadena para el mensaje legible por humanos.
  • Eso es todo. Channel, agent_alias, provider, tool, session_key, cron_job_id, model: ninguno de esos son argumentos del punto de llamada. Fluyen desde los spans (consulte Atribución: todo proviene de los spans).

La forma se impone mediante la estructura Event: los campos desconocidos son un error de compilación.

Cuando los atributos están justificados

Event::with_attrs(serde_json::json!({...})) es para mediciones por evento y datos ad-hoc que no existen en ningún lugar del ámbito circundante. Concretamente:

  • Mediciones por evento: bytes_received, tokens_used, retry_count, status_code, queue_depth.
  • Cargas de error cuando el error es el evento en sí: texto de cadena de anyhow, cuerpo de error HTTP, detalles de error de análisis.
  • Identificadores de sistemas externos: el request_id de una API remota, una cabecera de rastreo upstream.
  • Estado derivado capturado en este instante: número de solicitudes en curso, segundos de retry-after.

Los attrs NO son para nada que provenga del ámbito circundante: channel composite, agent_alias, model_provider, tool, session_key, cron_job_id, sender, message_id, etc. Esos pertenecen a un attribution_span! o scope! envolvente.

La regla de serde: pasa el valor en bruto, nunca format!("{}", v) o format!("{:?}", v). serde_json::json! serializa cadenas como cadenas, números como números, Vec<T> como arreglos, Option<T> como null o valor. Envuelve con .to_string() solo cuando el tipo no implementa impl Serialize (p. ej. anyhow::Error, reqwest::Error, std::io::Error, Path::Display, StatusCode).

Regla de marcador de posición

Los marcadores de posición en literales de cadena de Rust como "raw error body: {body}" están prohibidos dentro de los mensajes de record!. La captura implícita de cadenas de formato de Rust 2021 no se propaga a través de record!: cada {var} se convierte en una subcadena literal sin sustitución. La regla de conversión:

#![allow(unused)]
fn main() {
// BAD — {body} es un literal, nunca se interpola
record!(WARN, Event::new(module_path!(), Action::Fail), raw error body: {body});

// GOOD — cuerpo en attrs, el mensaje es texto plano
record!(WARN, Event::new(module_path!(), Action::Fail).with_attrs(serde_json::json!({"body": body})), cuerpo de error sin procesar);
}

Event, Action, EventOutcome, EventCategory

Las cuatro son enumeraciones cerradas definidas en crates/zeroclaw-log/src/event.rs. Agregar un valor es el único punto de cambio: los puntos de llamada no inventan cadenas.

  • Action: conjunto cerrado de verbos, en snake_case en disco mediante strum::IntoStaticStr: Start, Complete, Fail, Cancel, Skip, Timeout, Retry, Inbound, Outbound, Send, Receive, Connect, Disconnect, Reconnect, Spawn, Kill, Tick, Trigger, Schedule, Approve, Reject, Defer, Read, Write, Delete, List, Query, Invoke, Dispatch, Resolve, Register, Unregister, Load, Save, Migrate, Validate, Note.
  • EventOutcome: Success, Failure, Unknown. Unknown es el valor predeterminado y se omite en la serialización (se excluye del event.outcome en disco), por lo que una fila sin clave outcome es implícitamente Unknown.
  • EventCategory: Agent, Channel, Cron, Memory, Tool, Provider, Session, System, Internal. Derivado del span de rol más interno, a menos que se anule mediante Event::with_category(...).

Propagación de entrada/salida de herramientas

El ejecutor central de herramientas (crates/zeroclaw-runtime/src/agent/tool_execution.rs::execute_one_tool) envuelve cada llamada a Tool::execute(args) con eventos de invocación/finalización/fallo. El nombre de cada evento es module_path!() (el propio módulo del ejecutor), no una cadena codificada de forma fija; el Action y la severidad los distinguen:

  1. Antes de ejecutar: record!(DEBUG, Event::new(module_path!(), Action::Invoke).with_category(EventCategory::Tool).with_attrs(...)) con tool, tool_call_id y el input completo en attrs.
  2. Ejecuta execute(args).await.
  3. En caso de éxito (r.success): record!(DEBUG, ... Action::Complete) con Outcome::Success, la duración, y tool / tool_call_id / input / output en los attrs.
  4. En caso de fallo reportado por la herramienta (!r.success): record!(WARN, ... Action::Fail) con Outcome::Failure, la duración, y tool / tool_call_id / input / error / output en los attrs.
  5. En caso de Err de execute: record!(ERROR, ... Action::Fail) con Outcome::Failure, la duración y el error formateado con debug en los atributos.

Estos eventos se emiten dentro de un span estilo scope! (target = "zeroclaw_log_internal_scope", campo tool = <name>) que se abre alrededor de la llamada, por lo que el campo tool también acompaña a cada emisión descendiente. Las implementaciones de Tool::execute de cada herramienta no añaden ningún código de logging.

LogCaptureLayer y el esquema en disco

La capa en crates/zeroclaw-log/src/layer.rs es una Layer de tracing-subscriber que:

  1. En la creación/registro de un span con el target "zeroclaw_log_internal_attribution" (el target con el que abre la macro attribution_span!): analiza los campos role + alias en una instantánea de ZeroclawAttribution almacenada en las extensiones del span.
  2. Al crear/registrar un span con el destino "zeroclaw_log_internal_scope" (abierto con scope!): analiza los kvps ad-hoc y los almacena de forma similar.
  3. Al emitir un evento con target "zeroclaw_log_event" (el target a través del cual se dispara el macro record!): construye un LogEvent a partir del conjunto de campos zc_*, recorre el ámbito de spans de hoja→raíz fusionando cada snapshot de atribución que encuentra, parsea el blob JSON de zc_attrs en los attributes del evento, adjunta _file/_line desde la ubicación de origen capturada automáticamente, y entrega el evento final a writer::record_event, que lo distribuye en este orden:
    • Puente de Observer (observer_bridge.rs) para eventos tipados de Prometheus / OTel mapeados cuando se vincula un Observer.
    • Hook de difusión (broadcast.rs) para los suscriptores actuales de SSE/panel cuando se instala un emisor.
    • Persistencia de JSONL (writer.rs), ofrecida en último lugar a la cola de escritura asíncrona solo cuando log_persistence está habilitado.

La estructura JSON en disco (LogEvent en event.rs):

{
  "id": <uuid>,
  "@timestamp": 2026-05-16T10:08:59.002Z,
  "severity_number": 9,
  "severity_text": INFO,
  "event": { "category": canal, acción: inbound, resultado: "éxito" },
  servicio: { "nombre": zeroclaw, versión: "0.8.5" },
  "trace_id": <turn id>,
  "span_id": <sub-span id>,
  zeroclaw: {
    canal: telegram.clamps,
    "channel_type": "telegram",
    "channel_alias": clamps,
    agent_alias: clamps,
    "model_provider": anthropic.clamps,
    "model_provider_type": anthropic,
    "model_provider_alias": clamps,
    "modelo": claude-sonnet-4-6
  },
  "mensaje": mensaje entrante,
  atributos: { "sender": "...", "_file": "...", _line: 42 },
  "schema_version": 2
}

@timestamp es chrono::DateTime<Utc> serializado como RFC 3339 con Z. La versión del esquema es 2; las filas más antiguas con version: 1 se migran in situ al iniciar el daemon mediante migrate::migrate_legacy_jsonl_in_place.

Las superficies de entrega ofrecen garantías diferentes

writer::record_event construye una vez el valor persistido y, a continuación, deriva las demás entregas del mismo LogEvent. Cada destino tiene un contrato independiente:

DestinoPropietarioLímite del contrato y de la pérdida
Puente Observer tipado opcionalobserver_bridge.rsforward no hace nada hasta que se vincula explícitamente un Observer, y el arranque actual de producción no vincula ninguno. Cuando está vinculado, reenvía de forma síncrona, pero solo proyecta las acciones reconocidas por project; la asignación actual puede omitir acciones o campos predeterminados. Trátalo como una proyección selectiva de métricas/trazas, no como un registro completo de eventos, y consulta project para ver la asignación de campos actual.
Transmisión en directobroadcast.rs y su consumidorEnvía el evento estructurado a los suscriptores actuales dentro del proceso. Un suscriptor solo ve los eventos emitidos después de suscribirse, los receptores con búfer limitado pueden quedarse rezagados y el adaptador SSE de gateway omite las tramas atrasadas. Los atributos efímeros exclusivos de difusión pueden aparecer en una trama en vivo autenticada, pero se excluyen del JSONL persistido. Esta es una vía de notificaciones en vivo, no evidencia reproducible.
JSONL persistentewriter.rsEncola el evento serializado sin bloquear el tiempo de ejecución. La cola acotada puede descartar un evento cuando está llena, los errores de escritura del trabajador son advertencias y el sync_all periódico solo cubre el archivo activo actual. La rotación diaria antes de la primera adición de un nuevo día UTC y la rotación por tamaño después de una adición que supera el umbral pueden cambiar el nombre del archivo activo sin sincronizarlo primero, por lo que esta periodicidad no garantiza la durabilidad de un archivo que acaba de rotarse. El modo de persistencia determina entonces si el archivo activo se trunca, se conserva indefinidamente o se rota. Esto es un historial operativo de mejor esfuerzo, no un registro de auditoría transaccional.

No uses la salida de Observer ni la entrega mediante SSE para demostrar que se conservaron todos los eventos canónicos. A la inversa, no supongas que una fila ausente de JSONL nunca se emitió: puede haber llegado a la transmisión en directo y al puente de Observer, cuando estaba enlazado, antes de que la cola de persistencia la descartara o fallara.

Los cursores de lectura pertenecen a un único archivo activo

GET /api/logs resuelve la ruta activa actual del escritor y llama a reader::load_page. El lector analiza ese único archivo JSONL, conserva la ventana coincidente más reciente y devuelve los eventos del más reciente al más antiguo. No combina los archivos rotados.

El cursor de paginación principal es next_cursor_line_offset, el desplazamiento de bytes inmediatamente posterior al evento coincidente más antiguo de la página actual. El llamador lo devuelve como until_line_offset; el siguiente escaneo se detiene antes de esa línea y devuelve coincidencias más antiguas. Las adiciones puras conservan el prefijo al que apunta un cursor existente, por lo que los eventos posteriores no interrumpen un recorrido en curso.

El desplazamiento no es una identidad de evento persistente ni un punto de control entre archivos. Queda obsoleto cada vez que se reemplazan los bytes del archivo activo o cambia su ruta:

  • El recorte rolling transmite la cola conservada a un archivo temporal y lo renombra para reemplazar la ruta activa.
  • rotating cambia el nombre del archivo activo para archivarlo; la siguiente operación de anexado crea un nuevo archivo activo.
  • la migración del esquema reescribe el archivo activo mediante un archivo temporal y un renombrado atómico.
  • Una recarga de la configuración del demonio puede instalar una nueva ruta de persistencia.

Después de uno de esos límites, reinicia la paginación desde la página más reciente. Reutilizar el número antiguo puede duplicar, omitir o devolver filas no relacionadas porque la API no asocia al cursor la identidad del archivo ni los metadatos de generación. El cursor heredado de marca de tiempo/ID se mantiene por compatibilidad, pero está obsoleto en #8012 porque la ordenación lexicográfica de los ID puede omitir eventos empatados.

La política de persistencia gestiona las reescrituras y la retención

StoragePolicy en config.rs controla únicamente el destino JSONL. La entrega de Observer y la difusión siguen siendo independientes de esta.

PolíticaComportamiento del archivo activoResponsable de retención
noneNo se realizarán nuevas escrituras en JSONL.Ninguno.
rollingDespués de que una operación de anexado supere max_entries, transmite solo las líneas no vacías más recientes a un archivo temporal y renómbralo para sobrescribir el archivo activo.El escritor mantiene el tamaño configurado de la ventana activa. No crea archivos archivados y deja sin gestionar los archivos archivados de una configuración rotating anterior.
fullAñadir al final sin recorte ni rotación gestionados por el escritor.El operador se encarga del crecimiento de los archivos y de cualquier rotación externa.
rotatingAntes de la primera anexión de un nuevo día UTC, o después de que una anexión alcance el umbral de bytes, renombra el archivo activo como un archivo histórico con marca de tiempo.Después de cada rotación correcta, el escritor depura los archivos coincidentes primero por antigüedad y luego por cantidad. La eliminación se realiza como mejor esfuerzo y nunca provoca el fallo de la operación de anexado que la contiene.

La retención por antigüedad y cantidad se ejecuta únicamente después de la rotación. No realiza barridos continuos, no se aplica a full ni a rolling, y no elimina archivos vecinos arbitrarios: la detección de archivos archivados solo acepta nombres generados según el formato de archivo con marca de tiempo de la ruta activa. El lector activo de /api/logs sigue viendo únicamente el archivo activo; los archivos archivados son artefactos de diagnóstico sin conexión.

La migración del esquema es una reescritura del archivo activo

Cuando la persistencia está habilitada y la ruta activa existe, writer::init_from_config ejecuta migrate::migrate_legacy_jsonl_in_place antes de iniciar el trabajador de disco. El migrador transmite los registros no vacíos a través de un archivo temporal, convierte los registros heredados con timestamp pero sin @timestamp, conserva los registros que ya están actualizados, omite el JSON mal formado con una advertencia, sincroniza el archivo temporal y le cambia el nombre atómicamente para reemplazar la ruta activa.

La migración se realiza con el mejor esfuerzo. Su comprobación de esquema de bajo coste se detiene en la primera fila no vacía. La migración se ejecuta cuando esa fila está mal formada o contiene timestamp sin @timestamp; cualquier otro JSON analizable se trata como actual, incluso si tiene un esquema desconocido o no válido, por lo que las filas heredadas posteriores pueden quedar sin migrar. Si la migración devuelve un error, la inicialización emite una advertencia y continúa, por lo que las adiciones posteriores de v2 pueden coexistir con filas antiguas que el lector de v2 no puede deserializar. Los archivos de archivo rotados no se migran.

LogEvent es la fuente de verdad del esquema. Para cada cambio de esquema, evalúa la compatibilidad de la migración, la deserialización del archivo activo, la serialización y los consumidores de HTTP y RPC, así como la documentación de arquitectura y de operadores; actualiza únicamente los límites cuyo comportamiento o compatibilidad cambie. Las interfaces de registro de RPC se encuentran en crates/zeroclaw-runtime/src/rpc/types.rs y dispatch.rs. Una migración que reemplaza el archivo activo invalida los cursores de desplazamiento de bytes, mientras que un cambio aditivo compatible que no reescribe los bytes existentes no lo hace.

LogConfig vs ObservabilityConfig

zeroclaw-log define su propio LogConfig mínimo (en crates/zeroclaw-log/src/config.rs): log_persistence, log_persistence_path, log_persistence_max_entries, log_persistence_max_bytes, log_persistence_rotate_daily, log_persistence_retention_max_files, log_persistence_retention_max_age_days, log_tool_io, log_tool_io_truncate_bytes, log_tool_io_denylist. Esto rompe lo que, de otro modo, sería un ciclo de dependencias: zeroclaw-config::ObservabilityConfig lleva el esquema completo (con deserialización y validación TOML), y el runtime lo convierte a LogConfig al iniciar y después de recargar la configuración del daemon mediante crates/zeroclaw-runtime/src/observability/runtime_trace.rs::to_log_config. El resultado: zeroclaw-config puede record! sin invertir el árbol de dependencias, mientras que los cambios en la persistencia de logs y en la política de rotación siguen surtiendo efecto en la siguiente recarga del daemon.

Instalación del suscriptor

El daemon instala el suscriptor global mediante:

#![allow(unused)]
fn main() {
zeroclaw_log::install_global_subscriber(
    recording_filter.as_deref(),   // Option<&str> — el flag --log-level, si está establecido
    &default_filter,               // &str — filtro de respaldo cuando no hay flag ni RUST_LOG
    cli.verbose,                   // bool — controla la capa fmt (terminal) de stderr
);
}

Dos ejes independientes: el piso de registro (lo que llega a LogCaptureLayer, resuelto como flag → RUST_LOG → valor predeterminado) y la visualización en terminal (la capa fmt de stderr, completamente silenciada a menos que verbose sea true). Esa única llamada configura el formateador de terminal con prefijo de alias de agente + el LogCaptureLayer sobre un tracing-subscriber::Registry. src/main.rs es el único lugar que la llama. Las pruebas usan zeroclaw_log::try_install_capture_subscriber() + zeroclaw_log::subscribe_or_install() para drenar los eventos emitidos a través del hook de broadcast sin que se nombre ningún tipo de tracing en el crate de pruebas.

Cuándo extender las enumeraciones cerradas

  • Nueva implementación de canal: agrega una variante a ChannelKind. La forma snake_case es la cadena channel_type almacenada en disco. Agrega #[strum(serialize = "...")] solo cuando el nombre de la variante no se convierte en snake-case al valor deseado (p. ej., OpenAi"openai").
  • Nueva implementación de herramienta (integrada en el workspace): agregar a ToolKind.
  • Nueva forma de programación de cron: añadir a CronKind.
  • Nuevo proveedor de modelo / TTS / transcripción / túnel: agregar al subenum *ProviderKind correspondiente bajo ProviderKind.
  • Nuevo backend de memoria: añadir a MemoryKind.
  • Familia Role completamente nueva (PeerGroup / Skill / Mcp obtienen subtipos): se anida con su propio Kind sobre la marcha: el patrón es uniforme.

Luego añade impl Attributable for X junto a la nueva estructura (fn role() -> Role::Family(Kind::Variant), fn alias() -> &str { &self.alias }) y envuelve su punto de entrada con attribution_span!(self). La capa recoge todo lo demás automáticamente.

Preocupaciones del operador

Para los parámetros de configuración (log_persistence, log_tool_io, exportación de OTel) y la sintaxis de consultas, consulta Logs y observabilidad.