Session State Awareness: Signals, Watchers, and Reconciliation
Learn how OpenClaw detects interventions in shared sessions and alerts watchers once, with durable signal logs and efficient reconciliation via session_status changesSince.
Read this when
- You want agents to notice when humans or other agents change a session behind their back
- You are debugging state-change notices, watch cursors, or session_status changesSince
- You want to understand how parent agents stay synchronized with child sessions
When multiple sessions tackle the same issue, such as a manager handing work to child sessions, a person stepping directly into a worker session, or two agents coordinating through sessions_send, each session forms expectations about the others. Those expectations turn outdated the instant another actor steps in. Session state awareness is the mechanism that spots that intervention, alerts the impacted session exactly once, and offers it an inexpensive path to sync up before proceeding.
The system relies on three components:
- A durable signal log that stores chosen state changes for every session.
- Watchers that maintain per-target cursors and get a single merged stale-state alert.
- Reconciliation that retrieves the precise difference using
session_statuswithchangesSince.
The signal log
Whenever a watched session undergoes a meaningful change, OpenClaw writes a typed event into the shared state database (session_state_events). Each event includes metadata and a short description, never the message body itself.
| Kind | Recorded when | Notifies watchers |
|---|---|---|
human_direct_message | A human dispatches a turn directly to a watched session | Yes |
upstream_missing | An adopted session loses its upstream source | Yes |
goal_changed | The session's goal state is set, modified, or removed | Yes |
child_spawned | A sub-agent or ACP child session comes into existence | No (seeds cursor) |
run_completed | A child run wraps up successfully | No (log only) |
run_failed | A child run fails, exceeds its time limit, or is halted | No (log only) |
compacted | The session's history undergoes compaction | No (log only) |
adopted | A catalog session gets adopted into OpenClaw | No (log only) |
Each event identifies its source actor (human, agent, or system). Child runs that are cancelled or hit their timeout get logged as failures, with the exact outcome (cancelled, timeout, or error) stored in the event payload.
A session's state version is just the largest sequence number found in its log, maintained in a durable per-session head that endures pruning. sessions_list rows include stateVersion when a session has recorded changes; session_status always reports it.
Log-only kinds exist to support reconciliation history, not to trigger alerts: standard delivery of child-run completion stays under sub-agent announcements, and the signal log never repeats it.
Watchers
A watcher is a session that keeps a cursor (session_watch_cursors) on a target. Cursors originate from three sources:
- Implicit (spawn edges). When a session launches a sub-agent or ACP child, the parent's cursor gets seeded automatically at the child's spawn version. Parents never subscribe by hand.
- Ambient groups. With
session.groupScope: "per-group", the agent's main session watches its isolated group, room, and channel sessions once they receive their first human turn. This operates independently ofsession.dmScope; routing a room into main requires no watch since it already shares the main conversation. - Explicit (
sessions_send watch: true). Any coordinator can watch a target that was not spawned: supplywatch: trueonsessions_send, and once the send dispatches successfully, the sender becomes a watcher of the session that actually received the message. Registration begins at the target's current state version, so earlier history never generates notices. The tool result reportswatched: true|falsewhen the parameter was set.
Watcher identity must be an agent-qualified session key. Under session.scope="global" the shared global key is ambiguous across agents, so those sessions receive the durable log and changesSince but no proactive alerts.
Watches tidy up on their own: cursor rows expire alongside signal-log retention, get removed when the watcher session resets, and are deleted when either session disappears. Version 1 has no unwatch command.
Adopted Claude, Codex, OpenCode, and Pi sessions pulled from a session catalog are scanned for direct upstream human activity on a set interval. Pi monitoring begins only after the session reaches its append-only v3 format. Detected activity flows into the same signal log and watcher pipeline as other direct human turns.
OpenCode detection errs on the side of caution. Its v1 tables lack message provenance, so flagging ambiguous rows would produce false positives; per-message provenance appears only in the v2 schema. Consequently, OpenCode skips image-only turns, @file-mention-only turns, slash commands sent to a subagent, and turns from ACP clients that tag content with an audience (which OpenCode maps to synthetic or ignored). It also filters out text matching any of the previous 50 user messages to guard against compaction replay, meaning a human intentionally repeating the same text inside that window can go unnoticed.
If an adopted session's upstream source gets removed externally, three consecutive missed checks (roughly three monitor ticks) yield one upstream_missing signal for its watchers and drop the upstream link. Resuming the catalog session later creates a fresh link.
Notices: one, not many
When a notify-eligible event arrives and a watcher's cursor lags behind, the watcher gets a single system notice on its next turn:
Session "agent:main:subagent:child" changed (other actor). Reconcile before acting: session_status sessionKey "agent:main:subagent:child" changesSince 12.
Main-session watchers also receive an immediate heartbeat wake; nested sub-agent watchers see the notice on their next turn.
The protocol deliberately resists spam:
- One pending notice per watcher/target pair. The notice text stays byte-stable while pending, and the system-event queue dedupes on it, so twenty rapid changes to the same target still produce just one line in the watcher's prompt.
- Frozen watermark. The cursor locks its notified position when a notice is queued. Additional material events advance only the material watermark; they do not trigger another notice.
- Acknowledge on drain, reopen only for interleaved work. When the watcher's turn consumes the notice, the cursor advances. If more material events arrived between queueing and draining, exactly one fresh notice opens for the remainder.
- Self-suppression. A watcher never receives notices about events it caused itself.
- Restart recovery. Pending notices live in an in-memory queue; a startup sweep re-materializes them from durable cursors after a gateway restart.
Reconciling
The notice tells the watcher precisely what to do. session_status with changesSince: <version> returns the typed events after that version (up to 200), without advancing any cursors:
{
"stateVersion": 19,
"stateChanges": {
"events": [
{
"sequence": 14,
"kind": "human_direct_message",
"actorType": "human",
"summary": "human message via telegram"
},
{ "sequence": 19, "kind": "goal_changed", "actorType": "human", "summary": "goal updated" }
],
"historyGap": false
}
}
historyGap: true indicates the requested version predates retained history, so refresh the entire session state (sessions_history, session_status) rather than treating the response as an exact delta. The gap signal is precise: it derives from a per-session pruned watermark, not from sequence arithmetic.
Storage and limits
History resides in the shared state database, capped at 30 days and 50,000 rows; per-session heads stay monotonic after pruning. Recording is best-effort, a failed append gets logged and never fails the originating turn, so stateVersion is a signal-log head, not a transactional change-data-capture version.
Current limits:
- Notice delivery assumes one gateway process owns the shared state database. Multiple gateways share the durable log and
changesSince, but v1 does not push notices across processes. - Compaction events cover the embedded runtime's compaction owners; native-harness-only compaction is not fully logged.
- Cancelled-outcome payload detail is currently produced by ACP child runs; native sub-agent cancellations surface as generic failures.
- Upstream self-echo detection compares normalized user text. An external prompt matching one of the session's 10 most recent OpenClaw-side user messages is treated as self-echo.
- A single local Claude JSONL row larger than the 1 MiB per-cadence scan cap blocks that session's cursor in v1; unclassified bytes are never skipped.
- A single Pi JSONL row larger than the 1 MiB per-cadence scan cap blocks that session's cursor in v1; unclassified bytes are never skipped.
- Legacy Pi sessions are adopted without an upstream link. Resume once to migrate the file to v3, then continue it from the catalog again to start monitoring.
- OpenCode checks issue one batched database query per cadence. A session export runs only when that query shows its durable event sequence advanced.
- Paired-node Claude checks classify the latest 50 transcript items per cadence. Larger bursts can fall outside the v1 scan window.
- Paired-node Claude history reads do not expose a definitive thread-not-found result, so remote Claude deletions are not classified as
upstream_missingin v1. - Catalog sessions that have not been adopted remain outside the awareness layer in v1.
- Sessions adopted before this feature carry no upstream link; continue them from the catalog once to start upstream monitoring.
- Upstream links assume each adopted session key maps to one owning agent (adoption uses the default store agent). Multi-agent adoption of the same external thread is not monitored in v1.
Related
- Session tools,
sessions_send,session_status,sessions_list - Sub-agents, spawn edges and completion announcements
- Heartbeat, how queued notices wake main sessions
- Session management, session keys, scopes, lifecycle