Skip to content

Record actor state changes from ateapi - #1638

Merged
Jeff Luo (JeffLuoo) merged 3 commits into
agent-substrate:mainfrom
krisztianfekete:feat/lifecycle-operation-on-the-wire
Sep 14, 2026
Merged

Jeff Luo (JeffLuoo) merged 3 commits into
agent-substrate:mainfrom
krisztianfekete:feat/lifecycle-operation-on-the-wire

Conversation

@krisztianfekete

@krisztianfekete Krisztian F (krisztianfekete) commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Record actor state changes from ateapi

ateapi now writes a log record every time an actor changes state.

Before

Nothing tells you what state an actor is in. Metrics can't carry actor identity, and the control plane samples traces at 10%, so most transitions leave nothing behind at all. If you want to know whether an agent is running, paused or suspended, there's nowhere to look.

After

One filter:

ate.actor.uid="8f2a1c4e6b0d47f1" AND ate.actor.state!=""

Last record wins. That's the state it's in now, and the timestamp is when it got there.

The record:

{
  "time": "…",
  "level": "INFO",
  "msg": "Actor state changed",
  "ate.atespace": "ate-demo-counter",
  "ate.actor.name": "counter-1",
  "ate.actor.uid": "8f2a…",
  "ate.template.atespace": "ate-demo-counter",
  "ate.template.name": "counter",
  "ate.actor.operation.name": "suspend",
  "ate.actor.state": "suspended"
}

The two new keys

ate.actor.state is the ateapipb.ActorState values lowercased, so the log wording and the state machine can't drift apart.

ate.actor.operation.name says what caused the change. The state on its own doesn't tell you: an actor lands in suspended from a suspend and paused from a pause, and only one of those gives the worker back.

Actor crashed picks up the same state key. A crash is a state change too, and it's the one an actor can reach without any operation finishing.

Notes for review

Why ateapi. It owns the state machine. It also sees transitions that never reach a worker, like deleting an actor that was already suspended, or a resume that fails in the scheduler.

It can't log a state the store never had. The record goes out after the store commit, and every state commit has a version precondition, so a losing writer in a concurrent update writes nothing.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

@krisztianfekete
Krisztian F (krisztianfekete) force-pushed the feat/lifecycle-operation-on-the-wire branch from 27cb0c1 to 647bdab Compare September 14, 2026 12:44
@krisztianfekete
Krisztian F (krisztianfekete) force-pushed the feat/lifecycle-operation-on-the-wire branch from 647bdab to e67104f Compare September 14, 2026 13:49
@krisztianfekete Krisztian F (krisztianfekete) changed the title (otel): Name the operation and state on actor lifecycle log records Record actor state changes from ateapi Sep 14, 2026
@krisztianfekete
Krisztian F (krisztianfekete) marked this pull request as ready for review September 14, 2026 14:11

@JeffLuoo Jeff Luo (JeffLuoo) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you looked at OTel entity events? This record is effectively an entity.state stream for ate.actor — same identity/description/relationship split, same last-write-wins contract. Not a blocker for this PR given entity event is still in development stage.

Two comments -

  • There's no terminal record. deleting is the last state a deleted actor ever reports, so "gone" and "stuck deleting" look identical to a consumer. Can we log the actual delete as well?

  • Transition-only emission has no cold-start or expiry story: an actor that hasn't transitioned inside the retention window has no state, and a missed record is indistinguishable from steady state. Just document it somewhere.

@krisztianfekete

Copy link
Copy Markdown
Collaborator Author

Have you looked at OTel entity events? This record is effectively an entity.state stream for ate.actor — same identity/description/relationship split, same last-write-wins contract. Not a blocker for this PR given entity event is still in development stage.

Yes, that's exactly the what I am designing this around. ate.actor.uid identifies, everything else describes. So we can add entity.state in later as second record with entity.id being the uid and the rest as description. I checked their state after their recent blog a couple of week ago, but I think it's still early to go all in.

  • There's no terminal record. deleting is the last state a deleted actor ever reports, so "gone" and "stuck deleting" look identical to a consumer. Can we log the actual delete as well?
  • Transition-only emission has no cold-start or expiry story: an actor that hasn't transitioned inside the retention window has no state, and a missed record is indistinguishable from steady state. Just document it somewhere.

Thanks, added these!

@JeffLuoo Jeff Luo (JeffLuoo) left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on it, just a few more comments.

//
// Crashes go through logActorCrashed instead, so read the state off
// ate.actor.state rather than off the message.
func logActorStateChanged(ctx context.Context, actor *ateapipb.Actor, opName, state string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "creation" should also be covered. When an actor is created, it holds a suspended stage: https://github.com/agent-substrate/substrate/blob/main/cmd/ateapi/internal/controlapi/actor.go#L112

An actor created and never resumed has no state at all in the stream — not "unknown since retention", genuinely never emitted, even with infinite retention. If we have creation event, it can at least record something in logs for this actor.

My understanding of this logs like "logs for all lifecycle of an actor".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. CreateActor logs now with operation=create, reporting suspended since that's what a new actor actually is.

//
// Crashes go through logActorCrashed instead, so read the state off
// ate.actor.state rather than off the message.
func logActorStateChanged(ctx context.Context, actor *ateapipb.Actor, opName, state string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the state field in the parameters.

// SnapshotScopeValue maps the wire enum onto its label value, shared so ateapi
// (which sets the scope) and atelet (which receives it) cannot drift. An
// unrecognized scope reports as unknown rather than stringified, so no wire
// value can widen the label set.
func SnapshotScopeValue(scope ateletpb.SnapshotScope) string {
switch scope {
case ateletpb.SnapshotScope_SNAPSHOT_SCOPE_FULL:
return SnapshotScopeFull
case ateletpb.SnapshotScope_SNAPSHOT_SCOPE_DATA:
return SnapshotScopeData
case ateletpb.SnapshotScope_SNAPSHOT_SCOPE_DATA_ON_GOLDEN:
return SnapshotScopeDataOnGolden
default:
return SnapshotScopeUnknown
}
}

The function takes the state as a literal, but storedActor is the committed record and already carries it. That means each of the eight call sites hand-pairs a constant with whatever the mutate closure assigned, and nothing catches a mismatch. A wrong literal produces a record that confidently reports a state the store never held.

ateattr already has the pattern in SnapshotScopeValue as attached above. Can you update the function to grep the state from actor *ateapipb.Actor?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ActorStateValue so the helper reads state off the committed record. Only the delete-terminal one still names it by hand, since the row is gone by then.

@JeffLuoo
Jeff Luo (JeffLuoo) merged commit 1f22af8 into agent-substrate:main Sep 14, 2026
9 checks passed
@krisztianfekete
Krisztian F (krisztianfekete) deleted the feat/lifecycle-operation-on-the-wire branch September 15, 2026 08:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants