Skip to content

feat: durable message lifecycle from admission to execution - #3721

Open
Astro-Han wants to merge 22 commits into
apache:mainfrom
Astro-Han:fix/durable-message-lifecycle
Open

feat: durable message lifecycle from admission to execution#3721
Astro-Han wants to merge 22 commits into
apache:mainfrom
Astro-Han:fix/durable-message-lifecycle

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

This is the first end-to-end durable message lifecycle PR built from the latest main, replacing Draft PR #3633. It does not cherry-pick or continue the #3633 patch series.

The contract is simple: after turn.message.submit accepts a message, a Host crash must not lose it, revive it after settlement, or execute it twice.

The implementation keeps one durable admission/transcript authority and one proof-driven settlement owner. queued, leased, and in_flight are reconstructible Host-memory projections, not durable states.

submit
  |
  v
Accepted  -- retract / terminal Stop proof --> Cancelled
  |
  | durable root-source or steering-event proof
  v
HandedOff -- downstream provider-request proof --> Executed

These are durable classifications, not an independent provider state machine. SQLite keeps a small settlement index so pending reads and exact retries stay local and deterministic, but HandedOff and Executed may advance only when their authoritative root/steering/provider proofs exist. Normal completion and restart recovery call the same classifier/settlement owner.

First-principles ownership

  • SqliteSessionMetadataStore atomically owns canonical message admission plus transcript identity, and persists edit/reorder/promotion/retract settlement data.
  • RootAdmissionOwner owns the durable Root execution contract and source-message proof.
  • HostMessageCoordinator owns only the reconstructible queue projection and the shared proof classifier/settlement path.
  • Skill preparation and all admission preflight happen before Accepted; rejected submissions leave no transcript row. The accepted record stores one canonical content value plus the submitted-content digest, not raw and prepared copies.
  • Root handoff is durable before Runtime activation. Follow-up transcript rows are rebound to the successor Turn before activation, preserving one submitted message as one transcript identity.
  • A multi-message successor has userMessageId = null and keeps its individual sourceMessages; Runtime does not invent a third aggregate transcript message.
  • Durable Session capability binding is derived from the durable Root execution contract, not a restart-volatile marker.
  • RuntimeKernel no longer owns queue state, leasing, folding, retract, fallback authority, or a second root-message materialization path. The old public method surface remains only as a stateless compatibility shell while CLI/Desktop projection work stays out of scope.

Crash-cut behavior

  1. Before canonical admission: no accepted message or transcript row exists.
  2. After admission, before Root admission: recovery sees exactly one Accepted canonical message and admits it once.
  3. After Root admission, before activation: recovery replays the durable Root contract and its exact source identities.
  4. After a steering/root handoff proof: recovery classifies HandedOff; it does not put the message back in the queue.
  5. After a downstream provider-request proof: recovery settles Executed; it does not replay the message.
  6. During terminal cleanup: normal completion and restart recovery converge through the same settlement owner to Executed or Cancelled.

Scope

Included: atomic admission and transcript, canonical Skill admission, edit/reorder/promotion/retract, successor handoff, proof-driven settlement, restart recovery, size boundaries, durable capability derivation, and removal of old Runtime queue/transcript authority.

Excluded: CLI/Desktop projection refactors and Side Conversation UI/hooks.

Verification

Only affected tests were run locally; the full repository suite was not run locally.

  • Exact pushed head: 490ffb31a (rebased onto main at 3a9824a7e).
  • Local builds passed: @maka/core, @maka/storage, @maka/runtime, and @maka/runtime-host.
  • Real SQLite metadata and Root admission normalization: 53 passed.
  • Runtime interaction regression: 10 passed.
  • Runtime Host coordinator/root/recovery/queue UDS/message UDS/Goal/projection suites: 134 passed.
  • Exact-head CI run 32769002503 passed, including all 1,154 Runtime Host tests and release-candidate validation.
  • Fault coverage includes rejected idle submit without phantom transcript, canonical Skill crash cut before Root admission, 32 KiB public admission, real SQLite reorder plus restart, queue admission crash, admitted Root without a Run, killed/graceful Host recovery, steering/provider proof settlement, retract/interrupt, ordered multi-source successor handoff, and transcript de-duplication.

Review follow-up

  • Fixed real reorder permutations by validating cardinality plus identity membership and persisting the requested order transactionally.
  • Removed the hidden doubled admission envelope: durable admission now stores one canonical content value plus submitted digest.
  • Moved canonical preparation and rejection ahead of atomic admission, closing raw /skill replay and rejected phantom transcript paths.
  • Removed 132+ lines of duplicate Runtime message materialization/marker plumbing; the durable Root contract now directly represents singular versus multi-source transcript ownership.

AI use

This PR was implemented with Codex assistance. The design, repository decisions, code changes, affected-test selection, review of Draft PR #3633 as evidence, adversarial review, simplification audit, and verification were directed and checked against the repository's durable authorities and production composition.

@Astro-Han
Astro-Han marked this pull request as ready for review August 24, 2026 18:21

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

English

I found two correctness issues on exact head 2ea21a68950502615f5e109ce914a7af94a97140: one makes durable follow-up reordering fail for every real permutation, and the other rejects otherwise valid 32–49 KiB messages as an internal failure. The exact-head hosted test check is green, but its queue test uses an in-memory lifecycle stub for reorder and does not exercise either storage boundary.

简体中文

我在 exact head 2ea21a68950502615f5e109ce914a7af94a97140 上确认了两个正确性问题:持久化 follow-up 队列的任何实际换序都会失败;另一个问题会把本来合法的 32–49 KiB 消息错误地变成内部错误。当前 head 的托管 test 检查是绿色,但队列测试对重排使用了内存 lifecycle stub,没有覆盖这两个真实存储边界。

