LearnNewsExamplesServices
Frontmatter
id15114
titleGive Task transitions durable GraphLog event identity
stateOpen
labels
bugaitestingarchitecture
assignees[]
createdAt12:33 AM
updatedAt12:34 AM
githubUrlhttps://github.com/neomjs/neo/issues/15114
authorneo-gpt-emmy
commentsCount0
parentIssue15100
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[x] 15106 Persist the winning assignee for broadcast A2A Tasks
blocking[]
milestonev13.2

Give Task transitions durable GraphLog event identity

Open Backlog/active-chunk-5 bugaitestingarchitecture
neo-gpt-emmy
neo-gpt-emmy commented on 12:33 AM

Context

Cross-family review of PR #15111 verified the broadcast Task owner repair in #15106, then surfaced a separate residual in the Task wake path: the transition is durable, but its wake event has no durable typed identity. Source inspection confirms that a later unrelated MESSAGE rewrite can re-expose the last transition and produce a second wake after the in-memory coalescing window has closed.

This follow-up keeps the #15106 source repair bounded and gives the downstream lifecycle/wake consumer an immutable event fact before more awareness surfaces consume it.

The Problem

SQLite writes one generic GraphLog row for every MESSAGE node update. The shared TASK_STATE_CHANGED evaluator then reads the MESSAGE current state and lastModifiedAt from the node at evaluation time.

That means the GraphLog row does not prove that a Task transition occurred. The following sequence reproduces the semantic defect in source:

  1. transitionTask commits Working → Completed and updates lastModifiedAt;
  2. the node-update trigger appends a generic nodes GraphLog row;
  3. the wake evaluator emits Completed;
  4. after the coalescing window, markRead, archiveMessage, deleteMessage, or another whole-node upsert updates the same MESSAGE;
  5. the trigger appends another generic nodes row;
  6. the evaluator sees the unchanged Completed + lastModifiedAt and emits the same logical transition again with a fresh random eventId.

The current daemon deduplicates taskId + newState + lastModifiedAt only inside one in-memory coalescing window. Restart, resync, or a later generic rewrite therefore has no durable event identity to compare. Two real fast transitions can also collapse to the latest node snapshot when old GraphLog rows are evaluated after the node advances.

The Architectural Reality

  • ai/graph/storage/SQLite.mjs owns GraphLog schema and generic node invalidation triggers.
  • ai/services/memory-core/MailboxService.mjs owns authoritative Task transitions and TTL expiry.
  • ai/services/memory-core/heartbeatPulseEvaluator.mjs is the shared trigger-semantic owner used by both WakeSubscriptionService and the standalone wake daemon.
  • ai/services/memory-core/WakeSubscriptionService.mjs and ai/daemons/wake/daemon.mjs consume GraphLog; they must not infer a transition from an unrelated current-node rewrite.
  • GraphLog still needs generic nodes rows for cache invalidation. This ticket adds an immutable typed Task-transition fact; it does not replace ordinary graph invalidation.
  • The Agent OS structure map was run at 2026-07-12T22:33:56.195Z. Existing owners are ai/graph/storage, ai/services/memory-core, and ai/daemons/wake; no new service directory is introduced.

The Fix

  1. Extend GraphLog with a backward-compatible optional typed-event payload owned by SQLite storage.
  2. In the same SQLite transaction that commits a successful Task state change, append exactly one task_state_changed row with a server-generated durable eventId and immutable snapshot: taskId, previousState, newState, originator, canonical assignee, assignment authority, and lastModifiedAt.
  3. Emit the same typed row for successful TTL expiry. Failed, unauthorized, state-mismatch, and race-loser paths emit none.
  4. Make both wake consumers match TASK_STATE_CHANGED only from the typed row and its stored snapshot. Generic nodes rows remain cache invalidation only.
  5. Preserve the durable eventId in MCP notifications, daemon queue entries, resync output, retry state, and coalescing. Coalescing remains batching; it is not the event-identity source.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback / Edge Case Docs Evidence
GraphLog typed Task row Successful Memory Core Task transaction Store one immutable transition snapshot plus durable eventId Legacy generic nodes rows never classify as Task transitions SQLite JSDoc/schema note migration + atomicity unit specs
transitionTask and TTL expiry MailboxService state/RBAC transaction Append typed event only when the state write commits mismatch, denial, and race loser emit zero events JSDoc success/failure/race specs
heartbeatPulseEvaluator match Typed GraphLog event payload Match source event, not current MESSAGE snapshot missing/malformed payload fails closed JSDoc pure evaluator matrix
WakeSubscriptionService resync/notification Typed eventId and snapshot Return stable eventId and exact historical state repeated resync preserves identity; transport may remain at-least-once OpenAPI/JSDoc if shape changes resync + restart specs
Standalone wake daemon Same typed eventId and snapshot Queue/retry/coalesce without inventing identity generic later MESSAGE update produces no Task event JSDoc beyond-window rewrite + two-transition specs

Decision Record Impact

Aligned with ADR 0035. The ticket makes a source-owned Task transition a durable, citeable fact for the lifecycle frontier. It does not alter the four-surface composition or add a new authority.

Decision Record: Not needed — this is a bounded source/wake correctness repair beneath the accepted composition.

Acceptance Criteria

  • Every successful Task state transition appends exactly one typed GraphLog event in the same transaction as the state write.
  • Each typed event has a stable server-owned eventId and immutable previousState, newState, taskId, originator, canonical assignee, assignment authority, and lastModifiedAt.
  • Failed authorization, expected-state mismatch, malformed routing, and race-loser paths append no typed event.
  • TTL expiry emits the same typed contract exactly once per committed expiry.
  • Generic MESSAGE node updates still drive cache invalidation but never match TASK_STATE_CHANGED.
  • transition → coalescing-window expiry → markRead/archive/retract produces one logical Task event, not two.
  • Two Task transitions committed before one poll/resync are returned as two ordered events with their original snapshots, even when the current MESSAGE has advanced.
  • Repeated resync and daemon restart preserve the same eventId for the same GraphLog transition row.
  • Both WakeSubscriptionService and the standalone daemon consume the same shared typed-event evaluator contract.
  • Existing SENT_TO_ME and PERMISSION_GRANTED behavior is unchanged.
  • Focused Memory Core and wake-daemon suites pass through the canonical custom Playwright unit configuration; the default Playwright command is not used.

Out of Scope

  • End-to-end exactly-once delivery across external adapters.
  • Generic wake-subscription duplication addressed by #10430/#11182.
  • GraphLog retention/compaction policy addressed by #12329.
  • Replacing GraphLog with a general event store.
  • Changing Task assignment/RBAC delivered by #15106 / PR #15111.
  • LifecycleFrontier, Fleet Manager, Bird Views, or Golden Path composition.

Avoided Traps

  • Extending the coalescing window: delays the duplicate but loses on restart and later rewrites.
  • Treating taskId + current state as event identity: repeated same-state transitions and historical resync remain ambiguous.
  • Suppressing all later MESSAGE GraphLog rows: breaks cache invalidation and other MESSAGE lifecycle consumers.
  • Storing only the transition clock on the Task node: old GraphLog rows still read the newest node state.

Related

Origin Session ID: f95e01ff-ba36-409a-98af-573263fab247

Retrieval Hint: "durable TASK_STATE_CHANGED GraphLog event identity unrelated MESSAGE rewrite" Retrieval Hint: "Task transition snapshot coalescing window resync duplicate"

Live latest-open sweep: checked latest 20 open issues at 2026-07-12T22:33:56.195Z; no equivalent found. Recent all-state A2A claim sweep: checked latest 30 messages at 2026-07-12T22:33:56.195Z; no overlapping lane claim or intent found.