const current = rows.map((row) => row.message_id);
if (
current.length !== unique.length ||
current.some((messageId, index) => messageId !== unique[index])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

English

[P1] Compare identity membership here instead of requiring the requested order to equal the current order. The caller invokes this method only when the order changed, but this predicate rejects every such permutation before the updates run. With two accepted follow-ups persisted as message-1, message-2, calling reorderMessageAdmissions(session, [message-2, message-1]) deterministically throws SessionMetadataConflictError and leaves the old order intact. The coordinator test does not catch this because its lifecycle fake implements reorderMessageAdmissions as a no-op. Please validate equal cardinality plus the same identity set, then write the requested order in this transaction, and add a real SQLite reorder-and-restart test.

简体中文

[P1] 这里应校验 identity 集合,而不是要求请求顺序与当前顺序完全相同。调用方只会在顺序发生变化时进入这个方法,但当前条件会在更新执行前拒绝所有实际 permutation。把两个 accepted follow-up 按 message-1, message-2 持久化后,调用 reorderMessageAdmissions(session, [message-2, message-1]) 会稳定抛出 SessionMetadataConflictError,持久顺序仍保持不变。现有 coordinator 测试没有发现它,因为 lifecycle fake 把 reorderMessageAdmissions 实现成了 no-op。请改为校验数量相同且 identity 集合一致,再在同一事务中写入请求顺序,并增加真实 SQLite 的换序与重启测试。

disposition,
admittedAt: Date.now(),
};
await this.#lifecycle.commitMessageAdmission(messageAdmission);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

English

[P2] Preflight the exact pending-admission envelope before this write. The earlier guards check the public queue projection (submitted content) and future root payload (prepared content) separately, while the storage record contains both and has its own 64 KiB cap. Using the real decoders on this head, a plain 32 KiB message passes both earlier guards but normalizePendingMessageAdmission throws Pending message admission exceeds size limit; the mismatch continues through 49 KiB and escapes this handler as internal_failure. Please either align the durable format/limit with the supported envelopes or return a declared capacity error before mutation, and cover the 31/32/49/50 KiB boundaries with the real lifecycle store.

简体中文

[P2] 在这里写入前需要预检完整的 pending-admission envelope。前面的守卫分别检查公共队列 projection(submitted content)和未来 root payload(prepared content),但存储记录同时保存两份内容,并另有 64 KiB 上限。在当前 head 上使用真实 decoder 验证时,普通 32 KiB 消息能通过前两个守卫,却会在 normalizePendingMessageAdmission 抛出 Pending message admission exceeds size limit;这个错配一直持续到 49 KiB,并最终被转换成 internal_failure。请让持久化格式/上限与已支持的 envelope 对齐,或在任何 mutation 前返回明确的容量错误,并使用真实 lifecycle store 覆盖 31/32/49/50 KiB 边界。

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I reviewed this head and found blocking issues that need to be addressed before merge.

[P1] Recovery can replay raw /skill text instead of the prepared skill invocation

Idle submit persists raw modelContent first (message-coordinator.ts:906-918) and only later expands the skill in the Host (root-turn-coordinator.ts:1028-1077). If the Host exits between those steps, recovery replays the persisted raw content without re-running skill preparation, producing a root that the normal path would have wrapped.

[P1] Rejected idle submit leaves a phantom user message in the transcript

The admission and user transcript are written together, but later start/admission can still fail (skill blocked, oversized, binding failure). The cancellation only flips lifecycle state, not the transcript — a failed send remains visible and retries create duplicates.

[P2] Message reorder with identical content is rejected as a conflict

The metadata store compares target order byte-for-byte, so any non-trivial reorder is treated as a conflict. Existing tests pass only because reorders in those fixtures are no-ops.

[P2] Admission envelope can exceed storage limits undetected

Submitted and prepared payloads are checked separately, but the combined admission envelope (64 KiB limit) is not pre-validated. Inputs in the 32–49 KiB range pass early gates yet fail at admission, surfacing as an internal failure.

CI on 2ea21a689505 is test: success. These issues are independent of CI and require fixes before approval. Heads verified at time of review.

简体中文存在恢复路径与 transcript 残留等阻断问题,需修复后重审。

Preserve live Client capability bindings, make cancellation retries idempotent, and keep admission-backed transcripts out of compatibility Run synthesis until their root contract owns them.

Generated-by: Codex
@Astro-Han
Astro-Han force-pushed the fix/durable-message-lifecycle branch from 2ea21a6 to d691232 Compare August 24, 2026 19:29

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I verified a blocking issue in this head.

[P2] Retracted messages that never reached the Host remain visible in the transcript

When a queued Accepted message is retracted during reload, the lifecycle row is moved to cancelled but the session_messages user row and catalog preview inserted at admission stay visible. TUI expects still-queued text to return to the editor and only consumed messages to stay in history — after reload the old row is still paged as consumed steering, and resubmission leaves a duplicate.

Repro: next_turn submit → queue.retract → reopen. Lifecycle cancelled but readMessages still returns the user row with steeringEventId.

Fix: for accepted -> cancelled (never handed off) hide/remove the transcript row and recompute the preview with a lifecycle-aware projection; keep the row only for handed_off -> cancelled where Runtime actually consumed it. Cover reload + edit/resubmit.

Previous skill-recovery P2 is closed in this head. Checks on 490ffb31a4 are test: success.

简体中文撤回未送达消息仍留在历史视图,需区分未送达与已消费后取消。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants