You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/16 01:18:19 UTC

(maka) branch feat/runtime-host-rust updated (a1fa888964 -> 787c21f5f5)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a change to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git


    from a1fa888964 perf(runtime-host): reduce admission contention and redundant runtime work
     new d4bc6dec5c feat(runtime-host): support diagnostics and safe retirement
     new 939919e96e feat(agent): seal cooperative handoffs at settled step boundaries
     new a32b653af0 feat(event-log): authenticate same-turn handoff successors
     new 51c6372e44 feat(agent): resume handoffs with frozen execution state
     new 302cfb6e94 feat(runtime-host): preserve logical identity across handoffs
     new d046fbf3b5 feat(runtime-host): cancel sealed handoffs without provider recovery
     new cb3a3ef378 fix(runtime-host): retain work ownership through cooperative pauses
     new 1aa734f997 feat(host): freeze admitted client and skill composition
     new 5dda97b521 fix(oauth): separate enrollment generations from token refresh
     new 855cfda7b0 feat(host): coordinate durable handoff and recover frozen execution
     new 749bbfe25a feat(cli): add native host discovery and retirement control
     new 4e66509fce feat(desktop): accept native host operators across SSH and WSL
     new c6c0852c67 fix(storage): retain and revalidate named writer leases
     new 760a0500d2 feat(host): pin native deployments before runtime admission
     new 375772c879 feat(host): activate native deployments through verified live owners
     new 9714831831 feat(host): reconcile code updates under durable deployment authority
     new 6580151812 fix(desktop): preserve candidate ownership when drain observation expires
     new fa50454bca fix(build): bind embedded SDKs to the checked npm installation
     new bc18d173b0 feat(host): preserve native Windows background lifetimes
     new b8a9cbf1e0 feat(host): activate native account services across platforms
     new 18f178b14b feat(host): control and revoke native deployments safely
     new c9d1eff8a6 feat(host): expose read-only deployment status and bounded diagnostics
     new d3c4ea2a5e fix(build): align Windows CRT without build-time V8
     new a41cb84e60 feat(host): bridge native connections and repair explicit WSL remounts
     new d30c7e06f7 feat(host): publish code and deployment settings as one target
     new e17a375b93 perf(desktop): reuse native Host without redundant initialization
     new 1ce2ef41a7 feat(cli): prepare and revoke native remote pairing
     new 50158c161d fix(host): distinguish service residency from ephemeral ownership
     new 787c21f5f5 feat(desktop): activate and reuse native managed Hosts

The 29 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .husky/pre-commit => .cargo/config.toml            |    7 +-
 Cargo.lock                                         |   96 +-
 Cargo.toml                                         |    4 +
 LICENSE                                            |   28 +
 apps/desktop/electron-builder.config.mjs           |    3 +
 .../__tests__/runtime-host-wsl-controller.test.ts  |   13 +-
 .../src/main/native-runtime-host-deployment.ts     |  177 ++
 apps/desktop/src/main/native-runtime-host.ts       |   64 +-
 apps/desktop/src/main/runtime-host-boot.ts         |   20 +-
 .../src/main/runtime-host-desktop-candidate.ts     |    6 +-
 .../src/main/runtime-host-desktop-manager.ts       |   10 +-
 apps/desktop/src/main/runtime-host-onboarding.ts   |    3 +-
 .../src/main/runtime-host-wsl-controller.ts        |   11 +-
 .../src/shared/native-runtime-host-deployment.ts   |  110 ++
 biome.jsonc                                        |    3 +
 crates/agent/src/cancellation.rs                   |   24 +-
 crates/agent/src/compact.rs                        |    1 +
 crates/agent/src/continuation.rs                   |  108 +-
 crates/agent/src/handoff.rs                        |  339 ++++
 crates/agent/src/history/projection.rs             |    2 +-
 crates/agent/src/lib.rs                            |   51 +-
 crates/agent/src/model_attempt.rs                  |    9 +-
 crates/agent/src/runner.rs                         |   37 +-
 crates/agent/src/running.rs                        |    7 +
 crates/agent/src/steps.rs                          |   74 +-
 crates/agent/tests/integration/agent_loop.rs       |   19 +-
 crates/agent/tests/integration/handoff.rs          |  221 +++
 .../agent/tests/integration/handoff_compaction.rs  |  144 ++
 crates/agent/tests/integration/main.rs             |    3 +
 .../agent/tests/integration/support/agent_loop.rs  |   25 +-
 .../agent/tests/integration/support/invocation.rs  |    1 +
 crates/cli/Cargo.toml                              |   24 +-
 .../cli/migrations/deployment/0001_deployment.sql  |   14 +-
 .../cli/migrations/deployment/0002_update.sql      |    8 +-
 crates/cli/src/access.rs                           |  138 ++
 crates/cli/src/args.rs                             |   73 +-
 crates/cli/src/candidate.rs                        |   46 +-
 crates/cli/src/code.rs                             |    1 +
 crates/cli/src/deployment.rs                       |  298 +++
 crates/cli/src/deployment/activation.rs            |  284 +++
 crates/cli/src/deployment/configuration.rs         |   66 +
 crates/cli/src/deployment/connect.rs               |  192 ++
 crates/cli/src/deployment/control.rs               |  155 ++
 crates/cli/src/deployment/entry.rs                 |   48 +
 crates/cli/src/deployment/logs.rs                  |  218 +++
 crates/cli/src/deployment/package.rs               |  119 ++
 crates/cli/src/deployment/query.rs                 |  182 ++
 crates/cli/src/deployment/service.rs               |  182 ++
 crates/cli/src/deployment/service/linux.rs         |  179 ++
 crates/cli/src/deployment/service/macos.rs         |  192 ++
 crates/cli/src/deployment/service/status.rs        |   69 +
 crates/cli/src/deployment/service/windows.rs       |  282 +++
 crates/cli/src/deployment/store.rs                 |  272 +++
 crates/cli/src/deployment/update.rs                |  224 +++
 crates/cli/src/deployment/updates.rs               |  318 ++++
 crates/cli/src/host_client.rs                      |  320 ++++
 crates/cli/src/main.rs                             |   17 +-
 crates/cli/src/serve.rs                            |   60 +-
 .../src/cli.ts => crates/cli/src/service_main.rs   |    6 +-
 crates/cli/src/stdio.rs                            |   69 +
 crates/cli/src/windows.rs                          |  227 +++
 crates/cli/tests/integration/access.rs             |   62 +
 crates/cli/tests/integration/candidate.rs          |  156 +-
 crates/cli/tests/integration/deployment.rs         |  564 ++++++
 crates/cli/tests/integration/main.rs               |    2 +
 crates/client-capability/Cargo.toml                |    1 -
 crates/client-capability/src/broker.rs             |   51 +-
 crates/client-capability/src/broker/state.rs       |    9 +
 crates/client-capability/src/lib.rs                |   45 +-
 crates/client-capability/src/registry.rs           |    4 +-
 crates/client-capability/src/registry/bindings.rs  |    4 +
 .../src/registry/bindings/restoration.rs           |  217 +++
 .../src/registry/bindings/selection.rs             |   32 +-
 .../src/registry/bindings/snapshot.rs              |   30 +-
 crates/client-capability/tests/bindings.rs         |  111 +-
 .../client-capability/tests/journal_admission.rs   |   24 +
 crates/config/build.rs                             |    9 +-
 crates/config/src/oauth/enrollment/commit.rs       |    2 +-
 crates/config/src/vault.rs                         |   14 +-
 crates/config/src/vault/write.rs                   |   15 +
 crates/config/tests/oauth.rs                       |   32 +-
 crates/config/tests/oauth_login/discovery.rs       |   14 +-
 crates/config/tests/oauth_login/recovery.rs        |   31 +
 ...inuation_claims.sql => 0023_handoff_claims.sql} |   26 +-
 crates/event-log/src/append.rs                     |    1 +
 crates/event-log/src/archive/candidates.rs         |    2 +-
 crates/event-log/src/connection.rs                 |   22 +-
 crates/event-log/src/connection/authority.rs       |   14 +-
 crates/event-log/src/context/boundary.rs           |    8 +-
 crates/event-log/src/context/frozen.rs             |   24 +-
 crates/event-log/src/context/latest_main.rs        |    3 +-
 crates/event-log/src/context/read.rs               |   22 +
 crates/event-log/src/context/request.rs            |   16 +-
 crates/event-log/src/context/safety.rs             |    2 +-
 crates/event-log/src/context/selection.rs          |   11 +-
 crates/event-log/src/continuation.rs               |   39 +-
 crates/event-log/src/handoff.rs                    |  315 ++++
 crates/event-log/src/handoff/budget.rs             |  111 ++
 crates/event-log/src/handoff/cancel.rs             |  164 ++
 crates/event-log/src/handoff/owner.rs              |  117 ++
 crates/event-log/src/lib.rs                        |   21 +-
 crates/event-log/src/message_resolution.rs         |    2 +-
 crates/event-log/src/message_resolution/owner.rs   |   15 +-
 crates/event-log/src/observation/delivery.rs       |   20 +-
 crates/event-log/src/observation/streams.rs        |    2 +-
 crates/event-log/src/root.rs                       |  149 +-
 crates/event-log/src/root/windows.rs               |    2 +-
 crates/event-log/src/root/windows/filesystem.rs    |    2 +-
 crates/event-log/src/root_lock.rs                  |   26 +-
 crates/event-log/src/schema.rs                     |    2 +-
 crates/event-log/src/sessions/activity.rs          |    2 +-
 crates/event-log/src/sessions/execution.rs         |    1 +
 crates/event-log/src/sessions/partial.rs           |    2 +-
 crates/event-log/src/steering.rs                   |    4 +-
 crates/event-log/src/transcript.rs                 |   13 +-
 crates/event-log/src/transcript/evidence.rs        |    2 +-
 .../event-log/src/transcript/navigation/queries.rs |    2 +-
 crates/event-log/src/transcript/tools.rs           |    2 +-
 crates/event-log/src/transcript/workhub.rs         |   30 +-
 crates/event-log/src/turns.rs                      |   42 +-
 crates/event-log/src/turns/root.rs                 |   77 +
 crates/event-log/src/workhub.rs                    |   76 +-
 crates/event-log/src/workhub/actions.rs            |   35 +-
 crates/event-log/src/workhub/candidates.rs         |    7 +-
 crates/event-log/src/workhub/correction/admit.rs   |   32 +-
 crates/event-log/src/workhub/correction/resolve.rs |   22 +-
 crates/event-log/src/workhub/resume.rs             |    2 +-
 crates/event-log/src/workhub/stop.rs               |   37 +-
 crates/event-log/src/workhub/stop/admit.rs         |   39 +-
 crates/event-log/src/workhub/stop/subject.rs       |    3 +-
 crates/event-log/tests/continuation.rs             |   10 +
 crates/event-log/tests/continuation/fixtures.rs    |    9 +-
 crates/event-log/tests/continuation/handoff.rs     |  167 ++
 .../event-log/tests/continuation/handoff_budget.rs |  215 +++
 .../event-log/tests/continuation/handoff_cancel.rs |  204 ++
 .../event-log/tests/continuation/handoff_claim.rs  |  391 ++++
 .../tests/continuation/handoff_consumers.rs        |  699 +++++++
 crates/event-log/tests/migrations.rs               |   10 +-
 crates/event-log/tests/root_authority.rs           |   96 +-
 crates/event-log/tests/root_lease.rs               |   65 +
 crates/js-runtime/Cargo.toml                       |    5 -
 crates/js-runtime/build.rs                         |   48 +-
 crates/js-runtime/src/trusted/engine.rs            |   27 +
 .../third-party/deno-telemetry/telemetry.ts        | 1984 ++++++++++++++++++++
 .../js-runtime/third-party/deno-telemetry/util.ts  |   88 +
 crates/presentation/src/lib.rs                     |   25 +-
 crates/presentation/src/message.rs                 |    7 +-
 crates/presentation/src/workhub.rs                 |   13 +
 crates/presentation/tests/projection.rs            |   60 +-
 crates/process/Cargo.toml                          |    2 +-
 .../server/local.rs => process/src/detached.rs}    |   51 +-
 crates/process/src/detached/windows.rs             |  116 ++
 crates/process/src/lib.rs                          |    2 +
 crates/process/src/system.rs                       |   60 +
 crates/process/src/windows.rs                      |   30 +-
 crates/process/src/windows/attributes.rs           |   92 +
 crates/process/src/windows/spawn.rs                |   56 +-
 crates/protocol/src/handshake.rs                   |    2 +-
 crates/protocol/src/host.rs                        |   82 +
 crates/protocol/src/host/status.rs                 |  191 ++
 crates/protocol/src/lib.rs                         |    4 +-
 .../access/delivery.rs => access_delivery.rs}      |   86 +-
 crates/runtime-host/src/execution.rs               |   63 +-
 crates/runtime-host/src/execution/handoff.rs       |  166 ++
 .../runtime-host/src/execution/handoff/prepare.rs  |  253 +++
 crates/runtime-host/src/execution/launch.rs        |    3 +-
 crates/runtime-host/src/execution/prepare.rs       |    1 +
 .../src/execution/prepare/environment.rs           |   12 +-
 crates/runtime-host/src/execution/provider.rs      |   30 +-
 crates/runtime-host/src/execution/provider/auth.rs |   13 +-
 .../runtime-host/src/execution/provider/options.rs |   17 +-
 crates/runtime-host/src/execution/resume.rs        |   66 +-
 .../runtime-host/src/execution/resume/prepare.rs   |    5 +-
 crates/runtime-host/src/execution/snapshot.rs      |   41 +-
 crates/runtime-host/src/execution/successor.rs     |   34 +-
 crates/runtime-host/src/execution/workhub.rs       |   60 +-
 .../runtime-host/src/execution/workhub/profile.rs  |   17 +-
 crates/runtime-host/src/lib.rs                     |    1 +
 crates/runtime-host/src/server.rs                  |   55 +-
 crates/runtime-host/src/server/access.rs           |    2 +-
 crates/runtime-host/src/server/bootstrap.rs        |   70 +-
 crates/runtime-host/src/server/capabilities.rs     |   34 +-
 crates/runtime-host/src/server/connection.rs       |   33 +-
 crates/runtime-host/src/server/diagnostics.rs      |  177 ++
 crates/runtime-host/src/server/dispatch.rs         |   25 +-
 crates/runtime-host/src/server/handshake.rs        |   32 +-
 crates/runtime-host/src/server/interactions.rs     |    2 +
 crates/runtime-host/src/server/listeners.rs        |   12 +-
 crates/runtime-host/src/server/oauth.rs            |   12 +
 crates/runtime-host/src/server/operations.rs       |    3 +-
 crates/runtime-host/src/server/outbound.rs         |    9 +-
 .../src/server/projects/directories.rs             |   16 +
 crates/runtime-host/src/server/registration.rs     |    6 +-
 crates/runtime-host/src/server/retirement.rs       |  217 +++
 .../runtime-host/src/server/sessions/projection.rs |    6 +-
 .../src/server/subscriptions/delivery.rs           |    5 +-
 .../src/server/subscriptions/delivery/events.rs    |    5 +-
 .../src/server/subscriptions/streams.rs            |   49 +-
 crates/runtime-host/src/server/workhub.rs          |   59 +-
 crates/runtime-host/src/server/workhub/action.rs   |    6 +-
 .../src/server/workhub/action/correction.rs        |   23 +-
 .../runtime-host/src/server/workhub/action/stop.rs |    4 +
 crates/runtime-host/src/session.rs                 |    1 +
 crates/runtime-host/tests/integration/handoff.rs   |  569 ++++++
 crates/runtime-host/tests/integration/main.rs      |    2 +
 crates/runtime-host/tests/integration/oauth.rs     |   35 +-
 crates/runtime-host/tests/integration/resume.rs    |   13 +
 .../runtime-host/tests/integration/retirement.rs   |  213 +++
 .../runtime-host/tests/integration/support/peer.rs |   11 +-
 crates/runtime-host/tests/integration/workhub.rs   |   23 +
 crates/runtime/src/capability.rs                   |   24 +-
 .../src/capability/composition.rs}                 |   53 +-
 .../src => runtime/src/capability}/contract.rs     |   13 +-
 .../lib.rs => runtime/src/capability/identity.rs}  |   39 +-
 crates/runtime/src/context.rs                      |    9 +-
 crates/runtime/src/continuation.rs                 |   34 +-
 crates/runtime/src/event.rs                        |   25 +
 crates/runtime/src/event_write.rs                  |   18 +-
 crates/runtime/src/execution.rs                    |   11 +
 crates/runtime/src/handoff.rs                      |  111 ++
 crates/runtime/src/handoff/execution.rs            |   73 +
 crates/runtime/src/input.rs                        |   41 +
 crates/runtime/src/lib.rs                          |    1 +
 crates/skills/src/catalog.rs                       |   46 +-
 crates/skills/tests/invocation.rs                  |   59 +
 crates/tools/src/availability.rs                   |   24 +
 crates/tools/src/catalog.rs                        |   23 +
 crates/tools/src/dispatch.rs                       |   11 +
 crates/tools/tests/integration/catalog.rs          |   37 +-
 .../tools/tests/integration/client_preparation.rs  |    1 +
 docs/rust-runtime.md                               |   77 +-
 docs/rust-runtime.zh-CN.md                         |   57 +-
 .../src/__tests__/ssh-operator-activation.test.ts  |   38 +
 .../runtime-host/src/client/connect-or-spawn.ts    |   11 +-
 .../src/client/ssh-operator-activation.ts          |    8 +-
 packages/runtime-host/src/operator/index.ts        |    2 +
 .../runtime-host/src/operator/operator-command.ts  |   50 +
 packages/runtime-host/src/operator/setup-frame.ts  |    4 +-
 scripts/asf-license-headers.mjs                    |    3 +
 scripts/rust/bundle-providers.mjs                  |   42 +-
 tests/fixtures/client-access-entry.mjs             |  116 ++
 tests/fixtures/client-activation-entry.mjs         |  106 ++
 tests/fixtures/client-bridge-entry.mjs             |  143 ++
 tests/fixtures/client-candidate-entry.mjs          |   57 +-
 tests/fixtures/client-native-managed-entry.mjs     |  140 ++
 tests/fixtures/client.mjs                          |    4 +
 246 files changed, 16727 insertions(+), 1092 deletions(-)
 copy .husky/pre-commit => .cargo/config.toml (81%)
 mode change 100755 => 100644
 create mode 100644 apps/desktop/src/main/native-runtime-host-deployment.ts
 create mode 100644 apps/desktop/src/shared/native-runtime-host-deployment.ts
 create mode 100644 crates/agent/src/handoff.rs
 create mode 100644 crates/agent/tests/integration/handoff.rs
 create mode 100644 crates/agent/tests/integration/handoff_compaction.rs
 copy apps/desktop/src/global.d.ts => crates/cli/migrations/deployment/0001_deployment.sql (77%)
 copy apps/desktop/.storybook/node-crypto-boundary.ts => crates/cli/migrations/deployment/0002_update.sql (74%)
 create mode 100644 crates/cli/src/access.rs
 create mode 100644 crates/cli/src/deployment.rs
 create mode 100644 crates/cli/src/deployment/activation.rs
 create mode 100644 crates/cli/src/deployment/configuration.rs
 create mode 100644 crates/cli/src/deployment/connect.rs
 create mode 100644 crates/cli/src/deployment/control.rs
 create mode 100644 crates/cli/src/deployment/entry.rs
 create mode 100644 crates/cli/src/deployment/logs.rs
 create mode 100644 crates/cli/src/deployment/package.rs
 create mode 100644 crates/cli/src/deployment/query.rs
 create mode 100644 crates/cli/src/deployment/service.rs
 create mode 100644 crates/cli/src/deployment/service/linux.rs
 create mode 100644 crates/cli/src/deployment/service/macos.rs
 create mode 100644 crates/cli/src/deployment/service/status.rs
 create mode 100644 crates/cli/src/deployment/service/windows.rs
 create mode 100644 crates/cli/src/deployment/store.rs
 create mode 100644 crates/cli/src/deployment/update.rs
 create mode 100644 crates/cli/src/deployment/updates.rs
 create mode 100644 crates/cli/src/host_client.rs
 copy packages/cli/src/cli.ts => crates/cli/src/service_main.rs (84%)
 create mode 100644 crates/cli/src/stdio.rs
 create mode 100644 crates/cli/src/windows.rs
 create mode 100644 crates/cli/tests/integration/access.rs
 create mode 100644 crates/cli/tests/integration/deployment.rs
 create mode 100644 crates/client-capability/src/registry/bindings/restoration.rs
 copy crates/event-log/migrations/{0014_continuation_claims.sql => 0023_handoff_claims.sql} (50%)
 create mode 100644 crates/event-log/src/handoff.rs
 create mode 100644 crates/event-log/src/handoff/budget.rs
 create mode 100644 crates/event-log/src/handoff/cancel.rs
 create mode 100644 crates/event-log/src/handoff/owner.rs
 create mode 100644 crates/event-log/src/turns/root.rs
 create mode 100644 crates/event-log/tests/continuation/handoff.rs
 create mode 100644 crates/event-log/tests/continuation/handoff_budget.rs
 create mode 100644 crates/event-log/tests/continuation/handoff_cancel.rs
 create mode 100644 crates/event-log/tests/continuation/handoff_claim.rs
 create mode 100644 crates/event-log/tests/continuation/handoff_consumers.rs
 create mode 100644 crates/event-log/tests/root_lease.rs
 create mode 100644 crates/js-runtime/third-party/deno-telemetry/telemetry.ts
 create mode 100644 crates/js-runtime/third-party/deno-telemetry/util.ts
 copy crates/{runtime-host/src/server/local.rs => process/src/detached.rs} (50%)
 create mode 100644 crates/process/src/detached/windows.rs
 create mode 100644 crates/process/src/system.rs
 create mode 100644 crates/process/src/windows/attributes.rs
 create mode 100644 crates/protocol/src/host.rs
 create mode 100644 crates/protocol/src/host/status.rs
 rename crates/runtime-host/src/{server/access/delivery.rs => access_delivery.rs} (70%)
 create mode 100644 crates/runtime-host/src/execution/handoff.rs
 create mode 100644 crates/runtime-host/src/execution/handoff/prepare.rs
 create mode 100644 crates/runtime-host/src/server/diagnostics.rs
 create mode 100644 crates/runtime-host/src/server/retirement.rs
 create mode 100644 crates/runtime-host/tests/integration/handoff.rs
 create mode 100644 crates/runtime-host/tests/integration/retirement.rs
 copy crates/{presentation/src/shell.rs => runtime/src/capability/composition.rs} (51%)
 rename crates/{client-capability/src => runtime/src/capability}/contract.rs (94%)
 copy crates/{client-capability/src/lib.rs => runtime/src/capability/identity.rs} (64%)
 create mode 100644 crates/runtime/src/handoff.rs
 create mode 100644 crates/runtime/src/handoff/execution.rs
 create mode 100644 tests/fixtures/client-access-entry.mjs
 create mode 100644 tests/fixtures/client-activation-entry.mjs
 create mode 100644 tests/fixtures/client-bridge-entry.mjs
 create mode 100644 tests/fixtures/client-native-managed-entry.mjs


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 08/29: feat(host): freeze admitted client and skill composition

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 1aa734f997dfdeb391ff2737f8142b47bd4a498b
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 15:17:00 2026 +0800

    feat(host): freeze admitted client and skill composition
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                         |   1 -
 .../agent/tests/integration/support/invocation.rs  |   1 +
 crates/cli/src/code.rs                             |   1 +
 crates/client-capability/Cargo.toml                |   1 -
 crates/client-capability/src/lib.rs                |  45 +----
 crates/client-capability/src/registry.rs           |   4 +-
 crates/client-capability/src/registry/bindings.rs  |   4 +
 .../src/registry/bindings/restoration.rs           | 217 +++++++++++++++++++++
 .../src/registry/bindings/selection.rs             |  32 ++-
 .../src/registry/bindings/snapshot.rs              |  30 ++-
 crates/client-capability/tests/bindings.rs         | 111 ++++++++++-
 crates/event-log/tests/continuation/fixtures.rs    |   1 +
 crates/runtime-host/src/execution/prepare.rs       |   1 +
 .../src/execution/prepare/environment.rs           |  10 +-
 .../runtime-host/src/execution/resume/prepare.rs   |   5 +-
 crates/runtime-host/src/execution/workhub.rs       |   3 +-
 .../runtime-host/src/execution/workhub/profile.rs  |  12 +-
 crates/runtime-host/src/server/capabilities.rs     |  29 ++-
 crates/runtime-host/src/session.rs                 |   1 +
 crates/runtime-host/tests/integration/resume.rs    |  13 ++
 crates/runtime-host/tests/integration/workhub.rs   |  23 +++
 crates/runtime/src/capability.rs                   |  24 ++-
 crates/runtime/src/capability/composition.rs       |  52 +++++
 .../src => runtime/src/capability}/contract.rs     |  13 +-
 .../lib.rs => runtime/src/capability/identity.rs}  |  39 +---
 crates/runtime/src/execution.rs                    |  11 ++
 crates/skills/src/catalog.rs                       |  46 ++++-
 crates/skills/tests/invocation.rs                  |  59 ++++++
 .../tools/tests/integration/client_preparation.rs  |   1 +
 29 files changed, 671 insertions(+), 119 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 316d76f780..7e0fd8cff8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3958,7 +3958,6 @@ dependencies = [
  "maka-event-log",
  "maka-protocol",
  "maka-runtime",
- "ryu-js",
  "serde",
  "serde_json",
  "sha2 0.10.9",
diff --git a/crates/agent/tests/integration/support/invocation.rs b/crates/agent/tests/integration/support/invocation.rs
index 1760eaf347..84068d56e3 100644
--- a/crates/agent/tests/integration/support/invocation.rs
+++ b/crates/agent/tests/integration/support/invocation.rs
@@ -25,6 +25,7 @@ pub fn configuration(tool_mode: ToolMode) -> InvocationConfiguration {
     InvocationConfiguration {
         workspace_identity: None,
         system_prompt: None,
+        tool_composition: None,
         cwd: std::env::current_dir()
             .expect("test working directory")
             .into_os_string()
diff --git a/crates/cli/src/code.rs b/crates/cli/src/code.rs
index d9a204dbda..a5c151a9a6 100644
--- a/crates/cli/src/code.rs
+++ b/crates/cli/src/code.rs
@@ -117,6 +117,7 @@ pub(super) async fn run(path: &Path) -> Result<(), maka_runtime_host::server::Ho
         Fact::InvocationOpened {
             configuration: Some(InvocationConfiguration {
                 system_prompt: None,
+                tool_composition: None,
                 workspace_identity: None,
                 cwd: std::env::current_dir()?
                     .into_os_string()
diff --git a/crates/client-capability/Cargo.toml b/crates/client-capability/Cargo.toml
index c39c94448a..765804ee00 100644
--- a/crates/client-capability/Cargo.toml
+++ b/crates/client-capability/Cargo.toml
@@ -29,7 +29,6 @@ maka-runtime.workspace = true
 maka-protocol.workspace = true
 serde.workspace = true
 serde_json.workspace = true
-ryu-js = "1.0.3"
 sha2.workspace = true
 thiserror.workspace = true
 tokio.workspace = true
diff --git a/crates/client-capability/src/lib.rs b/crates/client-capability/src/lib.rs
index 66b64c9d92..e014bcfc41 100644
--- a/crates/client-capability/src/lib.rs
+++ b/crates/client-capability/src/lib.rs
@@ -19,59 +19,20 @@
 
 //! Ephemeral client capability ownership; durable execution remains in the log.
 pub mod broker;
-mod contract;
 mod endpoint;
 mod managed;
 mod registry;
-pub use contract::ContractId;
 pub use managed::ManagedAdmissionError;
 mod tool_name;
 pub use tool_name::proxy_tool_name;
 
 pub use endpoint::Endpoint;
 pub use registry::{
-    BindingError, BindingMode, PreparedBindings, Registration, Registry, Snapshot, SnapshotOffer,
+    BindingError, BindingMode, PreparedBindings, Registration, Registry, RestoredBindings,
+    Snapshot, SnapshotOffer,
 };
 
-use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
-use maka_runtime::access::CapabilityOwnerIdentity;
-use serde::Serialize;
-use sha2::{Digest, Sha256};
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum PrincipalKind {
-    LocalOwner,
-    RemoteOwner,
-    CapabilityProvider,
-}
-
-/// Authentication-derived identity. The hello client ID is not credential proof.
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct Identity {
-    pub principal_kind: PrincipalKind,
-    pub principal_id: String,
-    pub client_instance_id: String,
-    pub credential_bound_client_instance_id: Option<String>,
-    pub capability_owner: Option<CapabilityOwnerIdentity>,
-}
-
-impl Identity {
-    pub fn provider_id(&self) -> String {
-        let value = serde_json::to_vec(&(
-            "maka.client-capability-provider.v1",
-            self.principal_kind,
-            &self.principal_id,
-            &self.client_instance_id,
-        ))
-        .expect("identity contains only strings");
-        format!("cc_{}", URL_SAFE_NO_PAD.encode(Sha256::digest(value)))
-    }
-
-    pub fn trusted(&self) -> bool {
-        self.principal_kind != PrincipalKind::RemoteOwner
-    }
-}
+pub use maka_runtime::capability::{ContractId, Identity, PrincipalKind};
 
 #[derive(Debug, thiserror::Error, PartialEq, Eq)]
 pub enum Error {
diff --git a/crates/client-capability/src/registry.rs b/crates/client-capability/src/registry.rs
index be99151dda..fd3d208794 100644
--- a/crates/client-capability/src/registry.rs
+++ b/crates/client-capability/src/registry.rs
@@ -27,7 +27,9 @@ use uuid::Uuid;
 
 mod bindings;
 mod publication;
-pub use bindings::{BindingError, BindingMode, PreparedBindings, Snapshot, SnapshotOffer};
+pub use bindings::{
+    BindingError, BindingMode, PreparedBindings, RestoredBindings, Snapshot, SnapshotOffer,
+};
 pub use publication::Registration;
 use publication::Residency;
 
diff --git a/crates/client-capability/src/registry/bindings.rs b/crates/client-capability/src/registry/bindings.rs
index ae237fb922..43cb106e18 100644
--- a/crates/client-capability/src/registry/bindings.rs
+++ b/crates/client-capability/src/registry/bindings.rs
@@ -19,6 +19,8 @@
 
 mod selection;
 pub use selection::PreparedBindings;
+mod restoration;
+pub use restoration::RestoredBindings;
 mod snapshot;
 pub use snapshot::{Snapshot, SnapshotOffer};
 
@@ -38,6 +40,8 @@ pub enum BindingMode {
 
 #[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
 pub enum BindingError {
+    #[error("Frozen Client Capability composition no longer matches provider authority")]
+    InvalidComposition,
     #[error("Session-bound Client Capability provider is unavailable")]
     Lost,
     #[error("Client Capability provider selection is ambiguous")]
diff --git a/crates/client-capability/src/registry/bindings/restoration.rs b/crates/client-capability/src/registry/bindings/restoration.rs
new file mode 100644
index 0000000000..5440234619
--- /dev/null
+++ b/crates/client-capability/src/registry/bindings/restoration.rs
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{
+    Binding, BindingError, ProviderRef, SessionBindings, Snapshot, SnapshotOffer, snapshot::Source,
+};
+use crate::{ContractId, Identity, Registry};
+use maka_runtime::capability::{Affinity, ClientComposition, ClientOffer, PinnedAffinity};
+use std::{
+    collections::{BTreeMap, HashSet},
+    sync::Arc,
+};
+
+/// Restores only the admitted surface. It never discovers additional tools.
+pub struct RestoredBindings {
+    session_id: String,
+    previous: Option<SessionBindings>,
+    selected: SessionBindings,
+    identities: BTreeMap<String, Arc<Identity>>,
+    snapshot: Snapshot,
+}
+
+impl Registry {
+    pub fn restore_bindings(
+        &self,
+        session_id: &str,
+        composition: &ClientComposition,
+    ) -> Result<(RestoredBindings, Snapshot), BindingError> {
+        if self.draining {
+            return Err(BindingError::Draining);
+        }
+        let mut identities = BTreeMap::new();
+        let mut selected = SessionBindings::default();
+        for (contract, identity) in &composition.session_bindings {
+            let provider = self.restore_identity(identity, &mut identities)?;
+            let available = self.current(&provider.id).is_some_and(|r| {
+                r.available()
+                    && r.offer(contract)
+                        .is_some_and(|o| o.affinity == Affinity::Session)
+            });
+            selected.session.insert(
+                contract.clone(),
+                if available {
+                    Binding::Bound(provider)
+                } else {
+                    Binding::Lost(provider)
+                },
+            );
+        }
+        let mut offers = Vec::with_capacity(composition.offers.len());
+        let mut names = HashSet::new();
+        let mut contracts = HashSet::new();
+        for entry in &composition.offers {
+            let (contract, source) = match entry {
+                ClientOffer::Pinned {
+                    contract,
+                    affinity,
+                    identity,
+                } => {
+                    let provider = self.restore_identity(identity, &mut identities)?;
+                    let registration = self
+                        .current(&provider.id)
+                        .filter(|r| r.available())
+                        .ok_or(BindingError::Lost)?;
+                    let offer = registration.offer(contract).ok_or(BindingError::Lost)?;
+                    match affinity {
+                        PinnedAffinity::Session => {
+                            if offer.affinity != Affinity::Session
+                                || selected.session.get(contract).map(Binding::provider)
+                                    != Some(&provider)
+                            {
+                                return Err(BindingError::InvalidComposition);
+                            }
+                        }
+                        PinnedAffinity::Turn => {
+                            if offer.affinity != Affinity::Turn {
+                                return Err(BindingError::InvalidComposition);
+                            }
+                            selected.turn.insert(contract.clone(), provider);
+                        }
+                    }
+                    (contract.clone(), Source::Pinned(registration))
+                }
+                ClientOffer::Call { offer, selector } => {
+                    // Durable decoding does not replace manifest admission validation.
+                    let manifest = serde_json::json!({"registrationId":"restore","offers":[offer]});
+                    if offer.affinity != Affinity::Call
+                        || maka_protocol::capability::decode_replace_input(&manifest).is_err()
+                    {
+                        return Err(BindingError::InvalidComposition);
+                    }
+                    let selector = selector
+                        .as_ref()
+                        .map(|identity| self.restore_identity(identity, &mut identities))
+                        .transpose()?;
+                    (
+                        ContractId::of(offer),
+                        Source::Call {
+                            offer: offer.clone(),
+                            selector,
+                        },
+                    )
+                }
+            };
+            if !contracts.insert(contract.clone()) {
+                return Err(BindingError::InvalidComposition);
+            }
+            let entry = SnapshotOffer { contract, source };
+            for tool in &entry.offer().tools {
+                if !names.insert(crate::proxy_tool_name(&tool.server_id, &tool.name)) {
+                    return Err(BindingError::Conflict);
+                }
+            }
+            offers.push(entry);
+        }
+        let snapshot = Snapshot { offers };
+        Ok((
+            RestoredBindings {
+                session_id: session_id.into(),
+                previous: self.sessions.get(session_id).cloned(),
+                selected,
+                identities,
+                snapshot: snapshot.clone(),
+            },
+            snapshot,
+        ))
+    }
+
+    /// Caller serializes this with canonical invocation admission. A stale
+    /// candidate has no effect, including no offline authentication records.
+    pub fn commit_restored_bindings(
+        &mut self,
+        restored: RestoredBindings,
+    ) -> Result<bool, BindingError> {
+        if self.draining {
+            return Err(BindingError::Draining);
+        }
+        if self.sessions.get(&restored.session_id) != restored.previous.as_ref() {
+            return Ok(false);
+        }
+        for (id, identity) in &restored.identities {
+            if let Some(provider) = self.providers.get(id) {
+                if provider.identity != *identity {
+                    return Err(BindingError::InvalidComposition);
+                }
+                // A concurrent attach created another identity owner. Reprepare
+                // so all references share the registry's authentication lifetime.
+                if !Arc::ptr_eq(&provider.identity, identity) {
+                    return Ok(false);
+                }
+            }
+        }
+        for entry in &restored.snapshot.offers {
+            if let Source::Pinned(pinned) = &entry.source
+                && (!pinned.available()
+                    || !self
+                        .current(pinned.provider_id())
+                        .is_some_and(|current| Arc::ptr_eq(&current, pinned)))
+            {
+                return Ok(false);
+            }
+        }
+        for (id, identity) in restored.identities {
+            self.providers.entry(id).or_insert(super::super::Provider {
+                identity,
+                residency: Arc::default(),
+                current: None,
+                active: None,
+            });
+        }
+        self.sessions.insert(restored.session_id, restored.selected);
+        Ok(true)
+    }
+
+    fn restore_identity(
+        &self,
+        identity: &Identity,
+        identities: &mut BTreeMap<String, Arc<Identity>>,
+    ) -> Result<ProviderRef, BindingError> {
+        if identity.capability_owner.is_some()
+            && identity.principal_kind != crate::PrincipalKind::CapabilityProvider
+        {
+            return Err(BindingError::InvalidComposition);
+        }
+        let id = identity.provider_id();
+        let existing = identities
+            .get(&id)
+            .cloned()
+            .or_else(|| self.providers.get(&id).map(|p| p.identity.clone()));
+        let stored = match existing {
+            Some(stored) if *stored != *identity => return Err(BindingError::InvalidComposition),
+            Some(stored) => stored,
+            None => Arc::new(identity.clone()),
+        };
+        identities.insert(id.clone(), stored.clone());
+        Ok(ProviderRef {
+            id,
+            identity: stored,
+        })
+    }
+}
diff --git a/crates/client-capability/src/registry/bindings/selection.rs b/crates/client-capability/src/registry/bindings/selection.rs
index ce1113a70a..58f680315e 100644
--- a/crates/client-capability/src/registry/bindings/selection.rs
+++ b/crates/client-capability/src/registry/bindings/selection.rs
@@ -36,6 +36,26 @@ pub struct PreparedBindings {
     snapshot: super::Snapshot,
 }
 
+impl PreparedBindings {
+    pub fn composition(&self) -> maka_runtime::capability::ClientComposition {
+        composition(&self.selected, &self.snapshot)
+    }
+}
+
+fn composition(
+    selected: &SessionBindings,
+    snapshot: &super::Snapshot,
+) -> maka_runtime::capability::ClientComposition {
+    maka_runtime::capability::ClientComposition {
+        session_bindings: selected
+            .session
+            .iter()
+            .map(|(contract, binding)| (contract.clone(), (*binding.provider().identity).clone()))
+            .collect(),
+        offers: snapshot.composition(),
+    }
+}
+
 impl Registry {
     pub fn prepare_bindings(
         &self,
@@ -88,7 +108,7 @@ impl Registry {
         session_id: &str,
         initiating: Uuid,
         required: &[&str],
-    ) -> Result<super::Snapshot, BindingError> {
+    ) -> Result<(super::Snapshot, maka_runtime::capability::ClientComposition), BindingError> {
         let next = self.select_bindings(
             self.sessions.get(session_id),
             Some(initiating),
@@ -123,10 +143,16 @@ impl Registry {
         if selected.is_none() || !missing.is_empty() {
             return Err(BindingError::RequiredProvider);
         }
-        let snapshot = self.snapshot_bindings(Some(&next))?;
+        let mut snapshot = self.snapshot_bindings(Some(&next))?;
+        snapshot.offers.retain(|entry| {
+            entry.offer().tools.iter().any(|tool| {
+                required.contains(&crate::proxy_tool_name(&tool.server_id, &tool.name).as_str())
+            })
+        });
+        let composition = composition(&next, &snapshot);
         self.sessions.insert(session_id.into(), next);
         self.prune_sessions();
-        Ok(snapshot)
+        Ok((snapshot, composition))
     }
 
     /// Select all bindings atomically. A failed strict selection changes no
diff --git a/crates/client-capability/src/registry/bindings/snapshot.rs b/crates/client-capability/src/registry/bindings/snapshot.rs
index 5a4778c5d8..f8416354c4 100644
--- a/crates/client-capability/src/registry/bindings/snapshot.rs
+++ b/crates/client-capability/src/registry/bindings/snapshot.rs
@@ -26,9 +26,31 @@ use std::{collections::HashSet, sync::Arc};
 
 #[derive(Clone)]
 pub struct Snapshot {
-    offers: Vec<SnapshotOffer>,
+    pub(super) offers: Vec<SnapshotOffer>,
 }
 impl Snapshot {
+    pub(super) fn composition(&self) -> Vec<maka_runtime::capability::ClientOffer> {
+        use maka_runtime::capability::{ClientOffer, PinnedAffinity};
+        self.offers
+            .iter()
+            .map(|entry| match &entry.source {
+                Source::Pinned(registration) => ClientOffer::Pinned {
+                    contract: entry.contract.clone(),
+                    affinity: match entry.offer().affinity {
+                        Affinity::Session => PinnedAffinity::Session,
+                        Affinity::Turn => PinnedAffinity::Turn,
+                        Affinity::Call => unreachable!("Call offers are not pinned"),
+                    },
+                    identity: (**registration.identity()).clone(),
+                },
+                Source::Call { offer, selector } => ClientOffer::Call {
+                    offer: offer.clone(),
+                    selector: selector.as_ref().map(|p| (*p.identity).clone()),
+                },
+            })
+            .collect()
+    }
+
     pub fn offers(&self) -> &[SnapshotOffer] {
         &self.offers
     }
@@ -57,11 +79,11 @@ impl Snapshot {
 
 #[derive(Clone)]
 pub struct SnapshotOffer {
-    contract: ContractId,
-    source: Source,
+    pub(super) contract: ContractId,
+    pub(super) source: Source,
 }
 #[derive(Clone)]
-enum Source {
+pub(super) enum Source {
     Pinned(Arc<Registration>),
     Call {
         offer: Offer,
diff --git a/crates/client-capability/tests/bindings.rs b/crates/client-capability/tests/bindings.rs
index 0192b04504..74bcb8db78 100644
--- a/crates/client-capability/tests/bindings.rs
+++ b/crates/client-capability/tests/bindings.rs
@@ -51,6 +51,115 @@ fn attach(
     (id, provider, out)
 }
 
+#[test]
+fn restart_restores_identity_ceiling_without_discovery_or_call_pinning() {
+    for initiating in [false, true] {
+        let mut old = Registry::default();
+        let owner = identity("desktop");
+        let (connection, _, _out) = attach(&mut old, owner.clone());
+        old.replace(connection, manifest("old", &["session", "turn", "call"]))
+            .unwrap();
+        let (prepared, _) = old
+            .prepare_bindings("s", initiating.then_some(connection), BindingMode::Strict)
+            .unwrap();
+        let composition =
+            serde_json::from_value(serde_json::to_value(prepared.composition()).unwrap()).unwrap();
+        drop(old);
+
+        let mut registry = Registry::default();
+        assert!(matches!(
+            registry.restore_bindings("s", &composition),
+            Err(BindingError::Lost)
+        ));
+        let (other, _, _other_out) = attach(&mut registry, identity("another-desktop"));
+        registry
+            .replace(other, manifest("other", &["session", "turn", "call"]))
+            .unwrap();
+        assert!(matches!(
+            registry.restore_bindings("s", &composition),
+            Err(BindingError::Lost)
+        ));
+        let (connection, _, _out) = attach(&mut registry, owner);
+        registry
+            .replace(
+                connection,
+                manifest("current", &["session", "turn", "call"]),
+            )
+            .unwrap();
+        let (prepared, _) = registry.restore_bindings("s", &composition).unwrap();
+        registry
+            .replace(
+                connection,
+                manifest("replaced", &["session", "turn", "call"]),
+            )
+            .unwrap();
+        assert!(!registry.commit_restored_bindings(prepared).unwrap());
+        let (prepared, restored) = registry.restore_bindings("s", &composition).unwrap();
+        assert!(registry.commit_restored_bindings(prepared).unwrap());
+        assert_eq!(restored.offers().len(), 3);
+        let call = restored
+            .offers()
+            .iter()
+            .find(|o| o.offer().offer_id == "call")
+            .unwrap();
+        if initiating {
+            assert_eq!(call.resolve(&registry).unwrap().connection_id(), connection);
+        } else {
+            assert!(matches!(
+                call.resolve(&registry),
+                Err(BindingError::Ambiguous)
+            ));
+        }
+        registry.detach(connection);
+        if initiating {
+            assert!(matches!(call.resolve(&registry), Err(BindingError::Lost)));
+        } else {
+            assert_eq!(call.resolve(&registry).unwrap().connection_id(), other);
+        }
+        assert!(
+            restored
+                .offers()
+                .iter()
+                .filter(|o| o.offer().affinity != maka_runtime::capability::Affinity::Call)
+                .all(|o| matches!(o.resolve(&registry), Err(BindingError::Lost)))
+        );
+    }
+
+    // A lost owner is absent from this Run's tools but remains an authentication
+    // boundary, even if it reconnects before or after restoration.
+    let mut old = Registry::default();
+    let owner = identity("lost");
+    let (connection, _, _out) = attach(&mut old, owner.clone());
+    old.replace(connection, manifest("old", &["session"]))
+        .unwrap();
+    old.bind_session("s", Some(connection), BindingMode::Strict)
+        .unwrap();
+    old.detach(connection);
+    let (prepared, _) = old
+        .prepare_bindings("s", None, BindingMode::Degrade)
+        .unwrap();
+    let proof = prepared.composition();
+    assert_eq!(proof.session_bindings.len(), 1);
+    assert!(proof.offers.is_empty());
+    drop(old);
+    let mut registry = Registry::default();
+    let (prepared, restored) = registry.restore_bindings("s", &proof).unwrap();
+    assert!(registry.commit_restored_bindings(prepared).unwrap());
+    let mut changed = owner.clone();
+    changed.credential_bound_client_instance_id = Some("lost".into());
+    let (endpoint, _out) = Endpoint::channel(32);
+    assert!(registry.attach(Uuid::new_v4(), changed, endpoint).is_err());
+    let (connection, _, _out) = attach(&mut registry, owner);
+    registry
+        .replace(connection, manifest("new", &["session"]))
+        .unwrap();
+    assert!(restored.offers().is_empty());
+    assert_eq!(registry.snapshot("s").unwrap().offers().len(), 1);
+    let (prepared, restored) = registry.restore_bindings("s", &proof).unwrap();
+    assert!(registry.commit_restored_bindings(prepared).unwrap());
+    assert!(restored.offers().is_empty());
+}
+
 #[test]
 fn session_loss_restore_retirement_and_run_pins_are_distinct() {
     let mut registry = Registry::default();
@@ -251,7 +360,7 @@ fn required_tools_commit_only_one_session_owner_and_failed_selection_leaves_no_b
     registry
         .replace(second, offers("complete", "session", &["control", "tasks"]))
         .unwrap();
-    let snapshot = registry
+    let (snapshot, _) = registry
         .bind_required_tools("fresh", second, &required)
         .unwrap();
     assert_eq!(snapshot.offers().len(), 2);
diff --git a/crates/event-log/tests/continuation/fixtures.rs b/crates/event-log/tests/continuation/fixtures.rs
index e48880a743..80c7973e75 100644
--- a/crates/event-log/tests/continuation/fixtures.rs
+++ b/crates/event-log/tests/continuation/fixtures.rs
@@ -30,6 +30,7 @@ pub(super) fn opening(id: &str, claim: Option<ContinuationClaim>) -> RuntimeEven
         Fact::InvocationOpened {
             configuration: Some(InvocationConfiguration {
                 system_prompt: None,
+                tool_composition: None,
                 cwd: ".".into(),
                 workspace_identity: Some(
                     WorkspaceIdentity::from_marker_id("ef751105-55b5-4d65-a364-646281586a17")
diff --git a/crates/runtime-host/src/execution/prepare.rs b/crates/runtime-host/src/execution/prepare.rs
index a5fb71917b..521de97b07 100644
--- a/crates/runtime-host/src/execution/prepare.rs
+++ b/crates/runtime-host/src/execution/prepare.rs
@@ -110,6 +110,7 @@ impl Executions {
         .await?;
         let mut configuration = session.invocation_configuration().await.map_err(internal)?;
         configuration.system_prompt = Some(environment.prompt);
+        configuration.tool_composition = Some(environment.composition);
         let (tools, skills) = (environment.tools, environment.skills);
         let max_steps = usize::try_from(input.max_steps.unwrap_or(64)).map_err(internal)?;
         let invocation = Invocation {
diff --git a/crates/runtime-host/src/execution/prepare/environment.rs b/crates/runtime-host/src/execution/prepare/environment.rs
index 8f9a8e2b30..57c70a18b4 100644
--- a/crates/runtime-host/src/execution/prepare/environment.rs
+++ b/crates/runtime-host/src/execution/prepare/environment.rs
@@ -32,6 +32,7 @@ pub(crate) struct Environment {
     pub tools: maka_tools::ToolCatalog,
     pub skills: Arc<super::super::skills::FrozenSkills>,
     pub prompt: SystemPrompt,
+    pub composition: maka_runtime::execution::ToolComposition,
     directory: maka_fs_tools::workspace::directory::PublishedDirectory,
 }
 
@@ -99,13 +100,14 @@ impl Executions {
         let mut prompt = prompt.map_err(internal)?;
         let native = self.native_tools(&session.workspace.host_cwd, session.tool_profile);
         let mode = session.permission_mode;
-        let (directory, tools, skills) = tokio::task::spawn_blocking(move || {
+        let (directory, tools, skills, skills_digest) = tokio::task::spawn_blocking(move || {
             let directory = maka_fs_tools::workspace::directory::PublishedDirectory::open(
                 std::path::Path::new(&native.cwd),
             )
             .map_err(internal)?;
             let (tools, skills) = tools::catalog(native, mode, additional, skills)?;
-            Ok::<_, maka_protocol::OperationError>((directory, tools, skills))
+            let skills_digest = skills.catalog().fingerprint().map_err(internal)?;
+            Ok::<_, maka_protocol::OperationError>((directory, tools, skills, skills_digest))
         })
         .await
         .map_err(internal)??;
@@ -117,6 +119,10 @@ impl Executions {
             prompt.text.push_str(&fragment);
         }
         Ok(Environment {
+            composition: maka_runtime::execution::ToolComposition {
+                clients: bindings.composition(),
+                skills_digest: Some(skills_digest),
+            },
             digest: record.configuration_digest,
             bindings: Some(bindings),
             session,
diff --git a/crates/runtime-host/src/execution/resume/prepare.rs b/crates/runtime-host/src/execution/resume/prepare.rs
index 50e5601690..dc0cca38d4 100644
--- a/crates/runtime-host/src/execution/resume/prepare.rs
+++ b/crates/runtime-host/src/execution/resume/prepare.rs
@@ -52,7 +52,10 @@ impl Executions {
         };
         let mut configuration = session.observed_configuration(workspace);
         let (tools, system_prompt) = match mode {
-            Mode::Prepared(environment) => (environment.tools, environment.prompt),
+            Mode::Prepared(environment) => {
+                configuration.tool_composition = Some(environment.composition);
+                (environment.tools, environment.prompt)
+            }
             Mode::Observe(connection) => {
                 let mut system_prompt = prompt::resolve(
                     self.configuration
diff --git a/crates/runtime-host/src/execution/workhub.rs b/crates/runtime-host/src/execution/workhub.rs
index bef4f738a7..faa97633d5 100644
--- a/crates/runtime-host/src/execution/workhub.rs
+++ b/crates/runtime-host/src/execution/workhub.rs
@@ -183,7 +183,7 @@ impl Executions {
         let content = input.content();
         self.validate_message_content(COORDINATION_SESSION_ID, &content, root_id)
             .await?;
-        let tools = profile::tools(self, &session.configuration, connection_id)?;
+        let (tools, composition) = profile::tools(self, &session.configuration, connection_id)?;
         let provider = provider::resolve(
             &self.configuration,
             &self.oauth,
@@ -197,6 +197,7 @@ impl Executions {
             .await
             .map_err(internal)?;
         configuration.system_prompt = Some(profile::prompt());
+        configuration.tool_composition = Some(composition);
         let run = RunInput {
             invocation: Invocation {
                 session_id: COORDINATION_SESSION_ID.into(),
diff --git a/crates/runtime-host/src/execution/workhub/profile.rs b/crates/runtime-host/src/execution/workhub/profile.rs
index 63328629d9..9820a33d36 100644
--- a/crates/runtime-host/src/execution/workhub/profile.rs
+++ b/crates/runtime-host/src/execution/workhub/profile.rs
@@ -37,8 +37,8 @@ pub(super) fn tools(
     executions: &Executions,
     session: &SessionConfiguration,
     connection_id: Uuid,
-) -> Result<ToolCatalog> {
-    let mut tools = executions
+) -> Result<(ToolCatalog, maka_runtime::execution::ToolComposition)> {
+    let (mut tools, clients) = executions
         .capabilities
         .bind_required_tools(
             COORDINATION_SESSION_ID,
@@ -71,7 +71,13 @@ pub(super) fn tools(
         nesting: ToolNesting::Nestable,
         semantics: ToolSemantics::Parallel,
     });
-    ToolCatalog::new(tools).map_err(|e| failure(Code::OperationUnavailable, &e.to_string()))
+    Ok((
+        ToolCatalog::new(tools).map_err(|e| failure(Code::OperationUnavailable, &e.to_string()))?,
+        maka_runtime::execution::ToolComposition {
+            clients,
+            skills_digest: None,
+        },
+    ))
 }
 
 pub(super) fn prompt() -> SystemPrompt {
diff --git a/crates/runtime-host/src/server/capabilities.rs b/crates/runtime-host/src/server/capabilities.rs
index 02318c9b3a..00987027cc 100644
--- a/crates/runtime-host/src/server/capabilities.rs
+++ b/crates/runtime-host/src/server/capabilities.rs
@@ -123,20 +123,29 @@ impl Capabilities {
         required: &[&str],
         cwd: String,
         interactions: Arc<dyn maka_tools::ClientInteractions>,
-    ) -> Result<Vec<ToolRegistration>, BindingError> {
-        let snapshot = self
+    ) -> Result<
+        (
+            Vec<ToolRegistration>,
+            maka_runtime::capability::ClientComposition,
+        ),
+        BindingError,
+    > {
+        let (snapshot, composition) = self
             .registry
             .lock()
             .unwrap_or_else(|e| e.into_inner())
             .bind_required_tools(session_id, connection_id, required)?;
-        Ok(ClientTools::new(
-            snapshot,
-            self.registry.clone(),
-            self.broker.clone(),
-            cwd,
-            interactions,
-        )
-        .registrations())
+        Ok((
+            ClientTools::new(
+                snapshot,
+                self.registry.clone(),
+                self.broker.clone(),
+                cwd,
+                interactions,
+            )
+            .registrations(),
+            composition,
+        ))
     }
 
     pub(super) fn begin_drain(&self) {
diff --git a/crates/runtime-host/src/session.rs b/crates/runtime-host/src/session.rs
index c1a8c6e5d8..7b8682664a 100644
--- a/crates/runtime-host/src/session.rs
+++ b/crates/runtime-host/src/session.rs
@@ -74,6 +74,7 @@ impl SessionConfiguration {
     ) -> maka_runtime::execution::InvocationConfiguration {
         maka_runtime::execution::InvocationConfiguration {
             system_prompt: None,
+            tool_composition: None,
             cwd: self.workspace.host_cwd.clone(),
             workspace_identity: Some(workspace_identity),
             permission_mode: self.permission_mode,
diff --git a/crates/runtime-host/tests/integration/resume.rs b/crates/runtime-host/tests/integration/resume.rs
index acb5f8d7d1..49983464ef 100644
--- a/crates/runtime-host/tests/integration/resume.rs
+++ b/crates/runtime-host/tests/integration/resume.rs
@@ -61,6 +61,19 @@ async fn original_client_resumes_selected_lineage_streams_and_reopens_exact_admi
             1
         );
         let facts = serde_json::to_value(&prefix.events).unwrap();
+        for stored in &prefix.events {
+            if let Fact::InvocationOpened {
+                configuration: Some(configuration),
+                ..
+            } = &stored.event.fact
+            {
+                let composition = configuration
+                    .tool_composition
+                    .as_ref()
+                    .expect("Message and manual resume must freeze admitted Host handlers");
+                assert!(composition.skills_digest.is_some());
+            }
+        }
         if let Some(original) = &original {
             assert_eq!(&facts, original);
         } else {
diff --git a/crates/runtime-host/tests/integration/workhub.rs b/crates/runtime-host/tests/integration/workhub.rs
index 9387778079..be848ac17b 100644
--- a/crates/runtime-host/tests/integration/workhub.rs
+++ b/crates/runtime-host/tests/integration/workhub.rs
@@ -241,6 +241,29 @@ async fn original_client_workhub_answer_scopes_read_and_desktop_calls_without_re
     let log = fixture.log().await;
     let before = log.prefix(256, 512 * 1024).await.unwrap();
     use maka_runtime::event::Fact;
+    let composition = before
+        .events
+        .iter()
+        .find_map(|row| match &row.event.fact {
+            Fact::InvocationOpened {
+                configuration: Some(configuration),
+                ..
+            } => configuration.tool_composition.as_ref(),
+            _ => None,
+        })
+        .expect("WorkHub must retain its admitted Desktop owner");
+    assert!(
+        composition.skills_digest.is_none(),
+        "WorkHub has no Skills handlers"
+    );
+    assert!(!composition.clients.offers.is_empty());
+    assert!(composition.clients.offers.iter().all(|offer| matches!(
+        offer,
+        maka_runtime::capability::ClientOffer::Pinned {
+            affinity: maka_runtime::capability::PinnedAffinity::Session,
+            ..
+        }
+    )));
     assert_eq!(
         before
             .events
diff --git a/crates/runtime/src/capability.rs b/crates/runtime/src/capability.rs
index d37449a780..16aca03d21 100644
--- a/crates/runtime/src/capability.rs
+++ b/crates/runtime/src/capability.rs
@@ -17,11 +17,17 @@
  * under the License.
  */
 
+mod contract;
 pub mod form;
 pub mod form_decode;
 pub(crate) mod form_result_decode;
 mod form_validation;
 pub mod json;
+pub use contract::ContractId;
+mod composition;
+pub use composition::{ClientComposition, ClientOffer, PinnedAffinity};
+mod identity;
+pub use identity::{Identity, PrincipalKind};
 mod result;
 pub use form::{
     FormField, FormFieldSpec, FormFormat, FormInput, FormOption, FormRequester, FormResult,
@@ -31,10 +37,10 @@ mod frame;
 pub use frame::{AdmissionEvidence, ClientFrame, HostFrame};
 pub use result::{CallResult, ContentBlock};
 
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
 
-#[derive(Clone, Debug, PartialEq, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct Manifest {
     pub registration_id: String,
@@ -43,7 +49,7 @@ pub struct Manifest {
     pub services: Option<Vec<ServiceOffer>>,
 }
 
-#[derive(Clone, Debug, PartialEq, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct Offer {
     pub offer_id: String,
@@ -56,14 +62,14 @@ pub struct Offer {
     pub tools: Vec<ToolDescriptor>,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ServiceOffer {
     pub service_id: String,
     pub version: String,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum Affinity {
     Call,
@@ -71,14 +77,14 @@ pub enum Affinity {
     Session,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum HostPathAccess {
     None,
     Cwd,
 }
 
-#[derive(Clone, Debug, PartialEq, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ToolDescriptor {
     pub server_id: String,
@@ -92,7 +98,7 @@ pub struct ToolDescriptor {
     pub activity_kind: Option<ToolActivityKind>,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ToolAnnotations {
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -107,7 +113,7 @@ pub struct ToolAnnotations {
     pub open_world_hint: Option<bool>,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum ToolActivityKind {
     Computer,
diff --git a/crates/runtime/src/capability/composition.rs b/crates/runtime/src/capability/composition.rs
new file mode 100644
index 0000000000..d4c2e958e9
--- /dev/null
+++ b/crates/runtime/src/capability/composition.rs
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{ContractId, Identity, Offer};
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+/// Frozen selection evidence, not credentials, publication residency, or grants.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ClientComposition {
+    /// Includes unavailable Session owners, which still constrain future binding.
+    pub session_bindings: BTreeMap<ContractId, Identity>,
+    pub offers: Vec<ClientOffer>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum ClientOffer {
+    Pinned {
+        contract: ContractId,
+        affinity: PinnedAffinity,
+        identity: Identity,
+    },
+    Call {
+        offer: Offer,
+        selector: Option<Identity>,
+    },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PinnedAffinity {
+    Session,
+    Turn,
+}
diff --git a/crates/client-capability/src/contract.rs b/crates/runtime/src/capability/contract.rs
similarity index 94%
rename from crates/client-capability/src/contract.rs
rename to crates/runtime/src/capability/contract.rs
index 7a51ea0d15..0eb3426367 100644
--- a/crates/client-capability/src/contract.rs
+++ b/crates/runtime/src/capability/contract.rs
@@ -17,14 +17,14 @@
  * under the License.
  */
 
-use maka_runtime::capability::Offer;
-use serde::Serialize;
+use super::Offer;
+use serde::{Deserialize, Serialize};
 use serde_json::Value;
 use sha2::{Digest, Sha256};
 
 /// Stable semantic offer identity, distinct from provider and registration IDs.
 /// These host-owned IDs survive restarts; UI consumers also recognize the suffix.
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
 #[serde(transparent)]
 pub struct ContractId(String);
 
@@ -33,7 +33,7 @@ impl ContractId {
         &self.0
     }
 
-    pub(crate) fn of(offer: &Offer) -> Self {
+    pub fn of(offer: &Offer) -> Self {
         let mut tools: Vec<_> = offer.tools.iter().collect();
         tools.sort_by(|a, b| (&a.server_id, &a.name).cmp(&(&b.server_id, &b.name)));
         let value = serde_json::json!({
@@ -93,12 +93,11 @@ fn write_canonical(value: &Value, output: &mut String) {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use maka_protocol::capability::decode_replace_input;
     use serde_json::json;
 
     #[test]
     fn identity_tracks_execution_contract_but_not_display_or_json_spelling() {
-        let mut offer = decode_replace_input(&json!({
+        let mut offer = serde_json::from_value::<super::super::Manifest>(json!({
             "registrationId":"r","offers":[{"offerId":"desktop_browser",
                 "version":"1","affinity":"session","hostPathAccess":"none","label":"Browser",
                 "tools":[
@@ -137,7 +136,7 @@ mod tests {
             },
             {
                 let mut v = offer.clone();
-                v.host_path_access = maka_runtime::capability::HostPathAccess::Cwd;
+                v.host_path_access = crate::capability::HostPathAccess::Cwd;
                 v
             },
         ] {
diff --git a/crates/client-capability/src/lib.rs b/crates/runtime/src/capability/identity.rs
similarity index 64%
copy from crates/client-capability/src/lib.rs
copy to crates/runtime/src/capability/identity.rs
index 66b64c9d92..8471588f89 100644
--- a/crates/client-capability/src/lib.rs
+++ b/crates/runtime/src/capability/identity.rs
@@ -16,29 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-
-//! Ephemeral client capability ownership; durable execution remains in the log.
-pub mod broker;
-mod contract;
-mod endpoint;
-mod managed;
-mod registry;
-pub use contract::ContractId;
-pub use managed::ManagedAdmissionError;
-mod tool_name;
-pub use tool_name::proxy_tool_name;
-
-pub use endpoint::Endpoint;
-pub use registry::{
-    BindingError, BindingMode, PreparedBindings, Registration, Registry, Snapshot, SnapshotOffer,
-};
-
+use crate::access::CapabilityOwnerIdentity;
 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
-use maka_runtime::access::CapabilityOwnerIdentity;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use sha2::{Digest, Sha256};
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum PrincipalKind {
     LocalOwner,
@@ -46,8 +29,10 @@ pub enum PrincipalKind {
     CapabilityProvider,
 }
 
-/// Authentication-derived identity. The hello client ID is not credential proof.
-#[derive(Clone, Debug, PartialEq, Eq)]
+/// Authentication-derived identity. A saved identity is a comparison proof,
+/// never a credential or permission to restore a disconnected publication.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
 pub struct Identity {
     pub principal_kind: PrincipalKind,
     pub principal_id: String,
@@ -72,13 +57,3 @@ impl Identity {
         self.principal_kind != PrincipalKind::RemoteOwner
     }
 }
-
-#[derive(Debug, thiserror::Error, PartialEq, Eq)]
-pub enum Error {
-    #[error("Client Capability registry is draining")]
-    Draining,
-    #[error("Client Capability reverse channel is unavailable")]
-    Unavailable,
-    #[error("Invalid Client Capability registration: {0}")]
-    Invalid(&'static str),
-}
diff --git a/crates/runtime/src/execution.rs b/crates/runtime/src/execution.rs
index 72268cc85a..71000d3d37 100644
--- a/crates/runtime/src/execution.rs
+++ b/crates/runtime/src/execution.rs
@@ -94,6 +94,8 @@ impl SystemPrompt {
 /// Later Session mutations must not reinterpret relative paths or grants.
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct InvocationConfiguration {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tool_composition: Option<ToolComposition>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub system_prompt: Option<SystemPrompt>,
     pub cwd: String,
@@ -109,6 +111,15 @@ pub struct InvocationConfiguration {
     pub thinking_level: Option<ThinkingLevel>,
 }
 
+/// Admission evidence for Host-owned handlers whose behavior is not in schema.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ToolComposition {
+    pub clients: crate::capability::ClientComposition,
+    /// None for fixed profiles without Skill / SkillSearch handlers.
+    pub skills_digest: Option<String>,
+}
+
 /// User-selected workspace locator, before Host path resolution.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(
diff --git a/crates/skills/src/catalog.rs b/crates/skills/src/catalog.rs
index 9210952f1f..5e5babdd26 100644
--- a/crates/skills/src/catalog.rs
+++ b/crates/skills/src/catalog.rs
@@ -21,7 +21,7 @@ use crate::{DiscoveredSkill, DiscoverySnapshot, Issue, IssueCode, Severity, fiel
 use maka_runtime::skills::SkillFailureReason;
 pub use maka_runtime::skills::SkillPreference as Preference;
 use std::{
-    collections::{BTreeMap, HashMap, HashSet},
+    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
     path::PathBuf,
 };
 
@@ -54,6 +54,50 @@ pub struct LoadedInstructions<'a> {
 }
 
 impl Catalog<'_> {
+    /// Fingerprint the frozen inputs used by Skill and SkillSearch. Rejected
+    /// files and discovery diagnostics are UI facts, not executable catalog input.
+    pub fn fingerprint(&self) -> Result<String, serde_json::Error> {
+        let inventory: Vec<_> = self
+            .discovery
+            .inventory
+            .iter()
+            .map(|skill| {
+                let location = &skill.location;
+                (
+                    &location.reference,
+                    &location.id,
+                    &location.path,
+                    &location.discovery_root,
+                    &location.scope,
+                    &location.source,
+                    location.precedence,
+                    &skill.content_sha256,
+                    &skill.document.manifest,
+                    &skill.shadowed_by,
+                )
+            })
+            .collect();
+        let preferences = match self.preferences {
+            Preferences::Available(values) => Some(
+                values
+                    .iter()
+                    .map(|(id, preference)| (id, preference.enabled, preference.pinned))
+                    .collect::<Vec<_>>(),
+            ),
+            Preferences::Unavailable => None,
+        };
+        let tools: BTreeSet<_> = self.host.tools.iter().collect();
+        let capabilities: BTreeSet<_> = self.host.capabilities.iter().collect();
+        serde_json::to_vec(&(
+            "maka.skills.execution.v1",
+            inventory,
+            preferences,
+            tools,
+            capabilities,
+        ))
+        .map(|bytes| maka_runtime::artifact::content_digest(&bytes))
+    }
+
     pub fn available(&self) -> impl Iterator<Item = &DiscoveredSkill> {
         self.discovery.inventory.iter().filter(|skill| {
             skill.shadowed_by.is_none() && self.enabled(skill) && self.compatible(skill)
diff --git a/crates/skills/tests/invocation.rs b/crates/skills/tests/invocation.rs
index 788dcaf051..b884e244bb 100644
--- a/crates/skills/tests/invocation.rs
+++ b/crates/skills/tests/invocation.rs
@@ -76,6 +76,65 @@ fn prepare(catalog: &Catalog<'_>, text: &str, ids: &[String]) -> Value {
     json!({"prepared":projected,"references":inline_references(&result.receipts, text)})
 }
 
+#[test]
+fn execution_fingerprint_covers_frozen_content_and_resolution_inputs() {
+    let mut discovery = DiscoverySnapshot {
+        inventory: vec![skill("review", "Review", "old instructions")],
+        rejected: Vec::new(),
+        diagnostics: Vec::new(),
+    };
+    let mut preferences = Preferences::Available(BTreeMap::new());
+    let mut host = HostCapabilities {
+        tools: ["Read".into(), "Shell".into()].into(),
+        capabilities: ["network".into(), "files".into()].into(),
+    };
+    let digest =
+        |discovery: &DiscoverySnapshot, preferences: &Preferences, host: &HostCapabilities| {
+            Catalog {
+                discovery,
+                preferences,
+                host,
+            }
+            .fingerprint()
+            .unwrap()
+        };
+    let original = digest(&discovery, &preferences, &host);
+    host.tools = ["Shell".into(), "Read".into()].into();
+    host.capabilities = ["files".into(), "network".into()].into();
+    assert_eq!(digest(&discovery, &preferences, &host), original);
+    assert_ne!(
+        digest(&discovery, &Preferences::Unavailable, &host),
+        original
+    );
+
+    // Discovery computes this digest over the complete SKILL.md bytes.
+    discovery.inventory[0].document.body = "new instructions".into();
+    discovery.inventory[0].content_sha256 =
+        maka_runtime::artifact::content_digest(b"new SKILL.md bytes");
+    let content = digest(&discovery, &preferences, &host);
+    assert_ne!(
+        content, original,
+        "unchanged tool schemas cannot hide changed instructions"
+    );
+    discovery.inventory[0].location.path = "/moved/review".into();
+    let moved = digest(&discovery, &preferences, &host);
+    assert_ne!(
+        moved, content,
+        "relative references depend on the discovered path"
+    );
+    preferences = Preferences::Available(BTreeMap::from([(
+        "project:maka:review".into(),
+        Preference {
+            enabled: false,
+            pinned: false,
+        },
+    )]));
+    let disabled = digest(&discovery, &preferences, &host);
+    assert_ne!(disabled, moved);
+    host.capabilities.remove("network");
+    assert_ne!(digest(&discovery, &preferences, &host), disabled);
+}
+
 #[test]
 fn explicit_preparation_and_receipt_wire_match_current_source() {
     let mut discovery = DiscoverySnapshot {
diff --git a/crates/tools/tests/integration/client_preparation.rs b/crates/tools/tests/integration/client_preparation.rs
index b622c2fe86..a165acb132 100644
--- a/crates/tools/tests/integration/client_preparation.rs
+++ b/crates/tools/tests/integration/client_preparation.rs
@@ -149,6 +149,7 @@ async fn run(mode: ToolMode, cut: Cut, cells: CodeExecutor) {
     let configuration = InvocationConfiguration {
         workspace_identity: None,
         system_prompt: None,
+        tool_composition: None,
         cwd: directory.path().to_str().unwrap().into(),
         permission_mode: if cut == Cut::Explore {
             PermissionMode::Explore


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 20/29: feat(host): activate native account services across platforms

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit b8a9cbf1e081c9f4ffb9d523fb644cd71eedc1a7
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 20:45:41 2026 +0800

    feat(host): activate native account services across platforms
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                   |  87 +++++++++++
 Cargo.toml                                   |   4 +
 crates/cli/Cargo.toml                        |   1 +
 crates/cli/src/args.rs                       |  12 +-
 crates/cli/src/deployment.rs                 |   1 +
 crates/cli/src/deployment/activation.rs      |  63 ++++----
 crates/cli/src/deployment/service.rs         | 132 +++++++++++++++++
 crates/cli/src/deployment/service/linux.rs   | 107 ++++++++++++++
 crates/cli/src/deployment/service/macos.rs   | 132 +++++++++++++++++
 crates/cli/src/deployment/service/windows.rs | 210 +++++++++++++++++++++++++++
 crates/cli/src/deployment/update.rs          |   7 +-
 crates/cli/src/main.rs                       |   8 +-
 crates/cli/src/serve.rs                      |  17 ++-
 crates/cli/tests/integration/deployment.rs   |  17 ++-
 docs/rust-runtime.md                         |  16 +-
 docs/rust-runtime.zh-CN.md                   |  10 +-
 16 files changed, 769 insertions(+), 55 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index a3988a7610..ae478ffddc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3955,6 +3955,7 @@ dependencies = [
  "tokio",
  "tokio-util",
  "uuid",
+ "windows",
  "windows-sys 0.61.2",
 ]
 
@@ -7197,12 +7198,89 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "windows-link"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
 
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
 [[package]]
 name = "windows-registry"
 version = "0.6.1"
@@ -7275,6 +7353,15 @@ dependencies = [
  "windows_x86_64_msvc",
 ]
 
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
 [[package]]
 name = "windows_aarch64_gnullvm"
 version = "0.52.6"
diff --git a/Cargo.toml b/Cargo.toml
index 17e77613ad..5ce2171e1e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -70,3 +70,7 @@ tokio-util = { version = "0.7", features = ["rt"] }
 uuid = { version = "1", features = ["v4"] }
 
 tempfile = "3"
+
+[profile.dev]
+# Keep useful backtraces without embedding hundreds of MiB of dependency types.
+debug = "line-tables-only"
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 1775df4e1b..420cadd88e 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -61,4 +61,5 @@ tempfile.workspace = true
 libc.workspace = true
 
 [target.'cfg(windows)'.dependencies]
+windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_TaskScheduler", "Win32_System_Variant", "Win32_System_Ole"] }
 windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index e2d1401650..960f34bd09 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -53,7 +53,7 @@ enum HostCommand {
     Install(crate::deployment::Install),
     /// Activate an installed Host and report its verified loopback endpoint.
     Activate(crate::deployment::Activate),
-    /// Update an on-demand deployment to this executable at a safe boundary.
+    /// Update a deployment to this executable at a safe boundary.
     Update(crate::deployment::Update),
     /// Finish an interrupted deployment update without choosing another target.
     Reconcile(crate::deployment::Update),
@@ -71,6 +71,9 @@ enum HostCommand {
     },
     /// Run a discoverable ephemeral Host under its launcher.
     Candidate(candidate::Candidate),
+    /// Run only the currently admitted supervised deployment.
+    #[command(hide = true)]
+    ServiceRun(Root),
     /// Serve a native State Root over a private local endpoint.
     Serve {
         #[command(flatten)]
@@ -112,7 +115,7 @@ impl Cli {
         }
     }
 
-    pub(super) fn error_exit_code(&self) -> i32 {
+    pub(super) fn error_exit_code(&self) -> u8 {
         if matches!(self.command, Command::Host(HostCommand::Candidate(_))) {
             70
         } else {
@@ -135,6 +138,11 @@ impl Cli {
             Command::Host(HostCommand::Serve { root, websocket }) => {
                 serve::run(&root.root, websocket, false).await
             }
+            Command::Host(HostCommand::ServiceRun(root)) => {
+                #[cfg(windows)]
+                crate::windows::own_process_tree()?;
+                serve::run(&root.root, None, true).await
+            }
             Command::Host(HostCommand::Status(root)) => {
                 let mut client = crate::host_client::HostClient::connect(&root.root, None).await?;
                 let status = client.status().await?;
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index fdf4580619..be06af0294 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -19,6 +19,7 @@
 
 mod activation;
 mod package;
+mod service;
 mod store;
 mod update;
 mod updates;
diff --git a/crates/cli/src/deployment/activation.rs b/crates/cli/src/deployment/activation.rs
index 19d7973e60..ad2ba732cb 100644
--- a/crates/cli/src/deployment/activation.rs
+++ b/crates/cli/src/deployment/activation.rs
@@ -28,6 +28,7 @@ use serde::Serialize;
 use std::{
     io::Write,
     num::{NonZeroU16, NonZeroU32},
+    sync::Arc,
     time::Duration,
 };
 use tokio::io::AsyncWriteExt;
@@ -106,7 +107,7 @@ impl Activate {
         if !directory.is_dir() {
             return Err("Host deployment is not installed".into());
         }
-        let lease = FileLease::acquire(&directory.join("executor.lock"))?;
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
         let store::Installation::Installed(deployment) = store::read(&directory).await? else {
             return Err("Host deployment is absent or incomplete".into());
         };
@@ -116,7 +117,7 @@ impl Activate {
         }
         deployment.validate(&root, &directory)?;
         lease.validate()?;
-        let (client, live) = connect_or_launch(&deployment).await?;
+        let (client, live) = connect_or_launch(&deployment, lease.clone()).await?;
         lease.validate()?;
         emit(
             &Frame::Result {
@@ -143,8 +144,10 @@ impl Activate {
 
 pub(super) async fn connect_or_launch(
     deployment: &Deployment,
+    lease: Arc<FileLease>,
 ) -> Result<(HostClient, LiveHost), HostError> {
     let mut child: Option<Child> = None;
+    let mut service_started = false;
     let result = tokio::time::timeout(Duration::from_secs(30), async {
         loop {
             if let Ok(mut client) =
@@ -157,10 +160,7 @@ pub(super) async fn connect_or_launch(
                 if let Some(status) = child.try_wait()? {
                     return Err(format!("Host candidate exited before Ready: {status}").into());
                 }
-            } else {
-                if deployment.mode != Mode::OnDemand {
-                    return Err("the supervised Host must be started by its service".into());
-                }
+            } else if !service_started {
                 match RootOwner::open(
                     &deployment.root_path,
                     &RootNamespaces::for_current_account()?,
@@ -169,29 +169,34 @@ pub(super) async fn connect_or_launch(
                         if owner.root_id() != deployment.root_id {
                             return Err("State Root changed before launch".into());
                         }
-                        drop(owner);
-                        child = Some(
-                            detached::spawn(
-                                &deployment.executable,
-                                &[
-                                    "host",
-                                    "candidate",
-                                    "--root",
-                                    deployment
-                                        .root_path
-                                        .to_str()
-                                        .ok_or("State Root must be UTF-8")?,
-                                    "--expected-root-id",
-                                    &deployment.root_id,
-                                    "--startup-attempt-id",
-                                    &Uuid::new_v4().to_string(),
-                                    "--owner-stdin",
-                                    "--initial-connection-timeout-ms",
-                                    "30000",
-                                ],
-                            )
-                            .await?,
-                        );
+                        if deployment.mode == Mode::Supervised {
+                            super::service::start(deployment.clone(), lease.clone(), owner).await?;
+                            service_started = true;
+                        } else {
+                            drop(owner);
+                            child = Some(
+                                detached::spawn(
+                                    &deployment.executable,
+                                    &[
+                                        "host",
+                                        "candidate",
+                                        "--root",
+                                        deployment
+                                            .root_path
+                                            .to_str()
+                                            .ok_or("State Root must be UTF-8")?,
+                                        "--expected-root-id",
+                                        &deployment.root_id,
+                                        "--startup-attempt-id",
+                                        &Uuid::new_v4().to_string(),
+                                        "--owner-stdin",
+                                        "--initial-connection-timeout-ms",
+                                        "30000",
+                                    ],
+                                )
+                                .await?,
+                            );
+                        }
                     }
                     Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
                     Err(error) => return Err(error.into()),
diff --git a/crates/cli/src/deployment/service.rs b/crates/cli/src/deployment/service.rs
new file mode 100644
index 0000000000..636a697447
--- /dev/null
+++ b/crates/cli/src/deployment/service.rs
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::Deployment;
+use maka_event_log::root::{FileLease, RootOwner};
+use maka_runtime_host::server::HostError;
+use std::sync::Arc;
+
+#[cfg(target_os = "linux")]
+mod linux;
+#[cfg(target_os = "linux")]
+use linux as platform;
+#[cfg(target_os = "macos")]
+mod macos;
+#[cfg(target_os = "macos")]
+use macos as platform;
+#[cfg(windows)]
+mod windows;
+#[cfg(windows)]
+use windows as platform;
+
+/// The worker owns both leases until their actual effects finish, even if the
+/// caller stops awaiting it. A service definition is only a projection of Active.
+pub(super) async fn start(
+    deployment: Deployment,
+    lease: Arc<FileLease>,
+    owner: RootOwner,
+) -> Result<(), HostError> {
+    tokio::task::spawn_blocking(move || {
+        lease.validate()?;
+        owner.validate_current()?;
+        if owner.root_id() != deployment.root_id {
+            return Err("State Root changed before service activation".into());
+        }
+        let service = platform::Service::new(&deployment)?;
+        // Root is held: an OS restart cannot begin user work during replacement.
+        service.prepare()?;
+        owner.validate_current()?;
+        drop(owner);
+        service.start()?;
+        lease.validate()?;
+        Ok::<_, HostError>(())
+    })
+    .await?
+}
+
+fn arguments(deployment: &Deployment) -> Result<[&str; 5], HostError> {
+    let executable = deployment
+        .executable
+        .to_str()
+        .ok_or("service executable must be UTF-8")?;
+    let root = deployment
+        .root_path
+        .to_str()
+        .ok_or("service root must be UTF-8")?;
+    if executable.chars().chain(root.chars()).any(char::is_control) {
+        return Err("service paths cannot contain control characters".into());
+    }
+    Ok([executable, "host", "service-run", "--root", root])
+}
+
+fn label(deployment: &Deployment) -> String {
+    format!("org.apache.maka.host.{}", deployment.root_id)
+}
+
+#[cfg(any(target_os = "macos", windows))]
+fn xml(value: &str) -> String {
+    value
+        .replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+        .replace('\'', "&apos;")
+}
+
+#[cfg(unix)]
+fn command(program: &str, args: &[&str]) -> Result<std::process::Output, HostError> {
+    Ok(std::process::Command::new(program)
+        .args(args)
+        .stdin(std::process::Stdio::null())
+        .output()?)
+}
+
+#[cfg(unix)]
+fn checked(output: std::process::Output) -> Result<String, HostError> {
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        return Err(format!(
+            "service manager failed ({}): {}",
+            output.status,
+            stderr.chars().take(2048).collect::<String>()
+        )
+        .into());
+    }
+    Ok(String::from_utf8(output.stdout)?)
+}
+
+#[cfg(unix)]
+fn publish(path: &std::path::Path, contents: &str) -> Result<(), HostError> {
+    use std::io::Write;
+    let parent = path.parent().ok_or("service definition has no parent")?;
+    std::fs::create_dir_all(parent)?;
+    match path.symlink_metadata() {
+        Ok(metadata) if !metadata.is_file() => {
+            return Err("service definition is not a regular file".into());
+        }
+        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.into()),
+        _ => {}
+    }
+    let mut file = tempfile::NamedTempFile::new_in(parent)?;
+    file.write_all(contents.as_bytes())?;
+    file.as_file().sync_all()?;
+    file.persist(path)?;
+    std::fs::File::open(parent)?.sync_all()?;
+    Ok(())
+}
diff --git a/crates/cli/src/deployment/service/linux.rs b/crates/cli/src/deployment/service/linux.rs
new file mode 100644
index 0000000000..ce6f52109c
--- /dev/null
+++ b/crates/cli/src/deployment/service/linux.rs
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, HostError, arguments, checked, command, label, publish};
+use std::path::PathBuf;
+
+pub(super) struct Service {
+    unit: String,
+    path: PathBuf,
+    definition: String,
+}
+
+impl Service {
+    pub fn new(deployment: &Deployment) -> Result<Self, HostError> {
+        let unit = format!("{}.service", label(deployment));
+        let home = crate::serve::home_directory()?.ok_or("missing account home")?;
+        let config = std::env::var_os("XDG_CONFIG_HOME")
+            .filter(|path| !path.is_empty())
+            .map(PathBuf::from)
+            .unwrap_or_else(|| home.join(".config"));
+        if !config.is_absolute() {
+            return Err("XDG_CONFIG_HOME must be absolute".into());
+        }
+        let executable = arguments(deployment)?.map(quote).join(" ");
+        let definition = format!(
+            "[Unit]\nDescription=Maka Host\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nExecStart=:{executable}\nRestart=on-failure\nRestartSec=2\nKillMode=mixed\nTimeoutStopSec=45\nUMask=0077\n\n[Install]\nWantedBy=default.target\n"
+        );
+        Ok(Self {
+            path: config.join("systemd/user").join(&unit),
+            unit,
+            definition,
+        })
+    }
+
+    pub fn prepare(&self) -> Result<(), HostError> {
+        // An SSH logout must not destroy a persistent user service.
+        let uid = unsafe { libc::geteuid() }.to_string();
+        if checked(command(
+            "loginctl",
+            &["show-user", &uid, "--property=Linger", "--value"],
+        )?)?
+        .trim()
+            != "yes"
+        {
+            return Err(
+                format!("persistent user services require lingering for user {uid}").into(),
+            );
+        }
+        let loaded = checked(command(
+            "systemctl",
+            &[
+                "--user",
+                "show",
+                "--property=LoadState",
+                "--value",
+                &self.unit,
+            ],
+        )?)?;
+        match loaded.trim() {
+            "not-found" => {}
+            "loaded" | "error" | "bad-setting" => {
+                // The caller holds Root: no service instance can own user work.
+                checked(command("systemctl", &["--user", "stop", &self.unit])?)?;
+            }
+            _ => return Err("unrecognized systemd service load state".into()),
+        }
+        publish(&self.path, &self.definition)?;
+        checked(command("systemctl", &["--user", "daemon-reload"])?)?;
+        checked(command("systemctl", &["--user", "enable", &self.unit])?)?;
+        Ok(())
+    }
+
+    pub fn start(&self) -> Result<(), HostError> {
+        checked(command(
+            "systemctl",
+            &["--user", "reset-failed", &self.unit],
+        )?)?;
+        checked(command("systemctl", &["--user", "start", &self.unit])?)?;
+        Ok(())
+    }
+}
+
+fn quote(value: &str) -> String {
+    format!(
+        "\"{}\"",
+        value
+            .replace('\\', "\\\\")
+            .replace('"', "\\\"")
+            .replace('%', "%%")
+    )
+}
diff --git a/crates/cli/src/deployment/service/macos.rs b/crates/cli/src/deployment/service/macos.rs
new file mode 100644
index 0000000000..8cf6cc3264
--- /dev/null
+++ b/crates/cli/src/deployment/service/macos.rs
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, HostError, arguments, checked, command, label, publish, xml};
+use std::{
+    path::PathBuf,
+    time::{Duration, Instant},
+};
+
+pub(super) struct Service {
+    uid: String,
+    label: String,
+    target: String,
+    path: PathBuf,
+    definition: String,
+}
+
+struct Instance {
+    pid: Option<libc::pid_t>,
+}
+
+impl Service {
+    pub fn new(deployment: &Deployment) -> Result<Self, HostError> {
+        let uid = unsafe { libc::geteuid() }.to_string();
+        let label = label(deployment);
+        let home = crate::serve::home_directory()?.ok_or("missing account home")?;
+        let path = home
+            .join("Library/LaunchAgents")
+            .join(format!("{label}.plist"));
+        let args = arguments(deployment)?
+            .map(|arg| format!("<string>{}</string>", xml(arg)))
+            .join("");
+        let log = super::super::directory(&deployment.root_id)?.join("host.stderr.log");
+        let log = xml(log.to_str().ok_or("service log path must be UTF-8")?);
+        let definition = format!(
+            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>Label</key><string>{label}</string><key>ProgramArguments</key><array>{args}</array><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>2</integer><key>ExitTimeOut</key><integer>45</integer><key>Umask</key><integer>63</integer><key>StandardErrorPath</key><string>{log}</string></dict></plist>\n"
+        );
+        Ok(Self {
+            target: format!("gui/{uid}/{label}"),
+            uid,
+            label,
+            path,
+            definition,
+        })
+    }
+
+    fn in_user_domain(&self, args: &[&str]) -> Result<String, HostError> {
+        let mut command_args = vec!["asuser", &self.uid, "/bin/launchctl"];
+        command_args.extend_from_slice(args);
+        checked(command("/bin/launchctl", &command_args)?)
+    }
+
+    pub fn prepare(&self) -> Result<(), HostError> {
+        if self.in_user_domain(&["managername"])?.trim() != "Aqua"
+            || self.in_user_domain(&["manageruid"])?.trim() != self.uid
+        {
+            return Err("the account GUI launchd domain is unavailable".into());
+        }
+        // Unlike print's diagnostic format, list's three columns are documented.
+        if let Some(instance) = self.instance()? {
+            // Root is held, so even a racing automatic launch cannot execute work.
+            checked(command("/bin/launchctl", &["bootout", &self.target])?)?;
+            // bootout can return before launchd removes the old registration.
+            // Do not race bootstrap against that asynchronous teardown.
+            let deadline = Instant::now() + Duration::from_secs(45);
+            while self.instance()?.is_some() || instance.pid.is_some_and(process_exists) {
+                if Instant::now() >= deadline {
+                    return Err("launchd service did not finish unloading".into());
+                }
+                std::thread::sleep(Duration::from_millis(25));
+            }
+        }
+        publish(&self.path, &self.definition)?;
+        checked(command("/bin/launchctl", &["enable", &self.target])?)?;
+        checked(command(
+            "/bin/launchctl",
+            &[
+                "bootstrap",
+                &format!("gui/{}", self.uid),
+                self.path.to_str().ok_or("service path must be UTF-8")?,
+            ],
+        )?)?;
+        Ok(())
+    }
+
+    fn instance(&self) -> Result<Option<Instance>, HostError> {
+        for line in self.in_user_domain(&["list"])?.lines() {
+            let columns: Vec<_> = line.split_whitespace().collect();
+            if columns.len() == 3 && columns[2] == self.label {
+                let pid = match columns[0] {
+                    "-" => None,
+                    pid => Some(
+                        pid.parse::<libc::pid_t>()
+                            .ok()
+                            .filter(|pid| *pid > 0)
+                            .ok_or("invalid launchd service PID")?,
+                    ),
+                };
+                return Ok(Some(Instance { pid }));
+            }
+        }
+        Ok(None)
+    }
+
+    pub fn start(&self) -> Result<(), HostError> {
+        // No -k: an implicitly launched instance must never be killed.
+        checked(command("/bin/launchctl", &["kickstart", &self.target])?)?;
+        Ok(())
+    }
+}
+
+fn process_exists(pid: libc::pid_t) -> bool {
+    // Signal zero only observes existence; it never signals a potentially reused PID.
+    let exists = unsafe { libc::kill(pid, 0) == 0 };
+    exists || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
+}
diff --git a/crates/cli/src/deployment/service/windows.rs b/crates/cli/src/deployment/service/windows.rs
new file mode 100644
index 0000000000..2742cf0131
--- /dev/null
+++ b/crates/cli/src/deployment/service/windows.rs
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, HostError, arguments, label, xml};
+use std::time::{Duration, Instant};
+use windows::{
+    Win32::{
+        Foundation::RPC_E_CHANGED_MODE,
+        System::{
+            Com::{
+                CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx,
+                CoUninitialize,
+            },
+            TaskScheduler::{
+                ITaskFolder, ITaskService, TASK_CREATE_OR_UPDATE, TASK_LOGON_INTERACTIVE_TOKEN,
+                TaskScheduler,
+            },
+            Variant::VARIANT,
+        },
+    },
+    core::BSTR,
+};
+
+pub(super) struct Service {
+    folder: ITaskFolder,
+    scheduler: ITaskService,
+    name: String,
+    user: String,
+    definition: String,
+    // COM interfaces must be released before their thread's apartment.
+    _apartment: Apartment,
+}
+
+impl Service {
+    pub fn new(deployment: &Deployment) -> Result<Self, HostError> {
+        let apartment = Apartment::open()?;
+        // SAFETY: initialization and every interface stay on this blocking worker.
+        let (scheduler, folder, user) = unsafe {
+            let scheduler: ITaskService =
+                CoCreateInstance(&TaskScheduler, None, CLSCTX_INPROC_SERVER)?;
+            let empty = VARIANT::default();
+            scheduler.Connect(&empty, &empty, &empty, &empty)?;
+            let folder = scheduler.GetFolder(&BSTR::from("\\"))?;
+            let domain = scheduler.ConnectedDomain()?.to_string();
+            let account = scheduler.ConnectedUser()?.to_string();
+            let user = if domain.is_empty() {
+                account
+            } else {
+                format!("{domain}\\{account}")
+            };
+            (scheduler, folder, user)
+        };
+        let name = label(deployment);
+        let args = arguments(deployment)?;
+        let executable = xml(args[0]);
+        let args = xml(&args[1..]
+            .iter()
+            .map(|arg| quote(arg))
+            .collect::<Vec<_>>()
+            .join(" "));
+        let principal = xml(&user);
+        let definition = format!(
+            concat!(
+                "<?xml version=\"1.0\" encoding=\"UTF-16\"?>",
+                "<Task version=\"1.4\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">",
+                "<RegistrationInfo><Description>{name}</Description></RegistrationInfo>",
+                "<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{principal}</UserId></LogonTrigger></Triggers>",
+                "<Principals><Principal id=\"Maka\"><UserId>{principal}</UserId><LogonType>InteractiveToken</LogonType>",
+                "<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>",
+                "<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
+                "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
+                "<AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable>",
+                "<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>",
+                "<IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>",
+                "<AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled>",
+                "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>5</Count></RestartOnFailure></Settings>",
+                "<Actions Context=\"Maka\"><Exec><Command>{executable}</Command><Arguments>{args}</Arguments></Exec></Actions></Task>"
+            ),
+            name = name,
+            principal = principal,
+            executable = executable,
+            args = args
+        );
+        Ok(Self {
+            folder,
+            scheduler,
+            name,
+            user,
+            definition,
+            _apartment: apartment,
+        })
+    }
+
+    pub fn prepare(&self) -> Result<(), HostError> {
+        // Root is held by the caller; no instance can be executing user work.
+        // SAFETY: these COM interfaces are confined to their initialized thread.
+        unsafe {
+            match self.folder.GetTask(&BSTR::from(&self.name)) {
+                Ok(task) => {
+                    let mut description = BSTR::new();
+                    task.Definition()?
+                        .RegistrationInfo()?
+                        .Description(&mut description)?;
+                    if description != self.name.as_str() {
+                        return Err("scheduled task ownership marker differs".into());
+                    }
+                    if task.GetInstances(0)?.Count()? != 0 {
+                        task.Stop(0)?;
+                        let deadline = Instant::now() + Duration::from_secs(45);
+                        while task.GetInstances(0)?.Count()? != 0 {
+                            if Instant::now() >= deadline {
+                                return Err("scheduled task did not stop".into());
+                            }
+                            std::thread::sleep(Duration::from_millis(25));
+                        }
+                    }
+                }
+                Err(error) if error.code().0 as u32 == 0x80070002 => {}
+                Err(error) => return Err(error.into()),
+            }
+            let definition = self.scheduler.NewTask(0)?;
+            definition.SetXmlText(&BSTR::from(&self.definition))?;
+            let empty = VARIANT::default();
+            self.folder.RegisterTaskDefinition(
+                &BSTR::from(&self.name),
+                &definition,
+                TASK_CREATE_OR_UPDATE.0,
+                &VARIANT::from(self.user.as_str()),
+                &empty,
+                TASK_LOGON_INTERACTIVE_TOKEN,
+                &empty,
+            )?;
+        }
+        Ok(())
+    }
+
+    pub fn start(&self) -> Result<(), HostError> {
+        // SAFETY: registration has completed on this same COM thread.
+        unsafe {
+            self.folder
+                .GetTask(&BSTR::from(&self.name))?
+                .Run(&VARIANT::default())?;
+        }
+        Ok(())
+    }
+}
+
+fn quote(value: &str) -> String {
+    let mut result = String::from("\"");
+    let mut slashes = 0;
+    for character in value.chars() {
+        if character == '\\' {
+            slashes += 1;
+        } else {
+            result.extend(std::iter::repeat_n(
+                '\\',
+                if character == '"' {
+                    slashes * 2 + 1
+                } else {
+                    slashes
+                },
+            ));
+            slashes = 0;
+            result.push(character);
+        }
+    }
+    result.extend(std::iter::repeat_n('\\', slashes * 2));
+    result.push('"');
+    result
+}
+
+struct Apartment(bool);
+
+impl Apartment {
+    fn open() -> windows::core::Result<Self> {
+        // SAFETY: balanced on this thread, including the already-initialized case.
+        let result = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
+        if result == RPC_E_CHANGED_MODE {
+            Ok(Self(false))
+        } else {
+            result.ok()?;
+            Ok(Self(true))
+        }
+    }
+}
+
+impl Drop for Apartment {
+    fn drop(&mut self) {
+        if self.0 {
+            // SAFETY: paired with successful initialization on this thread.
+            unsafe { CoUninitialize() };
+        }
+    }
+}
diff --git a/crates/cli/src/deployment/update.rs b/crates/cli/src/deployment/update.rs
index 6f7b22f628..944eb466d2 100644
--- a/crates/cli/src/deployment/update.rs
+++ b/crates/cli/src/deployment/update.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{Deployment, Mode, RootId, activation, directory, package, store, updates};
+use super::{Deployment, RootId, activation, directory, package, store, updates};
 use crate::host_client::{HostClient, LiveHost};
 use clap::Args;
 use maka_event_log::root::{self, FileLease, RootNamespaces, RootOwner};
@@ -67,9 +67,6 @@ impl Update {
         {
             return Err("deployment identity or revision changed".into());
         }
-        if current.mode != Mode::OnDemand {
-            return Err("supervised updates require service coordination".into());
-        }
         let (observed, pending) = updates::read(&directory, lease.clone()).await?;
         if observed != current
             || !(current.config_revision == self.expected_revision
@@ -133,7 +130,7 @@ impl Update {
         };
         // The executor is already held. Calling the public activate command here
         // would acquire it twice. A Ready failure never restores older code.
-        let (client, host) = activation::connect_or_launch(&deployment).await?;
+        let (client, host) = activation::connect_or_launch(&deployment, lease.clone()).await?;
         lease.validate()?;
         println!(
             "{}",
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 26508f5680..080f6c3b13 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -30,7 +30,7 @@ mod signals;
 mod windows;
 
 #[tokio::main]
-async fn main() {
+async fn main() -> std::process::ExitCode {
     let cli = args::Cli::parse();
     let error_exit = cli.error_exit_code();
     let result = if env!("CARGO_BIN_NAME") == "maka-service" {
@@ -40,6 +40,10 @@ async fn main() {
     };
     if let Err(error) = result {
         eprintln!("{error}");
-        std::process::exit(error_exit);
+        // Let Tokio wait for accepted blocking work to finish before the OS
+        // releases its leases. A reported timeout does not cancel that work.
+        std::process::ExitCode::from(error_exit)
+    } else {
+        std::process::ExitCode::SUCCESS
     }
 }
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index c2afd197ea..2c65284f8a 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -30,7 +30,22 @@ pub(super) async fn run(
     use maka_runtime_host::server::{Host, websocket::WebSocketListener};
     let namespaces = RootNamespaces::for_current_account()?;
     let owner = if path.join(ROOT_MARKER).exists() {
-        RootOwner::open(path, &namespaces)?
+        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
+        loop {
+            match RootOwner::open(path, &namespaces) {
+                Ok(owner) => break owner,
+                Err(error)
+                    if require_managed
+                        && error.kind() == std::io::ErrorKind::WouldBlock
+                        && tokio::time::Instant::now() < deadline =>
+                {
+                    // launchd can start the job while its registrar still
+                    // holds Root. Admission happens only after acquisition.
+                    tokio::time::sleep(std::time::Duration::from_millis(25)).await;
+                }
+                Err(error) => return Err(error.into()),
+            }
+        }
     } else if !require_managed {
         RootOwner::create(path, &namespaces)?
     } else {
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index 0475a7280a..41441ffaa9 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -69,7 +69,11 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
             command
                 .args([
                     "host",
-                    if candidate { "candidate" } else { "serve" },
+                    if candidate {
+                        "candidate"
+                    } else {
+                        "service-run"
+                    },
                     "--root",
                 ])
                 .arg(&fixture.root);
@@ -180,16 +184,13 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert!(fixture.wait_for_exit().success());
         drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
 
-        let started = Instant::now();
-        let activated = activate.output().unwrap();
         if !candidate {
-            assert!(
-                !activated.status.success(),
-                "must not bypass service ownership"
-            );
-            assert_eq!(decode_activation(&activated.stdout)["kind"], "error");
+            // Registering an account OS service is an explicit platform
+            // acceptance check, never a side effect of ordinary cargo tests.
             continue;
         }
+        let started = Instant::now();
+        let activated = activate.output().unwrap();
         #[cfg(windows)]
         if requires_containment() {
             // Cargo's own Job forbids independent children. Refusal must not
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 6de921fc26..4d8d9d918a 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -52,12 +52,17 @@ maka host retire --root /absolute/path/to/new-root
 Use `target/debug/maka` if the binary is not on PATH. Local transport is a Unix
 socket on Linux/macOS or a private named pipe on Windows. An optional
 `--websocket 127.0.0.1:0` listener requires authentication; TLS is not implemented.
+Development builds retain line-number backtraces; `CARGO_PROFILE_DEV_DEBUG=full`
+enables full debugger information.
 
 `host install --root <directory>` pins the current executable and on-demand policy;
 `--mode supervised` selects persistent serving. Only the returned `executable`
 may start that managed root. `host activate --root-id <rootId> --framed` reuses a ready
-Host or starts the pinned on-demand executable; supervised mode requires a running
-service. Installation does not yet register OS services or automate updates.
+Host or starts the pinned executable. In supervised mode, activation registers and
+starts an account-level systemd service, LaunchAgent or Windows scheduled task.
+Linux requires an active user manager with lingering enabled; macOS requires an
+Aqua login and Windows an interactive user session. Installation alone does not
+start a service or change account policy.
 Deployment authority lives in account-level
 SQLite, outside the State Root; startup checks it before database migrations.
 On Windows, supervised installations use the sibling `maka-service.exe`, a
@@ -65,12 +70,13 @@ windowless entry to the same Host. Distribute it alongside `maka.exe`.
 On-demand activation requires permission to leave the launcher's Windows Job;
 run the built executable directly, not through `cargo run`.
 
-For an on-demand code update, run the new binary with
+For a code update, run the new binary with
 `host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`.
 Active clients or non-cooperative work defer the switch; `host reconcile` with the
 same identity arguments finishes the recorded update. A committed target is never
-automatically rolled back, even if startup fails. Supervised updates and unattended
-release selection are not yet connected.
+automatically rolled back, even if startup fails. Supervised activation replaces
+the service definition only while holding the Root. Unattended release selection
+and uninterrupted listener handoff are not yet connected.
 
 The `maka` command also provides `host candidate` for Desktop-owned startup,
 `code --log <file>` for a JavaScript cell read from stdin, and
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index d338797a51..6350e03dca 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -48,18 +48,22 @@ maka host retire --root /absolute/path/to/new-root
 
 二进制未加入 PATH 时使用 `target/debug/maka`。Linux/macOS 使用 Unix socket,
 Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要认证,尚不支持 TLS。
+开发构建保留文件与行号回溯;`CARGO_PROFILE_DEV_DEBUG=full` 可启用完整调试信息。
 
 `host install --root <目录>` 固定当前可执行文件和按需策略;`--mode supervised` 选择持续运行。
 此后只有返回的 `executable` 可以启动该托管根。`host activate --root-id <rootId> --framed`
-复用就绪 Host,或启动固定的按需版本;supervised 模式要求服务已运行。安装尚不注册 OS 服务或自动更新。
+复用就绪 Host,或启动固定版本;supervised 模式会注册并启动账户级 systemd 服务、LaunchAgent
+或 Windows 计划任务。Linux 要求用户服务管理器已运行且启用 linger;macOS 要求 Aqua 登录,
+Windows 要求交互式用户会话。单独安装不会启动服务或修改账户策略。
 部署权威保存在 State Root 之外的账户级 SQLite 中,启动时先校验再执行数据库迁移。
 Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 的无窗口入口,须与 `maka.exe` 一起分发。
 按需激活要求启动环境允许脱离 Windows Job;应直接运行已构建的程序,不要通过 `cargo run` 激活。
 
-更新按需部署时,用新二进制运行
+更新部署时,用新二进制运行
 `host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`。
 活跃客户端或不可交接任务会推迟切换;随后用相同身份参数运行 `host reconcile` 完成已记录的更新。
-目标一旦提交,即使启动失败也不自动回退。Supervised 更新和无人值守版本选择尚未接通。
+目标一旦提交,即使启动失败也不自动回退。Supervised 激活仅在持有 Root 时替换服务定义。
+无人值守版本选择和不中断监听的交接尚未接通。
 
 `maka` 命令还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
 `code --log <file>`,以及查看已提交执行事实的 `inspect --log <file>`。


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 27/29: feat(cli): prepare and revoke native remote pairing

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 1ce2ef41a7db55091ac160db251149a745807c84
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 00:23:17 2026 +0800

    feat(cli): prepare and revoke native remote pairing
    
    Generated-by: OpenAI Codex
---
 crates/cli/src/access.rs                           | 138 +++++++++++++++++++++
 crates/cli/src/args.rs                             |   4 +
 crates/cli/src/host_client.rs                      |  10 +-
 crates/cli/src/main.rs                             |   1 +
 crates/cli/tests/integration/access.rs             |  62 +++++++++
 crates/cli/tests/integration/main.rs               |   1 +
 .../access/delivery.rs => access_delivery.rs}      |  86 ++++++++++++-
 crates/runtime-host/src/lib.rs                     |   1 +
 crates/runtime-host/src/server/access.rs           |   2 +-
 docs/rust-runtime.md                               |   6 +
 docs/rust-runtime.zh-CN.md                         |   5 +
 tests/fixtures/client-access-entry.mjs             | 116 +++++++++++++++++
 tests/fixtures/client.mjs                          |   1 +
 13 files changed, 426 insertions(+), 7 deletions(-)

diff --git a/crates/cli/src/access.rs b/crates/cli/src/access.rs
new file mode 100644
index 0000000000..5aaa1926f2
--- /dev/null
+++ b/crates/cli/src/access.rs
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{args::Root, host_client::HostClient};
+use clap::{Args, Subcommand};
+use maka_event_log::root::RootNamespaces;
+use maka_protocol::{
+    Operation,
+    access::{
+        decode_issue_result, decode_prepare_input, decode_revoke_input, decode_revoke_result,
+    },
+};
+use maka_runtime::access::{
+    AccessCredentialPrepareInput, AccessCredentialRevokeInput, ManagedPrincipalKind,
+};
+use maka_runtime_host::{access_delivery, server::HostError};
+use serde::Serialize;
+
+#[derive(Subcommand)]
+pub(super) enum Access {
+    /// Print short-lived remote Desktop pairing JSON, including its secret credential.
+    Prepare(Prepare),
+    /// Revoke a credential and disconnect its remote clients.
+    Revoke(Revoke),
+}
+
+#[derive(Args)]
+pub(super) struct Prepare {
+    #[command(flatten)]
+    root: Root,
+    #[arg(long)]
+    principal: String,
+}
+
+#[derive(Args)]
+pub(super) struct Revoke {
+    #[command(flatten)]
+    root: Root,
+    #[arg(long)]
+    credential_id: String,
+}
+
+// No Debug: this receipt is intended only for the importing Client.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Pairing {
+    root_id: String,
+    credential_id: String,
+    credential: String,
+}
+
+impl Access {
+    pub async fn run(self) -> Result<(), HostError> {
+        match self {
+            Self::Prepare(args) => args.run().await,
+            Self::Revoke(args) => args.run().await,
+        }
+    }
+}
+
+impl Prepare {
+    async fn run(self) -> Result<(), HostError> {
+        // Match the Desktop owner policy, without granting arbitrary Host paths.
+        // Pending credentials expire and must be finalized by the importing
+        // Client; its instance identity then remains bound to the credential.
+        let input = serde_json::to_value(AccessCredentialPrepareInput {
+            principal_kind: ManagedPrincipalKind::RemoteOwner,
+            principal_id: self.principal,
+            operation_grants: Operation::ALL
+                .iter()
+                .filter(|operation| operation.allows_remote_owner())
+                .map(|operation| operation.as_str().into())
+                .collect(),
+            can_publish_client_capabilities: true,
+            can_use_host_paths: false,
+            bind_client_instance: Some(true),
+        })?;
+        decode_prepare_input(&input)?;
+        let mut client = HostClient::connect(&self.root.root, None).await?;
+        let result = decode_issue_result(
+            &client
+                .request(Operation::AccessCredentialPrepare, input)
+                .await?,
+        )?;
+        let control = RootNamespaces::for_current_account()?
+            .control
+            .join(client.root_id());
+        let credential_id = result.credential_id.clone();
+        let credential = tokio::task::spawn_blocking(move || {
+            access_delivery::consume(&control, &result.delivery_id, &credential_id)
+        })
+        .await??;
+        println!(
+            "{}",
+            serde_json::to_string(&Pairing {
+                root_id: client.root_id().into(),
+                credential_id: result.credential_id,
+                credential,
+            })?
+        );
+        // Retain the connection through delivery consumption and output.
+        drop(client);
+        Ok(())
+    }
+}
+
+impl Revoke {
+    async fn run(self) -> Result<(), HostError> {
+        let input = serde_json::to_value(AccessCredentialRevokeInput {
+            credential_id: self.credential_id,
+        })?;
+        decode_revoke_input(&input)?;
+        let mut client = HostClient::connect(&self.root.root, None).await?;
+        let result = decode_revoke_result(
+            &client
+                .request(Operation::AccessCredentialRevoke, input)
+                .await?,
+        )?;
+        println!("{}", serde_json::to_string(&result)?);
+        Ok(())
+    }
+}
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index c03d848ae5..d018dae960 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -47,6 +47,9 @@ enum Command {
 
 #[derive(Subcommand)]
 enum HostCommand {
+    /// Prepare one-time remote Desktop pairing or revoke a credential.
+    #[command(subcommand)]
+    Access(crate::access::Access),
     /// Initialize an empty native State Root, or verify its existing identity.
     Init(Root),
     /// Install this native executable as the root's managed Host.
@@ -116,6 +119,7 @@ impl Cli {
 
     pub(super) async fn run(self) -> Result<(), HostError> {
         match self.command {
+            Command::Host(HostCommand::Access(args)) => args.run().await,
             Command::Host(HostCommand::Candidate(args)) => args.run().await,
             Command::Host(HostCommand::Install(args)) => args.run().await,
             Command::Host(HostCommand::Activate(args)) => args.run().await,
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
index 2916dbd4cd..db871c1201 100644
--- a/crates/cli/src/host_client.rs
+++ b/crates/cli/src/host_client.rs
@@ -68,6 +68,10 @@ pub(super) struct HostClient {
 }
 
 impl HostClient {
+    pub fn root_id(&self) -> &str {
+        &self.discovery.root_id
+    }
+
     pub async fn connect(root: &Path, generation: Option<&str>) -> Result<Self, HostError> {
         let root = root.to_owned();
         tokio::time::timeout(Duration::from_secs(5), async move {
@@ -195,7 +199,11 @@ impl HostClient {
         Ok(result)
     }
 
-    async fn request(&mut self, operation: Operation, input: Value) -> Result<Value, HostError> {
+    pub(super) async fn request(
+        &mut self,
+        operation: Operation,
+        input: Value,
+    ) -> Result<Value, HostError> {
         // A timeout is not evidence of rollback. The caller must rediscover the
         // exact owner before deciding whether any subsequent mutation is safe.
         tokio::time::timeout(Duration::from_secs(15), async {
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 54565ddcad..4ddb44a455 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -18,6 +18,7 @@
  */
 
 use clap::Parser;
+mod access;
 mod args;
 mod candidate;
 mod code;
diff --git a/crates/cli/tests/integration/access.rs b/crates/cli/tests/integration/access.rs
new file mode 100644
index 0000000000..3c065c9d4c
--- /dev/null
+++ b/crates/cli/tests/integration/access.rs
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::candidate::CandidateFixture;
+use std::{
+    path::Path,
+    process::{Command, Stdio},
+};
+
+#[test]
+fn native_pairing_finalizes_once_binds_client_and_revokes_live_transport() {
+    let temporary = tempfile::tempdir().unwrap();
+    let mut fixture = CandidateFixture::new(temporary.path().join("root"));
+    fixture.child = Some(
+        Command::new(env!("CARGO_BIN_EXE_maka"))
+            .args(["host", "serve", "--root"])
+            .arg(&fixture.root)
+            .args(["--websocket", "127.0.0.1:0"])
+            .stdin(Stdio::null())
+            .stdout(Stdio::null())
+            .stderr(Stdio::inherit())
+            .spawn()
+            .unwrap(),
+    );
+    fixture.wait_for_registration();
+    let client = Command::new("node")
+        .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
+        .args(["--native-access", env!("CARGO_BIN_EXE_maka"), "--root"])
+        .arg(&fixture.root)
+        .output()
+        .unwrap();
+    assert!(
+        client.status.success(),
+        "{}",
+        String::from_utf8_lossy(&client.stderr)
+    );
+    assert!(
+        std::fs::read_dir(fixture.registration.parent().unwrap())
+            .unwrap()
+            .all(|entry| !entry
+                .unwrap()
+                .file_name()
+                .to_string_lossy()
+                .starts_with("runtime-host-access-delivery-"))
+    );
+}
diff --git a/crates/cli/tests/integration/main.rs b/crates/cli/tests/integration/main.rs
index 3ca0c01d65..d33ec7b9ff 100644
--- a/crates/cli/tests/integration/main.rs
+++ b/crates/cli/tests/integration/main.rs
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+mod access;
 mod candidate;
 mod code_command;
 mod deployment;
diff --git a/crates/runtime-host/src/server/access/delivery.rs b/crates/runtime-host/src/access_delivery.rs
similarity index 70%
rename from crates/runtime-host/src/server/access/delivery.rs
rename to crates/runtime-host/src/access_delivery.rs
index b6ad958edc..376dd492f2 100644
--- a/crates/runtime-host/src/server/access/delivery.rs
+++ b/crates/runtime-host/src/access_delivery.rs
@@ -20,7 +20,7 @@
 #[cfg(not(windows))]
 use std::fs::OpenOptions;
 use std::fs::{self, File};
-use std::io::{self, Write};
+use std::io::{self, Read, Write};
 use std::path::{Path, PathBuf};
 
 #[cfg(unix)]
@@ -30,14 +30,14 @@ const PREFIX: &str = "runtime-host-access-delivery-";
 
 // Keep the file open until cleanup so an unlinked inode cannot be reused for a
 // replacement. No Debug implementation: delivery contents are credentials.
-pub(super) struct Delivery {
+pub(crate) struct Delivery {
     id: String,
     path: PathBuf,
     file: File,
 }
 
 impl Delivery {
-    pub(super) fn create(
+    pub(crate) fn create(
         control: &Path,
         credential_id: &str,
         credential: &str,
@@ -88,7 +88,7 @@ impl Delivery {
         Ok(delivery)
     }
 
-    pub(super) fn id(&self) -> &str {
+    pub(crate) fn id(&self) -> &str {
         &self.id
     }
 }
@@ -147,7 +147,7 @@ fn validate_control(control: &Path) -> io::Result<()> {
     }
 }
 
-pub(super) fn purge(control: &Path) -> io::Result<()> {
+pub(crate) fn purge(control: &Path) -> io::Result<()> {
     validate_control(control)?;
     for entry in fs::read_dir(control)? {
         let entry = entry?;
@@ -178,6 +178,68 @@ pub(super) fn purge(control: &Path) -> io::Result<()> {
     Ok(())
 }
 
+/// Consume a private, short-lived delivery from an already verified local Host.
+/// The credential is never returned through the ordinary Host response stream.
+pub fn consume(control: &Path, delivery_id: &str, credential_id: &str) -> io::Result<String> {
+    let id = uuid::Uuid::parse_str(delivery_id)
+        .map_err(|_| io::Error::other("invalid access delivery identity"))?;
+    if id.get_version_num() != 4
+        || id.get_variant() != uuid::Variant::RFC4122
+        || id.to_string() != delivery_id
+    {
+        return Err(io::Error::other("invalid access delivery identity"));
+    }
+    validate_control(control)?;
+    let path = control.join(format!("{PREFIX}{delivery_id}.json"));
+    #[cfg(unix)]
+    let mut file = OpenOptions::new()
+        .read(true)
+        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+        .open(&path)?;
+    #[cfg(windows)]
+    let mut file = maka_event_log::root::windows::open_nofollow(&path, false)?;
+    let metadata = file.metadata()?;
+    if !metadata.is_file() {
+        return Err(io::Error::other("access delivery must be a regular file"));
+    }
+    #[cfg(unix)]
+    // SAFETY: geteuid has no preconditions or pointer arguments.
+    if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 {
+        return Err(io::Error::other("access delivery must be account-private"));
+    }
+    #[cfg(windows)]
+    maka_event_log::root::windows::validate_private(&file)?;
+    let mut bytes = Vec::new();
+    (&mut file).take(1025).read_to_end(&mut bytes)?;
+    if bytes.len() > 1024 {
+        return Err(io::Error::other("access delivery exceeds size limit"));
+    }
+    #[derive(serde::Deserialize)]
+    #[serde(rename_all = "camelCase", deny_unknown_fields)]
+    struct Payload {
+        credential_id: String,
+        credential: String,
+    }
+    let payload: Payload = serde_json::from_slice(&bytes)
+        .map_err(|_| io::Error::other("invalid access delivery payload"))?;
+    if payload.credential_id != credential_id
+        || payload.credential.is_empty()
+        || payload.credential.encode_utf16().count() > 512
+    {
+        return Err(io::Error::other(
+            "access delivery identity or credential is invalid",
+        ));
+    }
+    // Reuse the producer's inode-bound cleanup. A replaced pathname is never
+    // removed, and the producer's later cleanup cannot remove a new delivery.
+    drop(Delivery {
+        id: delivery_id.into(),
+        path,
+        file,
+    });
+    Ok(payload.credential)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -207,6 +269,20 @@ mod tests {
         assert_eq!(fs::metadata(&path).unwrap().mode() & 0o777, 0o600);
         #[cfg(windows)]
         maka_event_log::root::windows::validate_private(&delivery.file).unwrap();
+        assert!(consume(&root, delivery.id(), "another-credential").is_err());
+        assert!(
+            path.exists(),
+            "identity mismatch must not consume the delivery"
+        );
+        fs::write(&path, vec![b'x'; 1025]).unwrap();
+        assert!(consume(&root, delivery.id(), "id").is_err());
+        fs::write(
+            &path,
+            b"{\"credentialId\":\"id\",\"credential\":\"secret\"}\n",
+        )
+        .unwrap();
+        assert_eq!(consume(&root, delivery.id(), "id").unwrap(), "secret");
+        assert!(consume(&root, delivery.id(), "id").is_err());
         drop(delivery);
         assert!(!path.exists());
 
diff --git a/crates/runtime-host/src/lib.rs b/crates/runtime-host/src/lib.rs
index 44bd30aed0..bb6e9f7352 100644
--- a/crates/runtime-host/src/lib.rs
+++ b/crates/runtime-host/src/lib.rs
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+pub mod access_delivery;
 mod controllers;
 mod execution;
 pub mod oauth;
diff --git a/crates/runtime-host/src/server/access.rs b/crates/runtime-host/src/server/access.rs
index 702ebf43ef..48aab763bc 100644
--- a/crates/runtime-host/src/server/access.rs
+++ b/crates/runtime-host/src/server/access.rs
@@ -24,7 +24,7 @@ use maka_runtime::access::{
 };
 use serde_json::Value;
 
-mod delivery;
+use crate::access_delivery as delivery;
 mod issuance;
 
 #[derive(serde::Serialize)]
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 2a5ab47620..3b5a055639 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -52,6 +52,12 @@ maka host retire --root /absolute/path/to/new-root
 Use `target/debug/maka` if the binary is not on PATH. Local transport is a Unix
 socket on Linux/macOS or a private named pipe on Windows. An optional
 `--websocket 127.0.0.1:0` listener requires authentication; TLS is not implemented.
+Against a running Host, `host access prepare --root <directory> --principal <id>`
+prints pairing JSON containing a secret. Keep it private. It expires after 15
+minutes unless the importing client finalizes it, then reconnects with the same
+client identity. This Desktop-owner policy does not grant arbitrary Host paths.
+`host access revoke --root <directory> --credential-id <id>` revokes it and closes
+its remote connections. These commands neither start the Host nor migrate data.
 Development builds retain line-number backtraces; `CARGO_PROFILE_DEV_DEBUG=full`
 enables full debugger information.
 Windows MSVC builds use the static CRT required by the official V8 archive.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 5726d90d96..541f19bbad 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -48,6 +48,11 @@ maka host retire --root /absolute/path/to/new-root
 
 二进制未加入 PATH 时使用 `target/debug/maka`。Linux/macOS 使用 Unix socket,
 Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要认证,尚不支持 TLS。
+对运行中的 Host 执行 `host access prepare --root <目录> --principal <id>` 可获得配对 JSON,
+其中包含秘密凭据,须私密传递。待确认凭据在 15 分钟后过期;导入客户端须先确认配对,再用相同客户端身份重连。
+该 Desktop owner 策略不授予任意 Host 路径访问权。
+`host access revoke --root <目录> --credential-id <id>` 撤销凭据并关闭其远程连接。
+这些命令不会启动 Host 或迁移数据。
 开发构建保留文件与行号回溯;`CARGO_PROFILE_DEV_DEBUG=full` 可启用完整调试信息。
 Windows MSVC 构建使用与官方 V8 静态库一致的静态 CRT。
 两份随仓库保存的 Deno TypeScript 须与 `deno_telemetry` 完全一致,升级依赖时须同步;
diff --git a/tests/fixtures/client-access-entry.mjs b/tests/fixtures/client-access-entry.mjs
new file mode 100644
index 0000000000..a139f5934b
--- /dev/null
+++ b/tests/fixtures/client-access-entry.mjs
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { execFile } from 'node:child_process';
+import { parseArgs, promisify } from 'node:util';
+import {
+  connectExistingRuntimeHost,
+  connectRemoteRuntimeHost,
+} from '../../packages/runtime-host/src/client/connection.js';
+
+const { values } = parseArgs({
+  options: { 'native-access': { type: 'string' }, root: { type: 'string' } },
+});
+const run = promisify(execFile);
+const cli = async (...args) => {
+  const result = await run(
+    values['native-access'],
+    ['host', 'access', ...args, '--root', values.root],
+    {
+      timeout: 15_000,
+      maxBuffer: 16 * 1024,
+      windowsHide: true,
+    },
+  );
+  return JSON.parse(result.stdout);
+};
+const local = await connectExistingRuntimeHost({
+  rootPath: values.root,
+  protocol: { min: 0, max: 0 },
+  compositionId: 'maka.interactive',
+});
+assert.equal(local.kind, 'connected');
+const connections = [];
+let pairing;
+let revoked = false;
+try {
+  pairing = await cli('prepare', '--principal', 'native-pairing');
+  assert.equal(pairing.rootId, local.connection.rootId);
+  assert(typeof pairing.credential === 'string' && pairing.credential.startsWith('maka_rh_'));
+  const connect = async (clientInstanceId) => {
+    const result = await connectRemoteRuntimeHost({
+      url: local.registration.websocketEndpoints[0],
+      credential: pairing.credential,
+      expectedRootId: pairing.rootId,
+      compositionId: 'maka.interactive',
+      protocol: { min: 0, max: 0 },
+      clientInstanceId,
+      connectTimeoutMs: 3000,
+    });
+    if (result.kind === 'connected') connections.push(result.connection);
+    return result;
+  };
+  const pending = await connect('pairing-winner');
+  assert.equal(pending.kind, 'connected');
+  assert.equal((await pending.connection.status()).state, 'ready');
+  await assert.rejects(pending.connection.request('connection.catalog.query', { kind: 'start' }), {
+    code: 'unauthorized',
+  });
+  assert.deepEqual(await pending.connection.request('access.credential.finalize', {}), {
+    reconnectRequired: true,
+  });
+  await assert.rejects(pending.connection.request('connection.catalog.query', { kind: 'start' }), {
+    code: 'unauthorized',
+  });
+  await pending.connection.close();
+  assert.equal((await connect('pairing-other-client')).kind, 'unavailable');
+  const active = await connect('pairing-winner');
+  assert.equal(active.kind, 'connected');
+  await active.connection.request('connection.catalog.query', { kind: 'start' });
+  assert.equal((await active.connection.status()).hostEpoch, local.connection.hostEpoch);
+  await assert.rejects(active.connection.request('host.diagnostics.query', {}), {
+    code: 'unauthorized',
+  });
+  await assert.rejects(
+    active.connection.request('access.credential.revoke', { credentialId: pairing.credentialId }),
+    { code: 'unauthorized' },
+  );
+  const removed = await cli('revoke', '--credential-id', pairing.credentialId);
+  assert.deepEqual(removed, { credentialId: pairing.credentialId, revoked: true });
+  revoked = true;
+  let timer;
+  try {
+    await Promise.race([
+      active.connection.closed,
+      new Promise((_, reject) => {
+        timer = setTimeout(() => reject(new Error('revoked transport stayed open')), 3000);
+      }),
+    ]);
+  } finally {
+    clearTimeout(timer);
+  }
+  assert.equal((await connect('pairing-winner')).kind, 'unavailable');
+  assert.equal((await local.connection.status()).state, 'ready');
+  console.log('NATIVE_PAIRING_FINALIZED_BOUND_AND_REVOKED');
+} finally {
+  await Promise.all(connections.map((connection) => connection.close()));
+  if (pairing && !revoked) await cli('revoke', '--credential-id', pairing.credentialId);
+  await local.connection.close();
+}
diff --git a/tests/fixtures/client.mjs b/tests/fixtures/client.mjs
index 5fbf98bab4..59e703572f 100644
--- a/tests/fixtures/client.mjs
+++ b/tests/fixtures/client.mjs
@@ -35,6 +35,7 @@ const entries = [
   ['--onboarding-workspace', 'client-onboarding-entry.mjs'],
   ['--project-workspace', 'client-project-entry.mjs'],
   ['--native-candidate', 'client-candidate-entry.mjs'],
+  ['--native-access', 'client-access-entry.mjs'],
   ['--activation-frame', 'client-activation-entry.mjs'],
   ['--bridge', 'client-bridge-entry.mjs'],
   ['--system-prompt-workspace', 'client-system-prompt-entry.mjs'],


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 18/29: fix(build): bind embedded SDKs to the checked npm installation

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit fa50454bca32f2a0a91a0ec2256163823ff0ce92
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 18:59:50 2026 +0800

    fix(build): bind embedded SDKs to the checked npm installation
    
    Generated-by: OpenAI Codex
---
 crates/config/build.rs            |  9 +++++----
 crates/js-runtime/build.rs        | 19 +++++--------------
 scripts/rust/bundle-providers.mjs | 26 +++++++++++++++++++++++---
 3 files changed, 33 insertions(+), 21 deletions(-)

diff --git a/crates/config/build.rs b/crates/config/build.rs
index 2e792f0c78..682d044606 100644
--- a/crates/config/build.rs
+++ b/crates/config/build.rs
@@ -30,12 +30,13 @@ fn main() {
         println!("cargo:rerun-if-changed={source}");
     }
     println!("cargo:rerun-if-env-changed=MAKA_JS_DEPS");
-    if let Some(dependencies) = std::env::var_os("MAKA_JS_DEPS") {
+    let dependencies = std::env::var_os("MAKA_JS_DEPS")
+        .map(std::path::PathBuf::from)
+        .unwrap_or_else(|| "../..".into());
+    for source in ["package-lock.json", "node_modules/.package-lock.json"] {
         println!(
             "cargo:rerun-if-changed={}",
-            std::path::PathBuf::from(dependencies)
-                .join("package-lock.json")
-                .display()
+            dependencies.join(source).display()
         );
     }
     let status = std::process::Command::new("node")
diff --git a/crates/js-runtime/build.rs b/crates/js-runtime/build.rs
index 3be2a86c0f..e959380ecf 100644
--- a/crates/js-runtime/build.rs
+++ b/crates/js-runtime/build.rs
@@ -28,12 +28,13 @@ fn main() {
         println!("cargo:rerun-if-changed={source}");
     }
     println!("cargo:rerun-if-env-changed=MAKA_JS_DEPS");
-    if let Some(root) = std::env::var_os("MAKA_JS_DEPS") {
+    let dependencies = std::env::var_os("MAKA_JS_DEPS")
+        .map(std::path::PathBuf::from)
+        .unwrap_or_else(|| "../..".into());
+    for source in ["package-lock.json", "node_modules/.package-lock.json"] {
         println!(
             "cargo:rerun-if-changed={}",
-            std::path::PathBuf::from(root)
-                .join("package-lock.json")
-                .display()
+            dependencies.join(source).display()
         );
     }
     let status = std::process::Command::new("node")
@@ -57,16 +58,6 @@ fn bundle_providers() {
     println!("cargo:rerun-if-changed=trusted/responses-transport.js");
     println!("cargo:rerun-if-changed=trusted/network-fetch.js");
     println!("cargo:rerun-if-changed=../../scripts/rust/bundle-providers.mjs");
-    println!("cargo:rerun-if-changed=../../package-lock.json");
-    println!("cargo:rerun-if-env-changed=MAKA_JS_DEPS");
-    if let Some(dependencies) = std::env::var_os("MAKA_JS_DEPS") {
-        println!(
-            "cargo:rerun-if-changed={}",
-            std::path::PathBuf::from(dependencies)
-                .join("package-lock.json")
-                .display()
-        );
-    }
     let sources: std::collections::BTreeMap<_, _> = deno_telemetry::deno_telemetry::init()
         .lazy_loaded_js_files
         .iter()
diff --git a/scripts/rust/bundle-providers.mjs b/scripts/rust/bundle-providers.mjs
index ac639aece0..2ace6f764d 100644
--- a/scripts/rust/bundle-providers.mjs
+++ b/scripts/rust/bundle-providers.mjs
@@ -20,6 +20,7 @@
 import { createRequire } from 'node:module';
 import { resolve, join, basename } from 'node:path';
 import { writeFile } from 'node:fs/promises';
+import { readFileSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 
 const root = fileURLToPath(new URL('../../', import.meta.url));
@@ -28,10 +29,17 @@ const require = createRequire(join(dependencyRoot, 'package.json'));
 const { build, transform } = require('esbuild');
 const output = process.argv[2];
 if (!output) throw new Error('Cargo output directory is required');
+const locked = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8'));
+const entry = resolve(root, 'crates/js-runtime/trusted/adapter.js');
+for (const name of ['@ai-sdk/openai', '@ai-sdk/anthropic', '@ai-sdk/openai-compatible']) {
+  if (
+    require(`${name}/package.json`).version !== locked.packages[`node_modules/${name}`]?.version
+  ) {
+    throw new Error(`Provider SDK does not match repository lockfile: ${name}`);
+  }
+}
 await build({
-  entryPoints: [
-    fileURLToPath(new URL('../../crates/js-runtime/trusted/adapter.js', import.meta.url)),
-  ],
+  entryPoints: [entry],
   outfile: resolve(output, 'providers.js'),
   bundle: true,
   platform: 'browser',
@@ -39,6 +47,18 @@ await build({
   globalName: 'MakaProvider',
   target: 'es2024',
   nodePaths: [join(dependencyRoot, 'node_modules')],
+  plugins: [
+    {
+      name: 'locked-provider-dependencies',
+      setup(build) {
+        build.onResolve({ filter: /^@ai-sdk\/(openai|anthropic|openai-compatible)$/ }, (args) => {
+          if (args.importer !== entry) return;
+          // Keep import conditions and transitive resolution, but start from the checked install.
+          return build.resolve(args.path, { resolveDir: dependencyRoot, kind: args.kind });
+        });
+      },
+    },
+  ],
   legalComments: 'eof',
 });
 // Transpile Deno's embedded TypeScript at build time; no compiler or npm


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 05/29: feat(runtime-host): preserve logical identity across handoffs

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 302cfb6e94a1e315dd809e40496089b6cffc9e34
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 14:05:28 2026 +0800

    feat(runtime-host): preserve logical identity across handoffs
    
    Keep exact physical claims and control ownership while projecting stable client identities. Carry WorkHub user authority, resume aliases and streamed transcript evidence across cooperative continuations.
    
    Generated-by: Codex
---
 crates/event-log/src/continuation.rs               |   2 +-
 crates/event-log/src/message_resolution.rs         |   2 +-
 crates/event-log/src/message_resolution/owner.rs   |  15 +-
 crates/event-log/src/observation/delivery.rs       |  20 +-
 crates/event-log/src/observation/streams.rs        |   2 +-
 crates/event-log/src/sessions/activity.rs          |   2 +-
 crates/event-log/src/sessions/partial.rs           |   2 +-
 crates/event-log/src/steering.rs                   |   4 +-
 crates/event-log/src/transcript.rs                 |  13 +-
 crates/event-log/src/transcript/evidence.rs        |   2 +-
 .../event-log/src/transcript/navigation/queries.rs |   2 +-
 crates/event-log/src/transcript/tools.rs           |   2 +-
 crates/event-log/src/transcript/workhub.rs         |  30 +-
 crates/event-log/src/turns.rs                      |  26 +-
 crates/event-log/src/turns/root.rs                 |  77 +++
 crates/event-log/src/workhub.rs                    |  76 ++-
 crates/event-log/src/workhub/actions.rs            |  35 +-
 crates/event-log/src/workhub/candidates.rs         |   7 +-
 crates/event-log/src/workhub/correction/admit.rs   |  32 +-
 crates/event-log/src/workhub/resume.rs             |   2 +-
 crates/event-log/src/workhub/stop/admit.rs         |  35 +-
 crates/event-log/tests/continuation.rs             |   2 +
 crates/event-log/tests/continuation/fixtures.rs    |   8 +-
 .../event-log/tests/continuation/handoff_claim.rs  |  38 ++
 .../tests/continuation/handoff_consumers.rs        | 518 +++++++++++++++++++++
 crates/presentation/src/workhub.rs                 |  13 +
 crates/runtime-host/src/execution.rs               |   2 +-
 crates/runtime-host/src/execution/resume.rs        |  58 ++-
 crates/runtime-host/src/execution/snapshot.rs      |  40 +-
 .../src/server/subscriptions/delivery.rs           |   5 +-
 .../src/server/subscriptions/delivery/events.rs    |   5 +-
 .../src/server/subscriptions/streams.rs            |  49 +-
 crates/runtime-host/src/server/workhub/action.rs   |   4 +-
 .../src/server/workhub/action/correction.rs        |   6 +-
 crates/runtime/src/input.rs                        |  17 +
 docs/rust-runtime.md                               |   3 +-
 docs/rust-runtime.zh-CN.md                         |   2 +-
 37 files changed, 948 insertions(+), 210 deletions(-)

diff --git a/crates/event-log/src/continuation.rs b/crates/event-log/src/continuation.rs
index 564550ba0d..ff7687f836 100644
--- a/crates/event-log/src/continuation.rs
+++ b/crates/event-log/src/continuation.rs
@@ -43,7 +43,7 @@ impl EventLog {
                    ON terminal.invocation_id = opening.invocation_id AND terminal.kind = 'invocation_ended'
                  WHERE opening.kind = 'invocation_opened'
                    AND json_extract(opening.event_json, '$.invocation.session_id') = ?
-                   AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+                   AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
                    AND json_extract(terminal.event_json, '$.fact.outcome.kind') IN ('failed', 'cancelled')
                  ORDER BY opening.sequence DESC LIMIT 1"
             ).bind(session).fetch_optional(connection).await?)
diff --git a/crates/event-log/src/message_resolution.rs b/crates/event-log/src/message_resolution.rs
index 2a8153a274..08273fab38 100644
--- a/crates/event-log/src/message_resolution.rs
+++ b/crates/event-log/src/message_resolution.rs
@@ -74,7 +74,7 @@ impl EventLog {
                     }
                     let latest = crate::turns::read(&mut tx, &session, Some(&owner.turn_id)).await?
                         .ok_or_else(|| StoreError::InvalidTransition("message owner has no Turn".into()))?;
-                    MessageResolution::Owned { message_id, invocation: latest.invocation }
+                    MessageResolution::Owned { message_id, invocation: latest.root_invocation().clone() }
                 } else if crate::message_queue::cancelled(&mut tx, &session, &message_id).await? {
                     MessageResolution::Cancelled { message_id }
                 } else {
diff --git a/crates/event-log/src/message_resolution/owner.rs b/crates/event-log/src/message_resolution/owner.rs
index cd60425144..7b4b877595 100644
--- a/crates/event-log/src/message_resolution/owner.rs
+++ b/crates/event-log/src/message_resolution/owner.rs
@@ -176,7 +176,7 @@ async fn lineage(
         let successors: Vec<Option<String>> = sqlx::query_scalar(
             "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
              FROM runtime_events WHERE kind = 'invocation_opened'
-             AND json_extract(event_json, '$.fact.input.kind') = 'continuation'
+             AND json_extract(event_json, '$.fact.input.kind') IN ('continuation', 'handoff')
              AND json_extract(event_json, '$.invocation.session_id') = ?
              AND json_extract(event_json, '$.fact.input.claim.source.invocation.run_id') = ?
              LIMIT 2",
@@ -193,14 +193,15 @@ async fn lineage(
         };
         let successor: RuntimeEvent =
             serde_json::from_str(&successor.ok_or(StoreError::PrefixTooLarge)?)?;
-        let Fact::InvocationOpened {
-            input: InvocationInput::Continuation { claim, .. },
-            ..
-        } = &successor.fact
-        else {
+        let Fact::InvocationOpened { input, .. } = &successor.fact else {
             return Err(invalid("Message owner continuation index changed"));
         };
-        claim.validate(&successor.invocation).map_err(invalid)?;
+        input
+            .validate_inheritance(&successor.invocation)
+            .map_err(invalid)?;
+        let claim = input
+            .inherited_claim()
+            .ok_or_else(|| invalid("Message successor has no inherited boundary"))?;
         if claim.source.invocation != current {
             return Err(invalid(
                 "Message owner continuation belongs to another source",
diff --git a/crates/event-log/src/observation/delivery.rs b/crates/event-log/src/observation/delivery.rs
index c901c71f9c..60a7e26386 100644
--- a/crates/event-log/src/observation/delivery.rs
+++ b/crates/event-log/src/observation/delivery.rs
@@ -30,6 +30,7 @@ pub struct StoreStreamEvent {
     pub sequence: u64,
     pub id: String,
     pub invocation: Invocation,
+    pub root_run_id: String,
     pub recorded_at: std::time::SystemTime,
     pub fact: StreamFact,
 }
@@ -221,6 +222,7 @@ impl EventLog {
 // CASE evaluates only the selected arm: accepted model output, tool input/output,
 // and provider options are never included in the selected delivery value.
 const STREAM_EVENTS: &str = "
+WITH events AS MATERIALIZED (
 SELECT json_object(
     'sequence', sequence, 'id', event_id,
     'recorded_at', json_extract(event_json, '$.recorded_at'),
@@ -256,7 +258,7 @@ SELECT json_object(
         ELSE json_object('kind', 'part_finished',
                 'step_id', json_extract(event_json, '$.fact.step_id'),
                 'part_id', json_extract(event_json, '$.fact.event.data.id'))
-    END))
+    END)) AS projected, invocation_id AS owner, sequence
 FROM runtime_events
 WHERE sequence > ?2 AND sequence <= ?3
 AND json_extract(event_json, '$.invocation.session_id') = ?1
@@ -271,11 +273,23 @@ AND (kind NOT IN ('model_observed', 'model_completed', 'model_interrupted') OR
              AND opening.kind = 'invocation_opened'
              WHERE request.kind = 'model_requested' AND request.invocation_id = runtime_events.invocation_id
              AND request.operation_id = json_extract(runtime_events.event_json, '$.fact.step_id')
-             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main'))
 AND (kind IN ('invocation_opened', 'message_steered', 'invocation_ended', 'model_completed', 'model_interrupted',
              'tool_dispatched', 'tool_rejected', 'tool_settled', 'workhub_delegated')
      OR (kind = 'model_observed'
          AND json_extract(event_json, '$.fact.event.kind') IN
              ('part_started', 'part_delta', 'part_finished')))
-ORDER BY sequence LIMIT ?4";
+ORDER BY sequence LIMIT ?4
+), roots AS MATERIALIZED (
+ SELECT opening.invocation_id,
+   CASE WHEN json_extract(opening.event_json,'$.fact.input.kind')='handoff'
+     THEN json_extract(opening.event_json,'$.fact.input.pause.intent.root_run_id')
+     ELSE json_extract(opening.event_json,'$.invocation.run_id') END AS run_id
+ FROM runtime_events opening
+ JOIN (SELECT DISTINCT owner FROM events) owners ON owners.owner=opening.invocation_id
+ WHERE opening.kind='invocation_opened'
+)
+SELECT json_set(events.projected, '$.root_run_id', roots.run_id)
+FROM events LEFT JOIN roots ON roots.invocation_id=events.owner
+ORDER BY events.sequence";
diff --git a/crates/event-log/src/observation/streams.rs b/crates/event-log/src/observation/streams.rs
index 238293f30d..6078e68660 100644
--- a/crates/event-log/src/observation/streams.rs
+++ b/crates/event-log/src/observation/streams.rs
@@ -73,7 +73,7 @@ pub(super) async fn read(
              AND opening.kind = 'invocation_opened'
              WHERE request.kind = 'model_requested' AND request.invocation_id = s.invocation_id
              AND request.operation_id = json_extract(s.event_json, '$.fact.step_id')
-                     AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+                     AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main')
          AND json_extract(s.event_json, '$.fact.event.kind') = 'part_started'
          AND NOT EXISTS (
diff --git a/crates/event-log/src/sessions/activity.rs b/crates/event-log/src/sessions/activity.rs
index 1ba483de43..a50a0bfc45 100644
--- a/crates/event-log/src/sessions/activity.rs
+++ b/crates/event-log/src/sessions/activity.rs
@@ -182,7 +182,7 @@ pub(crate) async fn project_execution(
              AND opening.kind = 'invocation_opened'
              WHERE request.kind = 'model_requested' AND request.invocation_id = ?1
              AND request.operation_id = ?2
-             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main')",
         )
         .bind(&invocation)
diff --git a/crates/event-log/src/sessions/partial.rs b/crates/event-log/src/sessions/partial.rs
index a7d7d47524..c9efa5f3fa 100644
--- a/crates/event-log/src/sessions/partial.rs
+++ b/crates/event-log/src/sessions/partial.rs
@@ -134,7 +134,7 @@ pub(super) async fn project(
              AND opening.kind = 'invocation_opened'
              WHERE request.kind = 'model_requested' AND request.invocation_id = ?1
              AND request.operation_id = ?2
-             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main')",
     )
     .bind(invocation)
diff --git a/crates/event-log/src/steering.rs b/crates/event-log/src/steering.rs
index 2e986560a8..0b858fc5ad 100644
--- a/crates/event-log/src/steering.rs
+++ b/crates/event-log/src/steering.rs
@@ -34,11 +34,11 @@ pub(crate) async fn validate_append(
         .map_err(|error| StoreError::InvalidTransition(error.into()))?;
     let eligible: bool = sqlx::query_scalar(
         "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id = ?
-         AND kind = 'invocation_opened' AND json_extract(event_json, '$.fact.input.kind') IN ('message', 'continuation'))"
+         AND kind = 'invocation_opened' AND json_extract(event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff'))"
     ).bind(&event.invocation.invocation_id).fetch_one(&mut *tx).await?;
     if !eligible {
         return Err(StoreError::InvalidTransition(
-            "steering requires a message or continuation invocation".into(),
+            "steering requires an inline model invocation".into(),
         ));
     }
     let pending: bool = sqlx::query_scalar(crate::recovery::unresolved!(
diff --git a/crates/event-log/src/transcript.rs b/crates/event-log/src/transcript.rs
index 21d7467765..224a2f2dd2 100644
--- a/crates/event-log/src/transcript.rs
+++ b/crates/event-log/src/transcript.rs
@@ -127,7 +127,7 @@ impl EventLog {
              AND opening.kind = 'invocation_opened'
              WHERE request.kind = 'model_requested' AND request.invocation_id = runtime_events.invocation_id
              AND request.operation_id = runtime_events.operation_id
-             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main'))
                    AND kind IN ('invocation_opened', 'message_steered', 'model_completed',
                                 'model_interrupted', 'invocation_ended',
@@ -174,6 +174,17 @@ impl EventLog {
                         .await?;
                         let mut view = InvocationView::new(MAX_TEXT_BYTES)?;
                         for fact in facts {
+                            if let Fact::WorkhubDelegated { delegation } = &fact.event.fact {
+                                let source = crate::workhub::source_message(
+                                    &mut tx,
+                                    &fact.event.invocation,
+                                    Some(&delegation.source_message_event_id),
+                                ).await?;
+                                if let Some(row) = maka_presentation::workhub::delegated(&fact, &source)? {
+                                    persist(&mut tx, &session, row).await?;
+                                }
+                                continue;
+                            }
                             let resolved = if let Fact::ToolSettled {
                                 outcome: ToolOutcome::Succeeded { raw, .. },
                                 ..
diff --git a/crates/event-log/src/transcript/evidence.rs b/crates/event-log/src/transcript/evidence.rs
index aaf1df3c8f..302f2732d8 100644
--- a/crates/event-log/src/transcript/evidence.rs
+++ b/crates/event-log/src/transcript/evidence.rs
@@ -147,7 +147,7 @@ pub(super) async fn pending_step(
          WHERE invocation_id = ?1 AND sequence <= ?2 AND kind = 'model_requested'
            AND EXISTS(SELECT 1 FROM runtime_events opening
                WHERE opening.invocation_id = request.invocation_id AND opening.kind = 'invocation_opened'
-               AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation'))
+               AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff'))
            AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main'
            AND NOT EXISTS(SELECT 1 FROM runtime_events AS boundary
                WHERE boundary.invocation_id = request.invocation_id
diff --git a/crates/event-log/src/transcript/navigation/queries.rs b/crates/event-log/src/transcript/navigation/queries.rs
index 2810e12e2e..1bf5d177b7 100644
--- a/crates/event-log/src/transcript/navigation/queries.rs
+++ b/crates/event-log/src/transcript/navigation/queries.rs
@@ -22,7 +22,7 @@ SELECT MAX(ending.sequence) FROM runtime_events ending
 JOIN runtime_events opening ON opening.invocation_id = ending.invocation_id AND opening.kind = 'invocation_opened'
 WHERE json_extract(ending.event_json, '$.invocation.session_id') = ?1
 AND ending.kind = 'invocation_ended'
-AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')";
+AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')";
 
 pub(super) const ROWS: &str = "
 SELECT row.sequence, row.turn_id,
diff --git a/crates/event-log/src/transcript/tools.rs b/crates/event-log/src/transcript/tools.rs
index 08723bfdbd..5b47cd6c72 100644
--- a/crates/event-log/src/transcript/tools.rs
+++ b/crates/event-log/src/transcript/tools.rs
@@ -93,7 +93,7 @@ pub(super) async fn selected(
              AND opening.kind = 'invocation_opened'
              WHERE request.invocation_id = ?1 AND request.operation_id = ?2
              AND request.kind = 'model_requested'
-             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+             AND json_extract(opening.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND COALESCE(json_extract(request.event_json, '$.fact.purpose'), 'main') = 'main')",
         )
         .bind(invocation)
diff --git a/crates/event-log/src/transcript/workhub.rs b/crates/event-log/src/transcript/workhub.rs
index ead8765ba9..e0cd3bd911 100644
--- a/crates/event-log/src/transcript/workhub.rs
+++ b/crates/event-log/src/transcript/workhub.rs
@@ -93,34 +93,12 @@ async fn correction(
         .ok_or_else(|| {
             StoreError::InvalidTransition("WorkHub replaced delegation is missing".into())
         })?;
-    let json: Option<Option<String>> = sqlx::query_scalar(
-        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
-         FROM runtime_events WHERE event_id = ? AND kind = 'invocation_opened'",
+    let content = crate::workhub::source_message(
+        tx,
+        &intent.request.source,
+        Some(&intent.request.source_message_event_id),
     )
-    .bind(&intent.request.source_message_event_id)
-    .fetch_optional(tx)
     .await?;
-    let source: maka_runtime::event::RuntimeEvent = serde_json::from_str(
-        &json
-            .ok_or_else(|| {
-                StoreError::InvalidTransition("WorkHub correction source is missing".into())
-            })?
-            .ok_or(StoreError::PrefixTooLarge)?,
-    )?;
-    if source.invocation != intent.request.source {
-        return Err(StoreError::InvalidTransition(
-            "WorkHub correction source changed".into(),
-        ));
-    }
-    let maka_runtime::event::Fact::InvocationOpened {
-        input: maka_runtime::input::InvocationInput::Message { content, .. },
-        ..
-    } = source.fact
-    else {
-        return Err(StoreError::InvalidTransition(
-            "WorkHub correction requires a user message".into(),
-        ));
-    };
     Ok(maka_presentation::workhub::correction(
         sequence_number(sequence)?,
         event,
diff --git a/crates/event-log/src/turns.rs b/crates/event-log/src/turns.rs
index bec63dc703..2fb759c01c 100644
--- a/crates/event-log/src/turns.rs
+++ b/crates/event-log/src/turns.rs
@@ -22,12 +22,34 @@ use maka_runtime::event::{Fact, Invocation, InvocationOutcome, RuntimeEvent};
 use maka_runtime::input::InvocationInput;
 use sqlx::Connection;
 
+mod root;
+
 /// A bounded projection of canonical boundaries, never a model-history prefix.
 pub struct TurnBoundary {
     pub opening_event_id: String,
     pub invocation: Invocation,
     pub input: InvocationInput,
     pub state: InvocationState,
+    root: Option<root::Origin>,
+}
+
+impl TurnBoundary {
+    /// Stable client identity. Execution and cleanup still use `invocation`.
+    pub fn root_invocation(&self) -> &Invocation {
+        self.root
+            .as_ref()
+            .map_or(&self.invocation, |root| &root.invocation)
+    }
+
+    pub fn root_input(&self) -> &InvocationInput {
+        self.root.as_ref().map_or(&self.input, |root| &root.input)
+    }
+
+    pub fn root_opening_event_id(&self) -> &str {
+        self.root
+            .as_ref()
+            .map_or(&self.opening_event_id, |root| &root.event_id)
+    }
 }
 pub enum InvocationState {
     Admitted,
@@ -68,7 +90,7 @@ impl EventLog {
             .await
     }
 
-    /// At most two event bodies are decoded, irrespective of streamed history.
+    /// Only opening, logical root and terminal bodies are decoded, irrespective of streamed history.
     /// Reading control/status must remain possible after context budgets fill.
     pub async fn turn_boundary(
         &self,
@@ -150,6 +172,7 @@ pub(crate) async fn project(
             "invalid stored invocation opening".into(),
         ));
     };
+    let root = root::read(connection, &opening.invocation, &input).await?;
     let terminal: Option<String> = sqlx::query_scalar(
             "SELECT event_json FROM runtime_events WHERE invocation_id = ? AND kind = 'invocation_ended'",
         ).bind(&opening.invocation.invocation_id).fetch_optional(&mut *connection).await?;
@@ -189,6 +212,7 @@ pub(crate) async fn project(
         }
     };
     Ok(TurnBoundary {
+        root,
         opening_event_id: opening.id,
         invocation: opening.invocation,
         input,
diff --git a/crates/event-log/src/turns/root.rs b/crates/event-log/src/turns/root.rs
new file mode 100644
index 0000000000..373f8fa9fc
--- /dev/null
+++ b/crates/event-log/src/turns/root.rs
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::StoreError;
+use maka_runtime::{
+    event::{Fact, Invocation, RuntimeEvent},
+    input::InvocationInput,
+};
+use sqlx::SqliteConnection;
+
+pub(super) struct Origin {
+    pub event_id: String,
+    pub invocation: Invocation,
+    pub input: InvocationInput,
+}
+
+pub(super) async fn read(
+    tx: &mut SqliteConnection,
+    current: &Invocation,
+    input: &InvocationInput,
+) -> Result<Option<Origin>, StoreError> {
+    let InvocationInput::Handoff { pause, .. } = input else {
+        return Ok(None);
+    };
+    input.validate_inheritance(current).map_err(invalid)?;
+    // The canonical seal already authenticated this root across every handoff.
+    // Resolve provenance once, without replaying model/tool bodies or guessing latest.
+    let rows: Vec<String> = sqlx::query_scalar(
+        "SELECT event_json FROM runtime_events WHERE kind='invocation_opened'
+         AND json_extract(event_json,'$.invocation.session_id')=?1
+         AND json_extract(event_json,'$.invocation.turn_id')=?2
+         AND json_extract(event_json,'$.invocation.run_id')=?3 LIMIT 2",
+    )
+    .bind(&current.session_id)
+    .bind(&current.turn_id)
+    .bind(&pause.intent.root_run_id)
+    .fetch_all(tx)
+    .await?;
+    let [root] = rows.as_slice() else {
+        return Err(invalid("handoff has no unique logical root"));
+    };
+    let root: RuntimeEvent = serde_json::from_str(root)?;
+    let Fact::InvocationOpened { input, .. } = root.fact else {
+        return Err(invalid("logical root has no opening"));
+    };
+    if !matches!(
+        input,
+        InvocationInput::Message { .. } | InvocationInput::Continuation { .. }
+    ) {
+        return Err(invalid("handoff root is not a fresh logical model Run"));
+    }
+    Ok(Some(Origin {
+        event_id: root.id,
+        invocation: root.invocation,
+        input,
+    }))
+}
+
+fn invalid(reason: &str) -> StoreError {
+    StoreError::InvalidTransition(reason.into())
+}
diff --git a/crates/event-log/src/workhub.rs b/crates/event-log/src/workhub.rs
index 3e053f7cf0..e6f6ca1051 100644
--- a/crates/event-log/src/workhub.rs
+++ b/crates/event-log/src/workhub.rs
@@ -64,29 +64,8 @@ async fn deliver(
     if control {
         return Err(invalid("WorkHub action already belongs to a stop"));
     }
-    let json: Option<Option<String>> = sqlx::query_scalar(
-        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
-         FROM runtime_events WHERE event_id = ? AND kind = 'invocation_opened'",
-    )
-    .bind(&delegation.source_message_event_id)
-    .fetch_optional(&mut *tx)
-    .await?;
-    let source: RuntimeEvent = serde_json::from_str(
-        &json
-            .ok_or_else(|| invalid("WorkHub source message is missing"))?
-            .ok_or(StoreError::PrefixTooLarge)?,
-    )?;
-    if source.invocation != *coordinator {
-        return Err(invalid(
-            "WorkHub action cannot borrow another Turn's user authority",
-        ));
-    }
-    let InvocationInput::Message { content, .. } = (match source.fact {
-        Fact::InvocationOpened { input, .. } => input,
-        _ => return Err(invalid("invalid WorkHub source opening")),
-    }) else {
-        return Err(invalid("WorkHub action requires a user message"));
-    };
+    let content =
+        source_message(tx, coordinator, Some(&delegation.source_message_event_id)).await?;
     let occupied: bool = sqlx::query_scalar(
         "SELECT EXISTS(
          SELECT 1 FROM runtime_events WHERE invocation_id = ?1
@@ -229,6 +208,57 @@ async fn deliver(
     Ok(())
 }
 
+/// User authority follows the authenticated logical root, while action effects
+/// belong to the physical coordinator. Durable correction delivery may outlive it.
+pub(crate) async fn source_message(
+    tx: &mut SqliteConnection,
+    coordinator: &Invocation,
+    expected_event_id: Option<&str>,
+) -> Result<maka_runtime::input::MessageInput, StoreError> {
+    if coordinator.session_id != maka_runtime::workhub::COORDINATION_SESSION_ID {
+        return Err(invalid("WorkHub source is not its coordinator"));
+    }
+    let rows: Vec<Option<String>> = sqlx::query_scalar(
+        "SELECT CASE WHEN length(CAST(root.event_json AS BLOB)) <= 1048576 THEN root.event_json END
+         FROM runtime_events current JOIN runtime_events root
+           ON root.kind = 'invocation_opened'
+           AND json_extract(root.event_json, '$.invocation.session_id') = ?2
+           AND json_extract(root.event_json, '$.invocation.turn_id') = ?3
+           AND json_extract(root.event_json, '$.invocation.run_id') = CASE
+             WHEN json_extract(current.event_json, '$.fact.input.kind') = 'handoff'
+             THEN json_extract(current.event_json, '$.fact.input.pause.intent.root_run_id')
+             ELSE ?4 END
+         WHERE current.kind = 'invocation_opened' AND current.invocation_id = ?1
+           AND json_extract(current.event_json, '$.invocation.session_id') = ?2
+           AND json_extract(current.event_json, '$.invocation.turn_id') = ?3
+           AND json_extract(current.event_json, '$.invocation.run_id') = ?4 LIMIT 2",
+    )
+    .bind(&coordinator.invocation_id)
+    .bind(&coordinator.session_id)
+    .bind(&coordinator.turn_id)
+    .bind(&coordinator.run_id)
+    .fetch_all(tx)
+    .await?;
+    let [json] = rows.as_slice() else {
+        return Err(invalid("WorkHub source has no unique logical root"));
+    };
+    let source: RuntimeEvent =
+        serde_json::from_str(json.as_deref().ok_or(StoreError::PrefixTooLarge)?)?;
+    if expected_event_id.is_some_and(|expected| expected != source.id) {
+        return Err(invalid(
+            "WorkHub action cannot borrow another user decision",
+        ));
+    }
+    let Fact::InvocationOpened {
+        input: InvocationInput::Message { content, .. },
+        ..
+    } = source.fact
+    else {
+        return Err(invalid("WorkHub action requires a user message"));
+    };
+    Ok(content)
+}
+
 fn invalid(reason: &str) -> StoreError {
     StoreError::InvalidTransition(reason.into())
 }
diff --git a/crates/event-log/src/workhub/actions.rs b/crates/event-log/src/workhub/actions.rs
index e66c4bec0c..964155d1f2 100644
--- a/crates/event-log/src/workhub/actions.rs
+++ b/crates/event-log/src/workhub/actions.rs
@@ -87,30 +87,15 @@ pub(crate) async fn read(
 pub(super) async fn require_coordinator(
     tx: &mut SqliteConnection,
     coordinator: &Invocation,
-) -> Result<(), StoreError> {
-    let source: Option<Option<String>> = sqlx::query_scalar(
-        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
-         FROM runtime_events o WHERE o.kind = 'invocation_opened' AND o.invocation_id = ?
-         AND NOT EXISTS(SELECT 1 FROM runtime_events t WHERE t.invocation_id = o.invocation_id AND t.kind = 'invocation_ended')"
-    ).bind(&coordinator.invocation_id).fetch_optional(tx).await?;
-    let source: RuntimeEvent = serde_json::from_str(
-        &source
-            .ok_or_else(|| super::invalid("WorkHub source is no longer active"))?
-            .ok_or(StoreError::PrefixTooLarge)?,
-    )?;
-    if source.invocation != *coordinator
-        || coordinator.session_id != maka_runtime::workhub::COORDINATION_SESSION_ID
-        || !matches!(
-            source.fact,
-            Fact::InvocationOpened {
-                input: maka_runtime::input::InvocationInput::Message { .. },
-                ..
-            }
-        )
-    {
-        return Err(super::invalid(
-            "WorkHub source is not its authorized message",
-        ));
+    source_event_id: Option<&str>,
+) -> Result<maka_runtime::input::MessageInput, StoreError> {
+    let live: bool = sqlx::query_scalar(
+        "SELECT EXISTS(SELECT 1 FROM runtime_events o
+         WHERE o.kind = 'invocation_opened' AND o.invocation_id = ?
+         AND NOT EXISTS(SELECT 1 FROM runtime_events t WHERE t.invocation_id = o.invocation_id AND t.kind = 'invocation_ended'))"
+    ).bind(&coordinator.invocation_id).fetch_one(&mut *tx).await?;
+    if !live {
+        return Err(super::invalid("WorkHub source is no longer active"));
     }
-    Ok(())
+    super::source_message(tx, coordinator, source_event_id).await
 }
diff --git a/crates/event-log/src/workhub/candidates.rs b/crates/event-log/src/workhub/candidates.rs
index 493859cf5c..c16bb9c243 100644
--- a/crates/event-log/src/workhub/candidates.rs
+++ b/crates/event-log/src/workhub/candidates.rs
@@ -189,12 +189,11 @@ pub(super) async fn require_available(
         let live: Option<bool> = sqlx::query_scalar(
             "SELECT NOT EXISTS(SELECT 1 FROM runtime_events t WHERE t.invocation_id = o.invocation_id
                 AND t.kind = 'invocation_ended') FROM runtime_events o WHERE o.invocation_id = ?
-             AND o.kind = 'invocation_opened' AND json_extract(o.event_json, '$.fact.input.kind') IN ('message', 'continuation')
+             AND o.kind = 'invocation_opened' AND json_extract(o.event_json, '$.fact.input.kind') IN ('message', 'continuation', 'handoff')
              AND json_extract(o.event_json, '$.invocation.session_id') = ?"
         ).bind(&owner.invocation_id).bind(session).fetch_optional(&mut *tx).await?;
-        let live = live.ok_or_else(|| {
-            super::invalid("WorkHub steering requires a message or continuation owner")
-        })?;
+        let live =
+            live.ok_or_else(|| super::invalid("WorkHub steering requires an inline model owner"))?;
         live.then_some(owner)
     } else {
         None
diff --git a/crates/event-log/src/workhub/correction/admit.rs b/crates/event-log/src/workhub/correction/admit.rs
index 30ef7ab1bb..db460ae5d0 100644
--- a/crates/event-log/src/workhub/correction/admit.rs
+++ b/crates/event-log/src/workhub/correction/admit.rs
@@ -19,10 +19,7 @@
 
 use super::{CorrectionIntent, CorrectionRequest, StoreError, assignment, invalid};
 use crate::message_resolution::{MessageExecution, owner};
-use maka_runtime::{
-    event::{Fact, Invocation, RuntimeEvent},
-    input::InvocationInput,
-};
+use maka_runtime::event::Invocation;
 use sqlx::SqliteConnection;
 
 pub(super) async fn apply(
@@ -42,31 +39,12 @@ pub(super) async fn apply(
             "WorkHub action already belongs to another operation",
         ));
     }
-    super::super::actions::require_coordinator(tx, &request.source).await?;
-    let source: Option<Option<String>> = sqlx::query_scalar(
-        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
-         FROM runtime_events WHERE event_id = ? AND kind = 'invocation_opened'",
+    let content = super::super::actions::require_coordinator(
+        tx,
+        &request.source,
+        Some(&request.source_message_event_id),
     )
-    .bind(&request.source_message_event_id)
-    .fetch_optional(&mut *tx)
     .await?;
-    let source: RuntimeEvent = serde_json::from_str(
-        &source
-            .ok_or_else(|| invalid("WorkHub correction source is missing"))?
-            .ok_or(StoreError::PrefixTooLarge)?,
-    )?;
-    if source.invocation != request.source {
-        return Err(invalid(
-            "WorkHub correction cannot borrow another user decision",
-        ));
-    }
-    let Fact::InvocationOpened {
-        input: InvocationInput::Message { content, .. },
-        ..
-    } = source.fact
-    else {
-        return Err(invalid("WorkHub correction requires a user message"));
-    };
     // The target Message envelope has the same fixed labels as Delegation::message.
     if content.text_bytes()
         + request.delegation_text.len()
diff --git a/crates/event-log/src/workhub/resume.rs b/crates/event-log/src/workhub/resume.rs
index 334cd30cc9..2915d469da 100644
--- a/crates/event-log/src/workhub/resume.rs
+++ b/crates/event-log/src/workhub/resume.rs
@@ -87,7 +87,7 @@ pub(super) async fn validate(
         return Ok(());
     }
     let origin = validate_record(event)?;
-    actions::require_coordinator(tx, &origin.coordinator).await?;
+    actions::require_coordinator(tx, &origin.coordinator, None).await?;
     let stopped: bool = sqlx::query_scalar(
         "SELECT EXISTS(SELECT 1 FROM workhub_stops WHERE action_id = ?1
          OR (delegation_action_id = ?2 AND (resolution_json IS NULL
diff --git a/crates/event-log/src/workhub/stop/admit.rs b/crates/event-log/src/workhub/stop/admit.rs
index 2ad0efa583..5d9b36d8e4 100644
--- a/crates/event-log/src/workhub/stop/admit.rs
+++ b/crates/event-log/src/workhub/stop/admit.rs
@@ -19,10 +19,7 @@
 
 use super::{StopIntent, StopRecord, StopRequest, StopResolution, invalid, subject};
 use crate::{StoreError, message_resolution::MessageExecution, turns::InvocationState};
-use maka_runtime::{
-    event::{Fact, RuntimeEvent},
-    session_event::{SessionEvent, SessionFact},
-};
+use maka_runtime::session_event::{SessionEvent, SessionFact};
 use sqlx::SqliteConnection;
 
 pub(super) async fn apply(
@@ -37,28 +34,7 @@ pub(super) async fn apply(
             "WorkHub action already belongs to another operation",
         ));
     }
-    let source: Option<String> = sqlx::query_scalar(
-        "SELECT event_json FROM runtime_events o WHERE o.kind = 'invocation_opened'
-         AND o.invocation_id = ? AND NOT EXISTS(SELECT 1 FROM runtime_events t
-           WHERE t.invocation_id = o.invocation_id AND t.kind = 'invocation_ended')",
-    )
-    .bind(&request.source.invocation_id)
-    .fetch_optional(&mut *tx)
-    .await?;
-    let source: RuntimeEvent = serde_json::from_str(
-        &source.ok_or_else(|| invalid("WorkHub source is no longer active"))?,
-    )?;
-    if source.invocation != request.source
-        || !matches!(
-            source.fact,
-            Fact::InvocationOpened {
-                input: maka_runtime::input::InvocationInput::Message { .. },
-                ..
-            }
-        )
-    {
-        return Err(invalid("WorkHub stop source is not its authorized message"));
-    }
+    let content = super::super::actions::require_coordinator(tx, &request.source, None).await?;
     let target: Option<(bool, Option<String>)> = sqlx::query_as(
         "SELECT archived, CASE
             WHEN json_type(configuration, '$.name') = 'text'
@@ -122,13 +98,6 @@ pub(super) async fn apply(
             ));
         }
     };
-    let Fact::InvocationOpened {
-        input: maka_runtime::input::InvocationInput::Message { content, .. },
-        ..
-    } = source.fact
-    else {
-        unreachable!()
-    };
     let mut sequence = super::super::control::append(
         tx,
         &SessionEvent::workhub(
diff --git a/crates/event-log/tests/continuation.rs b/crates/event-log/tests/continuation.rs
index bf591f400c..c150636d9b 100644
--- a/crates/event-log/tests/continuation.rs
+++ b/crates/event-log/tests/continuation.rs
@@ -37,6 +37,8 @@ mod handoff;
 mod handoff_budget;
 #[path = "continuation/handoff_claim.rs"]
 mod handoff_claim;
+#[path = "continuation/handoff_consumers.rs"]
+mod handoff_consumers;
 use fixtures::*;
 
 #[tokio::test]
diff --git a/crates/event-log/tests/continuation/fixtures.rs b/crates/event-log/tests/continuation/fixtures.rs
index 71215f2695..e48880a743 100644
--- a/crates/event-log/tests/continuation/fixtures.rs
+++ b/crates/event-log/tests/continuation/fixtures.rs
@@ -89,7 +89,13 @@ pub(super) async fn claim(
     base: SessionBase,
 ) -> ContinuationClaim {
     let prefix = log
-        .run_prefix("session", &source.invocation.run_id, None, 100, 65536)
+        .run_prefix(
+            &source.invocation.session_id,
+            &source.invocation.run_id,
+            None,
+            100,
+            65536,
+        )
         .await
         .unwrap()
         .unwrap();
diff --git a/crates/event-log/tests/continuation/handoff_claim.rs b/crates/event-log/tests/continuation/handoff_claim.rs
index 978dc83e93..a064479df3 100644
--- a/crates/event-log/tests/continuation/handoff_claim.rs
+++ b/crates/event-log/tests/continuation/handoff_claim.rs
@@ -161,6 +161,34 @@ async fn handoff_claim_preserves_turn_and_frozen_ancestry_without_releasing_rese
             target.id
         );
         assert_eq!(target.invocation.turn_id, root.invocation.turn_id);
+        let logical = log
+            .turn_boundary("session", &root.invocation.turn_id)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            logical.invocation, target.invocation,
+            "control owns the physical tip"
+        );
+        assert_eq!(
+            logical.root_invocation(),
+            &root.invocation,
+            "client identity is stable across handoffs"
+        );
+        assert_eq!(logical.root_opening_event_id(), root.id);
+        assert!(matches!(
+            logical.root_input(),
+            InvocationInput::Message { .. }
+        ));
+        let physical = log
+            .run_boundary("session", &source.invocation.run_id)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            physical.invocation, source.invocation,
+            "exact Run reads never jump to a successor"
+        );
         let mut view = InvocationView::new(1024).unwrap();
         assert!(
             view.push(&StoredEvent {
@@ -177,6 +205,16 @@ async fn handoff_claim_preserves_turn_and_frozen_ancestry_without_releasing_rese
     let last = claim(&log, "manual", &source, base).await;
     let manual = opening("manual-turn", Some(last));
     append(&log, &manual).await;
+    let fresh = log
+        .turn_boundary("session", &manual.invocation.turn_id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        fresh.root_invocation(),
+        &manual.invocation,
+        "manual resume starts a fresh logical Turn"
+    );
     let Fact::InvocationOpened {
         input: InvocationInput::Continuation { claim, .. },
         ..
diff --git a/crates/event-log/tests/continuation/handoff_consumers.rs b/crates/event-log/tests/continuation/handoff_consumers.rs
new file mode 100644
index 0000000000..957d116813
--- /dev/null
+++ b/crates/event-log/tests/continuation/handoff_consumers.rs
@@ -0,0 +1,518 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_event_log::{
+    message_resolution::{MessageExecution, MessageResolution},
+    observation::StreamFact,
+    transcript::{TranscriptDirection, TranscriptRead},
+};
+use maka_runtime::{
+    artifact::content_digest,
+    input::DeliveredMessage,
+    message::{MessageDisposition, Placement, RootSourceMessage},
+    model::{ModelEvent, ModelPart, ModelStep, TextKind},
+    workhub::{
+        COORDINATION_SESSION_ID, CorrectionRequest, CorrectionTarget, Delegation,
+        DelegationDescription,
+    },
+};
+use serde_json::{Value, json};
+
+async fn successor(log: &EventLog, source: &RuntimeEvent) -> RuntimeEvent {
+    use maka_runtime::handoff::{HandoffIntent, HandoffPause};
+    let Fact::InvocationOpened {
+        configuration,
+        input,
+    } = &source.fact
+    else {
+        unreachable!()
+    };
+    let base = if let Some(claim) = input.inherited_claim() {
+        claim.base.clone()
+    } else {
+        let context = log
+            .context_before_run(&source.invocation, 100, 65536)
+            .await
+            .unwrap();
+        SessionBase {
+            high_water: context.source_evidence.high_water,
+            digest: context.source_evidence.digest,
+        }
+    };
+    let root = match input {
+        InvocationInput::Handoff { pause, .. } => &pause.intent.root_run_id,
+        _ => &source.invocation.run_id,
+    };
+    let next = format!("{}-next", source.invocation.run_id);
+    let pause = HandoffPause {
+        intent: HandoffIntent {
+            handoff_id: next.clone(),
+            host_epoch: "host".into(),
+            root_run_id: root.clone(),
+            successor_run_id: next.clone(),
+            successor_invocation_id: format!("invocation-{next}"),
+            claim_id: next,
+        },
+        remaining_steps: std::num::NonZeroU16::new(2).unwrap(),
+        execution: super::handoff::execution(),
+    };
+    append(
+        log,
+        &RuntimeEvent::new(
+            source.invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::HandoffPaused {
+                    pause: pause.clone(),
+                },
+            },
+        ),
+    )
+    .await;
+    let claim = claim(log, &pause.intent.claim_id, source, base).await;
+    let next = RuntimeEvent::new(
+        pause.intent.successor(&source.invocation),
+        Fact::InvocationOpened {
+            configuration: configuration.clone(),
+            input: InvocationInput::Handoff {
+                claim: Box::new(claim),
+                pause: Box::new(pause),
+            },
+        },
+    );
+    append(log, &next).await;
+    next
+}
+
+#[tokio::test]
+async fn handoff_output_keeps_one_public_identity_and_all_physical_evidence_after_reopen() {
+    let temp = tempfile::tempdir().unwrap();
+    let path = temp.path().join("events.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    log.create_session("session", "create", &json!({}), 1)
+        .await
+        .unwrap();
+    let mut root = opening("root", None);
+    let Fact::InvocationOpened {
+        input:
+            InvocationInput::Message {
+                content,
+                source_messages,
+                ..
+            },
+        ..
+    } = &mut root.fact
+    else {
+        unreachable!()
+    };
+    source_messages.push(RootSourceMessage {
+        message: DeliveredMessage {
+            message_id: "user-message".into(),
+            submitted_content_digest: content.content_digest().unwrap(),
+            content: content.clone(),
+        },
+        submitted_placement: Placement::NextTurn,
+        disposition: MessageDisposition::TurnStarted,
+        skill_invocation: Default::default(),
+        submitted_intent: None,
+    });
+    append(&log, &root).await;
+    let mut current = root.clone();
+    for text in ["first answer", "second answer"] {
+        current = successor(&log, &current).await;
+        let step_id = format!("step-{}", current.invocation.run_id);
+        if text == "first answer" {
+            let content = maka_runtime::input::MessageInput::from("additional direction");
+            append(
+                &log,
+                &RuntimeEvent::new(
+                    current.invocation.clone(),
+                    Fact::MessageSteered {
+                        message: Box::new(DeliveredMessage {
+                            message_id: "steered-message".into(),
+                            submitted_content_digest: content.content_digest().unwrap(),
+                            content,
+                        }),
+                        skill_invocation: Default::default(),
+                    },
+                ),
+            )
+            .await;
+        }
+        let context = log
+            .read_model_context(
+                "session",
+                Some(&current.invocation.invocation_id),
+                100,
+                65536,
+            )
+            .await
+            .unwrap();
+        append(
+            &log,
+            &RuntimeEvent::new(
+                current.invocation.clone(),
+                Fact::ModelRequested {
+                    purpose: Some(maka_runtime::context::ModelPurpose::Main),
+                    context: None,
+                    checkpoint_event_id: None,
+                    step_id: step_id.clone(),
+                    model_id: "test".into(),
+                    source_scope: context.source_evidence.scope,
+                    source_high_water: context.source_evidence.high_water,
+                    source_digest: context.source_evidence.digest,
+                    effective_source_digest: Some(context.effective_source_digest),
+                    input_digest: digest('a'),
+                    route_identity: digest('b'),
+                },
+            ),
+        )
+        .await;
+        for event in [
+            ModelEvent::PartStarted {
+                id: "part".into(),
+                text_kind: TextKind::Text,
+                provider_options: None,
+            },
+            ModelEvent::PartDelta {
+                id: "part".into(),
+                text: text.into(),
+                provider_options: None,
+            },
+            ModelEvent::PartFinished {
+                id: "part".into(),
+                provider_options: None,
+            },
+        ] {
+            append(
+                &log,
+                &RuntimeEvent::new(
+                    current.invocation.clone(),
+                    Fact::ModelObserved {
+                        step_id: step_id.clone(),
+                        event,
+                    },
+                ),
+            )
+            .await;
+        }
+        append(
+            &log,
+            &RuntimeEvent::new(
+                current.invocation.clone(),
+                Fact::ModelCompleted {
+                    step_id,
+                    output: ModelStep {
+                        parts: vec![ModelPart::Text {
+                            text_kind: TextKind::Text,
+                            text: text.into(),
+                            provider_options: None,
+                        }],
+                        finish_reason: maka_runtime::model::ModelFinishReason::Stop,
+                        usage: Default::default(),
+                        provider_options: None,
+                        response_id: None,
+                        model: None,
+                        timestamp: None,
+                    },
+                },
+            ),
+        )
+        .await;
+    }
+    let terminal = append(
+        &log,
+        &RuntimeEvent::new(
+            current.invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::Cancelled {
+                    source: "user".into(),
+                },
+            },
+        ),
+    )
+    .await;
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    let session = log.get_session::<Value>("session").await.unwrap().unwrap();
+    assert_eq!(
+        session
+            .execution
+            .unwrap()
+            .last_message
+            .unwrap()
+            .preview
+            .as_deref(),
+        Some("second answer")
+    );
+    assert_eq!(
+        log.latest_continuation_candidate("session").await.unwrap(),
+        Some(current.invocation.run_id.clone())
+    );
+    assert_eq!(
+        log.message_resolutions("session", &["user-message".into()])
+            .await
+            .unwrap(),
+        vec![MessageResolution::Owned {
+            message_id: "user-message".into(),
+            invocation: root.invocation.clone()
+        },]
+    );
+    let MessageExecution::Owned(owner) = log
+        .message_execution("session", "user-message")
+        .await
+        .unwrap()
+    else {
+        panic!("exclusive owner lost");
+    };
+    assert_eq!(
+        owner.invocation, current.invocation,
+        "control must follow the physical owner"
+    );
+    let mut after = 0;
+    let mut texts = Vec::new();
+    loop {
+        let page = log
+            .session_stream_events("session", after, terminal, 2, 4096)
+            .await
+            .unwrap();
+        for event in page.events {
+            assert_eq!(event.root_run_id, root.invocation.run_id);
+            if let StreamFact::PartDelta { text, .. } = event.fact {
+                assert_ne!(event.invocation.run_id, root.invocation.run_id);
+                texts.push(text);
+            }
+        }
+        let Some(next) = page.next_after else {
+            break;
+        };
+        assert!(next > after);
+        after = next;
+    }
+    assert_eq!(texts, ["first answer", "second answer"]);
+    let rows = transcript(&log, "session").await;
+    for text in ["root", "first answer", "second answer"] {
+        assert_eq!(
+            rows.iter().filter(|row| row["text"] == text).count(),
+            1,
+            "{text} must appear once"
+        );
+    }
+    assert_eq!(
+        rows.iter()
+            .filter(|row| row["type"] == "turn_state")
+            .count(),
+        1,
+        "physical pauses do not end the logical Turn"
+    );
+    assert_eq!(
+        log.navigation_fence("session").await.unwrap(),
+        Some(maka_presentation::watermark(terminal).unwrap())
+    );
+    assert_eq!(
+        log.navigation_landmarks(
+            "session",
+            maka_presentation::watermark(terminal).unwrap(),
+            64
+        )
+        .await
+        .unwrap()
+        .len(),
+        1
+    );
+    log.close().await.unwrap();
+}
+
+#[tokio::test]
+async fn workhub_handoff_keeps_user_authority_and_recovers_correction_after_coordinator_exit() {
+    let temp = tempfile::tempdir().unwrap();
+    let path = temp.path().join("events.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    let workspace = json!({"hostCwd": "/work"});
+    for session in [COORDINATION_SESSION_ID, "old", "new"] {
+        log.create_session(
+            session,
+            "create",
+            &json!({"name": session, "workspace": workspace}),
+            1,
+        )
+        .await
+        .unwrap();
+    }
+    let mut root = opening("original-request", None);
+    root.invocation.session_id = COORDINATION_SESSION_ID.into();
+    append(&log, &root).await;
+    let coordinator = successor(&log, &root).await;
+    let delegation = Delegation {
+        kind: Default::default(),
+        description: Some(DelegationDescription::Existing { name: "old".into() }),
+        delivery: Default::default(),
+        action_id: "assignment".parse().unwrap(),
+        request_fingerprint: content_digest(b"assignment"),
+        source_message_event_id: root.id.clone(),
+        target: Invocation {
+            session_id: "old".into(),
+            turn_id: "target-turn".into(),
+            run_id: "target-run".into(),
+            invocation_id: "target-invocation".into(),
+        },
+        target_revision: 1,
+        delegation_text: "first task".into(),
+    };
+    let event = RuntimeEvent::new(
+        coordinator.invocation.clone(),
+        Fact::WorkhubDelegated {
+            delegation: Box::new(delegation.clone()),
+        },
+    );
+    append(&log, &event).await;
+    assert_eq!(log.pending_messages("old").await.unwrap().len(), 1);
+    let request = CorrectionRequest {
+        action_id: "correction".parse().unwrap(),
+        request_fingerprint: content_digest(b"correction"),
+        source: coordinator.invocation.clone(),
+        source_message_event_id: root.id.clone(),
+        replaces_action_id: delegation.action_id.clone(),
+        target: CorrectionTarget::Existing {
+            session_id: "new".into(),
+            name: "new".into(),
+            workspace_digest: content_digest(serde_json::to_string(&workspace).unwrap().as_bytes()),
+        },
+        delegation_text: "corrected task".into(),
+    };
+    let mut wrong = request.clone();
+    wrong.source_message_event_id = coordinator.id.clone();
+    assert!(
+        log.request_workhub_correction(wrong, Some(1), None)
+            .await
+            .is_err(),
+        "a handoff opening is not a new user decision"
+    );
+    let intent = log
+        .request_workhub_correction(request.clone(), Some(1), None)
+        .await
+        .unwrap();
+    assert!(intent.intent.owner.is_none());
+    assert!(log.pending_messages("old").await.unwrap().is_empty());
+    close(&log, &coordinator).await;
+    let rows = transcript(&log, COORDINATION_SESSION_ID).await;
+    assert!(
+        rows.iter().any(|row| row["id"] == event.id),
+        "assignment receipt must be projectable from a handoff Run"
+    );
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    let replacement = Delegation {
+        action_id: request.action_id.clone(),
+        request_fingerprint: request.request_fingerprint.clone(),
+        source_message_event_id: root.id,
+        delegation_text: request.delegation_text,
+        target: Invocation {
+            session_id: "new".into(),
+            turn_id: "new-turn".into(),
+            run_id: "new-run".into(),
+            invocation_id: "new-invocation".into(),
+        },
+        description: Some(DelegationDescription::Existing { name: "new".into() }),
+        ..delegation
+    };
+    let resolved = log
+        .finish_workhub_correction::<Value>(&request.action_id, replacement.clone(), None)
+        .await
+        .unwrap();
+    assert_eq!(
+        log.finish_workhub_correction::<Value>(&request.action_id, replacement, None)
+            .await
+            .unwrap(),
+        resolved
+    );
+    let pending = log.pending_messages("new").await.unwrap();
+    assert_eq!(pending.len(), 1);
+    assert!(
+        pending[0]
+            .source
+            .message
+            .content
+            .text
+            .contains("original-request")
+    );
+    assert!(
+        pending[0]
+            .source
+            .message
+            .content
+            .text
+            .contains("corrected task")
+    );
+    let after = transcript(&log, COORDINATION_SESSION_ID).await;
+    assert!(
+        after.len() > rows.len(),
+        "recovered correction publishes its immutable receipt"
+    );
+    let mut stop = opening("stop-request", None);
+    stop.invocation.session_id = COORDINATION_SESSION_ID.into();
+    append(&log, &stop).await;
+    let stop = successor(&log, &stop).await;
+    let stopped = log
+        .request_workhub_stop(maka_runtime::workhub::StopRequest {
+            action_id: "stop-replacement".parse().unwrap(),
+            request_fingerprint: content_digest(b"stop-replacement"),
+            source: stop.invocation.clone(),
+            target_session_id: "new".into(),
+        })
+        .await
+        .unwrap();
+    assert!(matches!(
+        stopped.resolution,
+        Some(maka_runtime::workhub::StopResolution::CancelledPending)
+    ));
+    assert!(log.pending_messages("new").await.unwrap().is_empty());
+    close(&log, &stop).await;
+    assert!(transcript(&log, COORDINATION_SESSION_ID).await.len() > after.len());
+    log.close().await.unwrap();
+}
+
+async fn transcript(log: &EventLog, session: &str) -> Vec<Value> {
+    let through = *log.subscribe_commits().borrow();
+    while !log.prepare_transcript(session, through, 32).await.unwrap() {}
+    let headers = log
+        .transcript_headers(
+            session,
+            &TranscriptRead {
+                through: maka_presentation::watermark(through).unwrap(),
+                position: 0,
+                direction: TranscriptDirection::Newer,
+                limit: 100,
+            },
+        )
+        .await
+        .unwrap();
+    let mut rows = Vec::new();
+    for row in headers {
+        rows.push(
+            serde_json::from_slice(
+                &log.transcript_fragment(session, row.sequence, 0, row.total_bytes)
+                    .await
+                    .unwrap(),
+            )
+            .unwrap(),
+        );
+    }
+    rows
+}
diff --git a/crates/presentation/src/workhub.rs b/crates/presentation/src/workhub.rs
index af1026f90d..963110e4b4 100644
--- a/crates/presentation/src/workhub.rs
+++ b/crates/presentation/src/workhub.rs
@@ -80,6 +80,19 @@ enum Disposition {
     CreateNew,
 }
 
+/// Project one assignment using its caller-authenticated original user message.
+/// A physical handoff opening does not itself contain that message.
+pub fn delegated(
+    stored: &maka_runtime::event::StoredEvent,
+    source: &maka_runtime::input::MessageInput,
+) -> Result<Option<Row>, ProjectionError> {
+    let maka_runtime::event::Fact::WorkhubDelegated { delegation } = &stored.event.fact else {
+        return Err(ProjectionError::Invalid("not a WorkHub delegation"));
+    };
+    let sequence = watermark(stored.sequence)? - 255;
+    Ok(assigned(&stored.event, delegation, source)?.map(|message| Row { sequence, message }))
+}
+
 pub(crate) fn assigned(
     event: &maka_runtime::event::RuntimeEvent,
     delegation: &maka_runtime::workhub::Delegation,
diff --git a/crates/runtime-host/src/execution.rs b/crates/runtime-host/src/execution.rs
index ba2c467292..45b5407009 100644
--- a/crates/runtime-host/src/execution.rs
+++ b/crates/runtime-host/src/execution.rs
@@ -195,7 +195,7 @@ impl Executions {
             .active
             .lock()
             .unwrap()
-            .get(&input.run_id)
+            .get(&invocation.run_id)
             .map(|run| run.cancellation.clone());
         if let Some(cancellation) = cancellation {
             self.interactions.stop_run(&invocation).await?;
diff --git a/crates/runtime-host/src/execution/resume.rs b/crates/runtime-host/src/execution/resume.rs
index 277ac8214f..576729858d 100644
--- a/crates/runtime-host/src/execution/resume.rs
+++ b/crates/runtime-host/src/execution/resume.rs
@@ -81,7 +81,7 @@ impl Executions {
         match self.engine.check_continuation(&run, &self.shutdown).await {
             Ok(()) => Ok(TurnResumePlan::Ready {
                 session_id: input.session_id,
-                source_run_id: source.invocation.run_id,
+                source_run_id: input.source_run_id.unwrap_or(source.invocation.run_id),
                 source_turn_id: source.invocation.turn_id,
                 source_runtime_event_high_water: source.high_water,
             }),
@@ -112,10 +112,27 @@ impl Executions {
                 .await
                 .map_err(internal)?
             {
-                if !matches!(&boundary.input, InvocationInput::Continuation { claim, .. }
-                if claim.source.invocation.run_id == input.source_run_id
-                    && claim.source.high_water == input.source_runtime_event_high_water)
-                {
+                let same_request = match boundary.root_input() {
+                    InvocationInput::Continuation { claim, .. }
+                        if claim.source.high_water == input.source_runtime_event_high_water =>
+                    {
+                        if claim.source.invocation.run_id == input.source_run_id {
+                            true
+                        } else {
+                            // Resolve the recorded source, never today's Session tip.
+                            self.log
+                                .run_boundary(&input.session_id, &claim.source.invocation.run_id)
+                                .await
+                                .map_err(internal)?
+                                .is_some_and(|source| {
+                                    source.invocation == claim.source.invocation
+                                        && source.root_invocation().run_id == input.source_run_id
+                                })
+                        }
+                    }
+                    _ => false,
+                };
+                if !same_request {
                     return Err(failure(
                         Code::OperationConflict,
                         "Turn identity belongs to another request",
@@ -263,7 +280,32 @@ impl Executions {
     async fn resume_source(&self, input: &TurnResumeQueryInput) -> Result<Selection> {
         use TurnResumeParkReason as Reason;
         let run_id = match &input.source_run_id {
-            Some(id) => id.clone(),
+            Some(id) => {
+                let Some(source) = self
+                    .log
+                    .run_boundary(&input.session_id, id)
+                    .await
+                    .map_err(internal)?
+                else {
+                    return Ok(Selection::Parked(Reason::ResumeCandidateMissing));
+                };
+                if source.root_invocation() == &source.invocation {
+                    // The public root denotes the whole logical Turn. Claims still
+                    // bind the exact physical terminal, including its high-water.
+                    let tip = self
+                        .log
+                        .turn_boundary(&input.session_id, &source.invocation.turn_id)
+                        .await
+                        .map_err(internal)?
+                        .ok_or_else(|| failure(Code::InternalFailure, "Resume Turn disappeared"))?;
+                    if tip.root_invocation() != &source.invocation {
+                        return Ok(Selection::Parked(Reason::SafetyCheckFailed));
+                    }
+                    tip.invocation.run_id
+                } else {
+                    id.clone()
+                }
+            }
             None => match self
                 .log
                 .latest_continuation_candidate(&input.session_id)
@@ -316,7 +358,9 @@ impl Executions {
         if !matches!(
             prefix.events.first().map(|e| &e.event.fact),
             Some(Fact::InvocationOpened {
-                input: InvocationInput::Message { .. } | InvocationInput::Continuation { .. },
+                input: InvocationInput::Message { .. }
+                    | InvocationInput::Continuation { .. }
+                    | InvocationInput::Handoff { .. },
                 ..
             })
         ) {
diff --git a/crates/runtime-host/src/execution/snapshot.rs b/crates/runtime-host/src/execution/snapshot.rs
index 517db00fc3..4cd57ff022 100644
--- a/crates/runtime-host/src/execution/snapshot.rs
+++ b/crates/runtime-host/src/execution/snapshot.rs
@@ -29,12 +29,25 @@ pub(crate) struct RecordedTurn {
 }
 
 pub(crate) fn project(boundary: TurnBoundary) -> RecordedTurn {
-    let invocation = boundary.invocation;
+    let invocation = boundary.root_invocation().clone();
     let live = LiveTurn {
-        root_execution_kind: matches!(boundary.input, InvocationInput::ContextCompact { .. })
-            .then_some(RootExecutionKind::ContextCompact),
+        root_execution_kind: matches!(
+            boundary.root_input(),
+            InvocationInput::ContextCompact { .. }
+        )
+        .then_some(RootExecutionKind::ContextCompact),
         ..LiveTurn::default()
     };
+    let skill_invocation = match boundary.root_input() {
+        InvocationInput::Message {
+            skill_invocation, ..
+        } => skill_invocation.as_deref().cloned().unwrap_or_default(),
+        _ => Default::default(),
+    };
+    let fingerprint = boundary
+        .root_input()
+        .request_fingerprint()
+        .map(str::to_owned);
     let state = match boundary.state {
         InvocationState::Admitted => TurnState::Admitted(live),
         InvocationState::Running => TurnState::Running(live),
@@ -63,12 +76,6 @@ pub(crate) fn project(boundary: TurnBoundary) -> RecordedTurn {
             },
         },
     };
-    let skill_invocation = match &boundary.input {
-        InvocationInput::Message {
-            skill_invocation, ..
-        } => skill_invocation.as_deref().cloned().unwrap_or_default(),
-        _ => Default::default(),
-    };
     RecordedTurn {
         skill_invocation,
         snapshot: TurnSnapshot {
@@ -77,20 +84,7 @@ pub(crate) fn project(boundary: TurnBoundary) -> RecordedTurn {
             run_id: invocation.run_id,
             state,
         },
-        fingerprint: match boundary.input {
-            InvocationInput::Message {
-                request_fingerprint,
-                ..
-            } => request_fingerprint,
-            InvocationInput::ContextCompact {
-                request_fingerprint,
-            }
-            | InvocationInput::Continuation {
-                request_fingerprint,
-                ..
-            } => Some(request_fingerprint),
-            InvocationInput::Code { .. } | InvocationInput::Handoff { .. } => None,
-        },
+        fingerprint,
     }
 }
 
diff --git a/crates/runtime-host/src/server/subscriptions/delivery.rs b/crates/runtime-host/src/server/subscriptions/delivery.rs
index 8dfc9aeae9..cdfe87c274 100644
--- a/crates/runtime-host/src/server/subscriptions/delivery.rs
+++ b/crates/runtime-host/src/server/subscriptions/delivery.rs
@@ -84,7 +84,10 @@ impl Delivery {
         prepared: Option<PreparedTranscript>,
     ) -> Result<(Self, SubscriptionOpenResult), HostError> {
         let streams = Streams::bootstrap(
-            observation.root_turn.as_ref().map(|turn| &turn.invocation),
+            observation
+                .root_turn
+                .as_ref()
+                .map(|turn| turn.root_invocation()),
             observation.active_streams,
         );
         let cursor = observation.through_sequence;
diff --git a/crates/runtime-host/src/server/subscriptions/delivery/events.rs b/crates/runtime-host/src/server/subscriptions/delivery/events.rs
index 9985d14fd6..ab42d1efe3 100644
--- a/crates/runtime-host/src/server/subscriptions/delivery/events.rs
+++ b/crates/runtime-host/src/server/subscriptions/delivery/events.rs
@@ -87,7 +87,7 @@ impl Delivery {
             }
             let pending = &self.pending.as_ref().expect("pending fence").snapshot;
             if pending.root_turn.as_ref().is_some_and(|root| {
-                root.run_id == stored.invocation.run_id
+                root.run_id == stored.root_run_id
                     && self
                         .snapshot
                         .root_turn
@@ -125,7 +125,7 @@ impl Delivery {
                     subscription_id: self.id.clone(),
                     sequence: self.sequence,
                     session_id: self.session_id.clone(),
-                    run_id: stored.invocation.run_id.clone(),
+                    run_id: stored.root_run_id.clone(),
                     event,
                 };
                 let value = serde_json::to_value(frame)?;
@@ -213,6 +213,7 @@ mod tests {
 
     fn event(run: &str, sequence: u64, fact: StreamFact) -> StoreStreamEvent {
         StoreStreamEvent {
+            root_run_id: run.into(),
             sequence,
             id: format!("event-{sequence}"),
             invocation: Invocation {
diff --git a/crates/runtime-host/src/server/subscriptions/streams.rs b/crates/runtime-host/src/server/subscriptions/streams.rs
index 47c30289a9..42426bead8 100644
--- a/crates/runtime-host/src/server/subscriptions/streams.rs
+++ b/crates/runtime-host/src/server/subscriptions/streams.rs
@@ -29,7 +29,8 @@ use std::collections::BTreeMap;
 pub(super) const MAX_DELTA_BYTES: usize = 8 * 1024;
 
 struct ActiveStream {
-    invocation: Invocation,
+    turn_id: String,
+    run_id: String,
     message_id: String,
     kind: AssistantStreamKind,
     offset: u64,
@@ -47,7 +48,8 @@ impl Streams {
                 streams.0.insert(
                     (seed.step_id, seed.part_id),
                     ActiveStream {
-                        invocation: invocation.clone(),
+                        turn_id: invocation.turn_id.clone(),
+                        run_id: invocation.run_id.clone(),
                         message_id: seed.message_id,
                         kind: kind(seed.text_kind),
                         offset: seed.offset,
@@ -63,7 +65,7 @@ impl Streams {
             .values()
             .map(|stream| SessionAssistantStreamIdentity {
                 kind: stream.kind,
-                turn_id: stream.invocation.turn_id.clone(),
+                turn_id: stream.turn_id.clone(),
                 message_id: stream.message_id.clone(),
             })
             .collect()
@@ -86,7 +88,8 @@ impl Streams {
                 let previous = self.0.insert(
                     (step_id.clone(), id.clone()),
                     ActiveStream {
-                        invocation: event.invocation.clone(),
+                        turn_id: event.invocation.turn_id.clone(),
+                        run_id: event.root_run_id.clone(),
                         message_id: event.id.clone(),
                         kind: kind(*text_kind),
                         offset: 0,
@@ -178,8 +181,8 @@ impl ActiveStream {
         self.offset += text.encode_utf16().count() as u64;
         SessionAssistantDelta {
             kind: self.kind,
-            turn_id: self.invocation.turn_id.clone(),
-            run_id: self.invocation.run_id.clone(),
+            turn_id: self.turn_id.clone(),
+            run_id: self.run_id.clone(),
             message_id: self.message_id.clone(),
             start_offset,
             text,
@@ -209,6 +212,11 @@ mod tests {
             run_id: "run".into(),
             invocation_id: "invocation".into(),
         };
+        let root = Invocation {
+            run_id: "root".into(),
+            invocation_id: "original-invocation".into(),
+            ..invocation.clone()
+        };
         for failed in [false, true] {
             let seeds = [
                 ("text", TextKind::Text),
@@ -224,14 +232,37 @@ mod tests {
                 offset: 5,
             })
             .collect();
-            let mut streams = Streams::bootstrap(Some(&invocation), seeds);
             let event = |fact| StoreStreamEvent {
+                root_run_id: root.run_id.clone(),
                 sequence: 10,
                 id: "boundary".into(),
                 invocation: invocation.clone(),
                 recorded_at: std::time::SystemTime::UNIX_EPOCH,
                 fact,
             };
+            let mut streams = if failed {
+                Streams::bootstrap(Some(&root), seeds)
+            } else {
+                let mut streams = Streams::default();
+                for seed in seeds {
+                    let mut start = event(StreamFact::PartStarted {
+                        step_id: seed.step_id.clone(),
+                        part_id: seed.part_id.clone(),
+                        text_kind: seed.text_kind,
+                    });
+                    start.id = seed.message_id;
+                    streams.observe(&start).unwrap();
+                    let deltas = streams
+                        .observe(&event(StreamFact::PartDelta {
+                            step_id: seed.step_id,
+                            part_id: seed.part_id,
+                            text: "12345".into(),
+                        }))
+                        .unwrap();
+                    assert_eq!(deltas[0].run_id, root.run_id);
+                }
+                streams
+            };
             assert!(
                 streams
                     .observe(&event(StreamFact::PartFinished {
@@ -255,6 +286,10 @@ mod tests {
                 .unwrap();
             assert_eq!(deltas.len(), if failed { 4 } else { 3 });
             for delta in &deltas {
+                assert_eq!(
+                    delta.run_id, root.run_id,
+                    "fresh and reconnected streams keep the logical identity"
+                );
                 assert!(delta.complete.is_some());
                 assert!(delta.text.is_empty());
                 assert_eq!(
diff --git a/crates/runtime-host/src/server/workhub/action.rs b/crates/runtime-host/src/server/workhub/action.rs
index 740f0a2e92..b68b781927 100644
--- a/crates/runtime-host/src/server/workhub/action.rs
+++ b/crates/runtime-host/src/server/workhub/action.rs
@@ -153,7 +153,7 @@ async fn admit(
             "The selecting Run is no longer active",
         ));
     }
-    let InvocationInput::Message { content, .. } = &source.input else {
+    let InvocationInput::Message { content, .. } = source.root_input() else {
         return Err(failure(
             Code::OperationConflict,
             "WorkHub action requires a user message",
@@ -185,7 +185,7 @@ async fn admit(
         delivery,
         action_id: input.action_id,
         request_fingerprint: fingerprint,
-        source_message_event_id: source.opening_event_id,
+        source_message_event_id: source.root_opening_event_id().to_owned(),
         target: owner.unwrap_or_else(|| Invocation {
             session_id: target.id().to_owned(),
             turn_id: Uuid::new_v4().to_string(),
diff --git a/crates/runtime-host/src/server/workhub/action/correction.rs b/crates/runtime-host/src/server/workhub/action/correction.rs
index 67910c75d6..36b3271de1 100644
--- a/crates/runtime-host/src/server/workhub/action/correction.rs
+++ b/crates/runtime-host/src/server/workhub/action/correction.rs
@@ -75,7 +75,7 @@ pub(super) async fn act(host: &Arc<Host>, input: ActInput) -> Result<ActResult,
                 return Err(failure(Code::HostDraining, "Host is draining"));
             }
             let source = host.executions.workhub_source(&input.turn_id).await?;
-            let InvocationInput::Message { content, .. } = &source.input else {
+            let InvocationInput::Message { content, .. } = source.root_input() else {
                 return Err(failure(
                     Code::OperationConflict,
                     "WorkHub correction requires a user message",
@@ -112,8 +112,8 @@ pub(super) async fn act(host: &Arc<Host>, input: ActInput) -> Result<ActResult,
             let request = CorrectionRequest {
                 action_id: input.action_id.clone(),
                 request_fingerprint: fingerprint(&input, &target)?,
-                source: source.invocation,
-                source_message_event_id: source.opening_event_id,
+                source: source.invocation.clone(),
+                source_message_event_id: source.root_opening_event_id().to_owned(),
                 replaces_action_id: replaces_action_id.clone(),
                 target,
                 delegation_text: input
diff --git a/crates/runtime/src/input.rs b/crates/runtime/src/input.rs
index 03681e0a33..f29c38f7d8 100644
--- a/crates/runtime/src/input.rs
+++ b/crates/runtime/src/input.rs
@@ -53,6 +53,23 @@ pub enum InvocationInput {
 }
 
 impl InvocationInput {
+    pub fn request_fingerprint(&self) -> Option<&str> {
+        match self {
+            Self::Message {
+                request_fingerprint,
+                ..
+            } => request_fingerprint.as_deref(),
+            Self::Continuation {
+                request_fingerprint,
+                ..
+            }
+            | Self::ContextCompact {
+                request_fingerprint,
+            } => Some(request_fingerprint),
+            Self::Handoff { .. } | Self::Code { .. } => None,
+        }
+    }
+
     pub fn inherited_claim(&self) -> Option<&crate::continuation::ContinuationClaim> {
         match self {
             Self::Continuation { claim, .. } | Self::Handoff { claim, .. } => Some(claim),
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 1434c775cb..120d44f161 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -68,7 +68,8 @@ Do not run untrusted code or point test instances at existing user data.
   Routing and discovery share typed provider contracts. Tool JSON, schemas and
   provider extensions remain open-ended.
 - A State Root has one writer and execution authority. Session, Turn, Run and
-  invocation identities remain distinct.
+  invocation identities remain distinct. Cooperative continuation preserves the
+  public Run identity; claims and cleanup still address exact physical Runs.
 - Host diagnostics and retirement share active-work accounting, including pending
   OAuth authorization. Retirement targets an exact Host epoch and retains authority
   through response flush and resource cleanup. Cooperative step handoff is not yet
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 6cef9a741f..050ac03d02 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -61,7 +61,7 @@ Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 每个 State Root 只有一个写入与执行 authority;Session、Turn、Run、invocation
-  身份保持独立。
+  身份保持独立。协作续接保留对外 Run 身份;恢复凭证与清理仍绑定精确物理 Run。
 - Host 诊断与退出共用活动工作统计,包含等待授权的 OAuth 登录。退出绑定精确 Host epoch,
   回复发送与资源清理完成前不释放 authority。尚未公布协作步骤交接能力;旧协议字段
   `nodeVersion` 如实返回 `not applicable (Rust)`。


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 16/29: feat(host): reconcile code updates under durable deployment authority

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 9714831831262443bf115ce33198b94d522667f4
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 17:44:41 2026 +0800

    feat(host): reconcile code updates under durable deployment authority
    
    Generated-by: OpenAI Codex
---
 crates/cli/migrations/deployment/0002_update.sql |  24 +++
 crates/cli/src/args.rs                           |   6 +
 crates/cli/src/deployment.rs                     |  22 ++
 crates/cli/src/deployment/activation.rs          |  25 +--
 crates/cli/src/deployment/store.rs               |  36 ++--
 crates/cli/src/deployment/update.rs              | 171 +++++++++++++++
 crates/cli/src/deployment/updates.rs             | 252 +++++++++++++++++++++++
 crates/cli/src/host_client.rs                    |   2 +
 docs/rust-runtime.md                             |   7 +
 docs/rust-runtime.zh-CN.md                       |   5 +
 10 files changed, 515 insertions(+), 35 deletions(-)

diff --git a/crates/cli/migrations/deployment/0002_update.sql b/crates/cli/migrations/deployment/0002_update.sql
new file mode 100644
index 0000000000..c23b42555f
--- /dev/null
+++ b/crates/cli/migrations/deployment/0002_update.sql
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+-- The Active reader remains version 1. Pending intent does not authorize startup.
+CREATE TABLE deployment_update (
+    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+    target TEXT NOT NULL CHECK (json_valid(target) AND length(CAST(target AS BLOB)) <= 65536)
+) STRICT;
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 34741f9705..180b38a8bc 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -53,6 +53,10 @@ enum HostCommand {
     Install(crate::deployment::Install),
     /// Activate an installed Host and report its verified loopback endpoint.
     Activate(crate::deployment::Activate),
+    /// Update an on-demand deployment to this executable at a safe boundary.
+    Update(crate::deployment::Update),
+    /// Finish an interrupted deployment update without choosing another target.
+    Reconcile(crate::deployment::Update),
     /// Query the existing Host without opening its State Root for writing.
     Status(Root),
     /// Retire the exact current Host, preserving work at safe step boundaries.
@@ -102,6 +106,8 @@ impl Cli {
             Command::Host(HostCommand::Candidate(args)) => args.run().await,
             Command::Host(HostCommand::Install(args)) => args.run().await,
             Command::Host(HostCommand::Activate(args)) => args.run().await,
+            Command::Host(HostCommand::Update(args)) => args.run(false).await,
+            Command::Host(HostCommand::Reconcile(args)) => args.run(true).await,
             Command::Host(HostCommand::Init(args)) => {
                 let root_id = initialize(&args.root, &RootNamespaces::for_current_account()?)?;
                 println!("{}", serde_json::json!({"rootId": root_id}));
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index 53671b2a82..ed4aae3f44 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -20,7 +20,10 @@
 mod activation;
 mod package;
 mod store;
+mod update;
+mod updates;
 pub(super) use activation::Activate;
+pub(super) use update::Update;
 
 use clap::{Args, ValueEnum};
 use maka_event_log::root::{self, FileLease, RootLocation, RootNamespaces, RootOwner};
@@ -29,10 +32,29 @@ use serde::{Deserialize, Serialize};
 use std::{
     net::SocketAddr,
     path::{Path, PathBuf},
+    str::FromStr,
     sync::Arc,
 };
 use uuid::Uuid;
 
+#[derive(Clone)]
+struct RootId(String);
+
+impl FromStr for RootId {
+    type Err = &'static str;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        if value.len() == 64
+            && value
+                .bytes()
+                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+        {
+            Ok(Self(value.into()))
+        } else {
+            Err("root ID must contain 64 lowercase hexadecimal characters")
+        }
+    }
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub(super) enum Mode {
diff --git a/crates/cli/src/deployment/activation.rs b/crates/cli/src/deployment/activation.rs
index 7feba81057..5cc2b289c9 100644
--- a/crates/cli/src/deployment/activation.rs
+++ b/crates/cli/src/deployment/activation.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{Deployment, Mode, directory, store};
+use super::{Deployment, Mode, RootId, directory, store};
 use crate::host_client::{HostClient, LiveHost};
 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
 use clap::Args;
@@ -28,7 +28,6 @@ use std::{
     io::Write,
     num::{NonZeroU16, NonZeroU32},
     process::Stdio,
-    str::FromStr,
     time::Duration,
 };
 use tokio::{
@@ -37,24 +36,6 @@ use tokio::{
 };
 use uuid::Uuid;
 
-#[derive(Clone)]
-struct RootId(String);
-
-impl FromStr for RootId {
-    type Err = &'static str;
-    fn from_str(value: &str) -> Result<Self, Self::Err> {
-        if value.len() == 64
-            && value
-                .bytes()
-                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
-        {
-            Ok(Self(value.into()))
-        } else {
-            Err("root ID must contain 64 lowercase hexadecimal characters")
-        }
-    }
-}
-
 #[derive(Args)]
 pub(crate) struct Activate {
     #[arg(long)]
@@ -163,7 +144,9 @@ impl Activate {
     }
 }
 
-async fn connect_or_launch(deployment: &Deployment) -> Result<(HostClient, LiveHost), HostError> {
+pub(super) async fn connect_or_launch(
+    deployment: &Deployment,
+) -> Result<(HostClient, LiveHost), HostError> {
     let mut child: Option<Child> = None;
     let result = tokio::time::timeout(Duration::from_secs(30), async {
         loop {
diff --git a/crates/cli/src/deployment/store.rs b/crates/cli/src/deployment/store.rs
index a685ebacb6..426eca7cc9 100644
--- a/crates/cli/src/deployment/store.rs
+++ b/crates/cli/src/deployment/store.rs
@@ -30,6 +30,7 @@ use std::{fs::File, path::Path, sync::Arc};
 const DATABASE: &str = "deployment.sqlite";
 const APPLICATION_ID: i64 = 0x4d414b44;
 static MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/deployment");
+// user_version describes the stable Active reader, not the SQLx migration count.
 
 pub(super) enum Installation {
     Missing,
@@ -83,19 +84,7 @@ pub(super) async fn install(
     owner: RootOwner,
     deployment: Deployment,
 ) -> Result<Deployment, HostError> {
-    let path = directory.join(DATABASE);
-    let file = private_file(&path)?;
-    file.sync_all()?;
-    #[cfg(unix)]
-    File::open(directory)?.sync_all()?;
-    let connection = OwnedConnection::open(
-        SqliteConnectOptions::new()
-            .filename(&path)
-            .create_if_missing(false),
-        ConnectionAuthority::Writer { lease, root: None },
-        |connection| Box::pin(initialize(connection)),
-    )
-    .await?;
+    let connection = open_writer(directory, lease, None).await?;
     let result = connection
         .run(move |connection| {
             Box::pin(async move {
@@ -115,10 +104,29 @@ pub(super) async fn install(
     let closed = connection.close().await;
     let deployment = result?;
     closed?;
-    drop(file);
     Ok(deployment)
 }
 
+pub(super) async fn open_writer(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    root: Option<Arc<RootOwner>>,
+) -> Result<OwnedConnection, HostError> {
+    let path = directory.join(DATABASE);
+    let file = private_file(&path)?;
+    file.sync_all()?;
+    #[cfg(unix)]
+    File::open(directory)?.sync_all()?;
+    Ok(OwnedConnection::open(
+        SqliteConnectOptions::new()
+            .filename(&path)
+            .create_if_missing(false),
+        ConnectionAuthority::Writer { lease, root },
+        |connection| Box::pin(initialize(connection)),
+    )
+    .await?)
+}
+
 fn private_file(path: &Path) -> Result<File, HostError> {
     #[cfg(unix)]
     let file = {
diff --git a/crates/cli/src/deployment/update.rs b/crates/cli/src/deployment/update.rs
new file mode 100644
index 0000000000..14c1a6f479
--- /dev/null
+++ b/crates/cli/src/deployment/update.rs
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, Mode, RootId, activation, directory, package, store, updates};
+use crate::host_client::{HostClient, LiveHost};
+use clap::Args;
+use maka_event_log::root::{self, FileLease, RootNamespaces, RootOwner};
+use maka_protocol::host::RetirementResult;
+use maka_runtime_host::server::HostError;
+use serde::Serialize;
+use std::{sync::Arc, time::Duration};
+use uuid::Uuid;
+
+#[derive(Args)]
+pub(crate) struct Update {
+    #[arg(long)]
+    root_id: RootId,
+    #[arg(long)]
+    expected_deployment_id: Uuid,
+    #[arg(long, value_parser = clap::value_parser!(u64).range(1..=9_007_199_254_740_991))]
+    expected_revision: u64,
+}
+
+#[derive(Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum Outcome {
+    Ready {
+        deployment: Deployment,
+        host: LiveHost,
+    },
+    ActiveTasks {
+        deployment: Deployment,
+        target: Deployment,
+    },
+}
+
+impl Update {
+    pub async fn run(self, reconcile: bool) -> Result<(), HostError> {
+        let directory = directory(&self.root_id.0)?;
+        if !directory.is_dir() {
+            return Err("Host deployment is not installed".into());
+        }
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+        // Do not create a deployment database through update/reconcile.
+        let store::Installation::Installed(current) = store::read(&directory).await? else {
+            return Err("Host deployment is absent or incomplete".into());
+        };
+        let root = root::resolve(&current.root_path)?;
+        current.validate(&root, &directory)?;
+        if current.root_id != self.root_id.0 || current.deployment_id != self.expected_deployment_id
+        {
+            return Err("deployment identity or revision changed".into());
+        }
+        if current.mode != Mode::OnDemand {
+            return Err("supervised updates require service coordination".into());
+        }
+        let (observed, pending) = updates::read(&directory, lease.clone()).await?;
+        if observed != current
+            || !(current.config_revision == self.expected_revision
+                || (current.config_revision == self.expected_revision + 1 && pending.is_none()))
+        {
+            return Err("deployment revision changed".into());
+        }
+        let target = if reconcile {
+            pending
+        } else {
+            let source = std::env::current_exe()?.canonicalize()?;
+            let (executable, sha256) = if source == current.executable {
+                (source, current.sha256.clone())
+            } else {
+                let stage_directory = directory.clone();
+                let stage_lease = lease.clone();
+                tokio::task::spawn_blocking(move || {
+                    stage_lease.validate()?;
+                    let package = package::stage(&stage_directory, &source)?;
+                    stage_lease.validate()?;
+                    Ok::<_, HostError>(package)
+                })
+                .await??
+            };
+            if sha256 == current.sha256 {
+                if pending.is_some() {
+                    return Err("another update is pending; reconcile it first".into());
+                }
+                None // Includes a lost acknowledgement after the same target committed.
+            } else {
+                if current.config_revision != self.expected_revision {
+                    return Err("deployment changed before package staging".into());
+                }
+                let mut target = current.clone();
+                target.config_revision += 1;
+                target.executable = executable;
+                target.sha256 = sha256;
+                target.validate(&root, &directory)?;
+                updates::prepare(&directory, lease.clone(), current.clone(), target.clone())
+                    .await?;
+                Some(target)
+            }
+        };
+        let deployment = if let Some(target) = target {
+            target.validate(&root, &directory)?;
+            let Some(owner) = retire(&current).await? else {
+                lease.validate()?;
+                println!(
+                    "{}",
+                    serde_json::to_string(&Outcome::ActiveTasks {
+                        deployment: current,
+                        target
+                    })?
+                );
+                return Ok(());
+            };
+            updates::commit(&directory, lease.clone(), owner, current, target.clone()).await?;
+            target
+        } else {
+            current
+        };
+        // The executor is already held. Calling the public activate command here
+        // would acquire it twice. A Ready failure never restores older code.
+        let (client, host) = activation::connect_or_launch(&deployment).await?;
+        lease.validate()?;
+        println!(
+            "{}",
+            serde_json::to_string(&Outcome::Ready { deployment, host })?
+        );
+        drop(client);
+        Ok(())
+    }
+}
+
+/// A prepared receipt is not proof of release: only acquiring RootOwner is.
+async fn retire(deployment: &Deployment) -> Result<Option<RootOwner>, HostError> {
+    tokio::time::timeout(Duration::from_secs(30), async {
+        loop {
+            match RootOwner::open(
+                &deployment.root_path,
+                &RootNamespaces::for_current_account()?,
+            ) {
+                Ok(owner) => return Ok(Some(owner)),
+                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
+                Err(error) => return Err(error.into()),
+            }
+            if let Ok(mut client) =
+                HostClient::connect(&deployment.root_path, Some(&deployment.generation())).await
+            {
+                match client.retire(None, false).await? {
+                    RetirementResult::ActiveTasks => return Ok(None),
+                    RetirementResult::Prepared { .. } => {}
+                }
+            }
+            tokio::time::sleep(Duration::from_millis(25)).await;
+        }
+    })
+    .await?
+}
diff --git a/crates/cli/src/deployment/updates.rs b/crates/cli/src/deployment/updates.rs
new file mode 100644
index 0000000000..27f194c826
--- /dev/null
+++ b/crates/cli/src/deployment/updates.rs
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, store};
+use maka_event_log::{
+    StoreError,
+    root::{FileLease, RootOwner},
+};
+use maka_runtime_host::server::HostError;
+use sqlx::{Connection, SqliteConnection};
+use std::{path::Path, sync::Arc};
+
+/// Pending intent never authorizes startup. The unchanged Active row does.
+pub(super) async fn read(
+    directory: &Path,
+    lease: Arc<FileLease>,
+) -> Result<(Deployment, Option<Deployment>), HostError> {
+    let connection = store::open_writer(directory, lease, None).await?;
+    let result = connection
+        .run(|connection| Box::pin(load(connection)))
+        .await;
+    let closed = connection.close().await;
+    let state = result?;
+    closed?;
+    Ok(state)
+}
+
+pub(super) async fn prepare(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    expected: Deployment,
+    target: Deployment,
+) -> Result<(), HostError> {
+    validate_target(&expected, &target)?;
+    let connection = store::open_writer(directory, lease, None).await?;
+    let result = connection.run(move |connection| Box::pin(async move {
+        let mut transaction = connection.begin().await?;
+        let (active, pending) = load(&mut transaction).await?;
+        if active != expected || pending.as_ref().is_some_and(|pending| pending != &target) {
+            return Err(conflict());
+        }
+        sqlx::query("INSERT INTO deployment_update(singleton, target) VALUES (1, ?) ON CONFLICT(singleton) DO NOTHING")
+            .bind(serde_json::to_string(&target)?)
+            .execute(&mut *transaction).await?;
+        transaction.commit().await?;
+        Ok(())
+    })).await;
+    let closed = connection.close().await;
+    result?;
+    closed?;
+    Ok(())
+}
+
+pub(super) async fn commit(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    owner: RootOwner,
+    expected: Deployment,
+    target: Deployment,
+) -> Result<(), HostError> {
+    validate_target(&expected, &target)?;
+    if owner.root_id() != expected.root_id || owner.canonical_path() != expected.root_path {
+        return Err("update does not own the expected State Root".into());
+    }
+    // Keep both authorities in the worker through actual database close, including
+    // a dropped caller, a failed COMMIT or a queued transaction rollback.
+    let connection = store::open_writer(directory, lease, Some(Arc::new(owner))).await?;
+    let result = connection
+        .run(move |connection| {
+            Box::pin(async move {
+                let mut transaction = connection.begin().await?;
+                let (active, pending) = load(&mut transaction).await?;
+                if active != expected || pending.as_ref() != Some(&target) {
+                    return Err(conflict());
+                }
+                sqlx::query("UPDATE deployment SET configuration = ? WHERE singleton = 1")
+                    .bind(serde_json::to_string(&target)?)
+                    .execute(&mut *transaction)
+                    .await?;
+                sqlx::query("DELETE FROM deployment_update WHERE singleton = 1")
+                    .execute(&mut *transaction)
+                    .await?;
+                transaction.commit().await?;
+                Ok(())
+            })
+        })
+        .await;
+    let closed = connection.close().await;
+    result?;
+    closed?;
+    Ok(())
+}
+
+fn validate_target(current: &Deployment, target: &Deployment) -> Result<(), StoreError> {
+    let mut expected = target.clone();
+    expected.executable = current.executable.clone();
+    expected.sha256 = current.sha256.clone();
+    expected.config_revision = current.config_revision;
+    if &expected != current
+        || current.config_revision >= 9_007_199_254_740_991
+        || target.config_revision != current.config_revision + 1
+        || target.sha256 == current.sha256
+    {
+        return Err(conflict());
+    }
+    Ok(())
+}
+
+async fn load(
+    connection: &mut SqliteConnection,
+) -> Result<(Deployment, Option<Deployment>), StoreError> {
+    let (active, pending): (String, Option<String>) = sqlx::query_as(
+        "SELECT CASE WHEN length(CAST(configuration AS BLOB)) <= 65536 THEN configuration END, \
+         (SELECT CASE WHEN length(CAST(target AS BLOB)) <= 65536 THEN target ELSE '' END FROM deployment_update WHERE singleton = 1) \
+         FROM deployment WHERE singleton = 1"
+    ).fetch_one(connection).await?;
+    let active = serde_json::from_str(&active)?;
+    let pending = pending
+        .map(|value| serde_json::from_str(&value))
+        .transpose()?;
+    if let Some(target) = &pending {
+        validate_target(&active, target)?;
+    }
+    Ok((active, pending))
+}
+
+fn conflict() -> StoreError {
+    StoreError::InvalidTransition("deployment or pending update changed".into())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::deployment::{Mode, package};
+    use maka_event_log::root::{self, RootNamespaces};
+
+    #[tokio::test]
+    async fn pending_intent_survives_reopen_and_cut_is_atomic_under_the_real_root_owner() {
+        let temporary = tempfile::tempdir().unwrap();
+        let namespaces = RootNamespaces {
+            ownership: temporary.path().join("ownership"),
+            control: temporary.path().join("control"),
+        };
+        let root_path = temporary.path().join("root");
+        let owner = RootOwner::create(&root_path, &namespaces).unwrap();
+        let directory = temporary.path().join("deployment");
+        root::private_directory(&directory).unwrap();
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock")).unwrap());
+        let current = Deployment {
+            deployment_id: uuid::Uuid::new_v4(),
+            config_revision: 1,
+            root_id: owner.root_id().into(),
+            root_path: owner.canonical_path().into(),
+            executable: package::path(&directory, &"a".repeat(64)),
+            sha256: "a".repeat(64),
+            mode: Mode::OnDemand,
+            websocket: "127.0.0.1:0".parse().unwrap(),
+        };
+        store::install(&directory, lease.clone(), owner, current.clone())
+            .await
+            .unwrap();
+        let mut target = current.clone();
+        target.config_revision += 1;
+        target.sha256 = "b".repeat(64);
+        target.executable = package::path(&directory, &target.sha256);
+        prepare(&directory, lease.clone(), current.clone(), target.clone())
+            .await
+            .unwrap();
+        // Old startup readers still see the sole authorized version while an
+        // updater is absent. A different target cannot overwrite its intent.
+        let store::Installation::Installed(active) = store::read(&directory).await.unwrap() else {
+            panic!("pending intent must not change the startup reader contract");
+        };
+        assert_eq!(active, current);
+        let mut other = target.clone();
+        other.sha256 = "c".repeat(64);
+        other.executable = package::path(&directory, &other.sha256);
+        assert!(
+            prepare(&directory, lease.clone(), current.clone(), other)
+                .await
+                .is_err()
+        );
+        assert_eq!(
+            read(&directory, lease.clone()).await.unwrap(),
+            (current.clone(), Some(target.clone()))
+        );
+
+        let writer = store::open_writer(&directory, lease.clone(), None)
+            .await
+            .unwrap();
+        writer.run(|connection| Box::pin(async move {
+            sqlx::raw_sql("CREATE TRIGGER reject_cut BEFORE DELETE ON deployment_update BEGIN SELECT RAISE(ABORT, 'cut failed'); END;")
+                .execute(connection).await?;
+            Ok(())
+        })).await.unwrap();
+        writer.close().await.unwrap();
+        let owner = RootOwner::open(&root_path, &namespaces).unwrap();
+        assert!(
+            commit(
+                &directory,
+                lease.clone(),
+                owner,
+                current.clone(),
+                target.clone()
+            )
+            .await
+            .is_err()
+        );
+        assert_eq!(
+            read(&directory, lease.clone()).await.unwrap(),
+            (current.clone(), Some(target.clone()))
+        );
+        // Failed cut closed the SQL connection before returning writer authority.
+        let owner = RootOwner::open(&root_path, &namespaces).unwrap();
+        let writer = store::open_writer(&directory, lease.clone(), None)
+            .await
+            .unwrap();
+        writer
+            .run(|connection| {
+                Box::pin(async move {
+                    sqlx::query("DROP TRIGGER reject_cut")
+                        .execute(connection)
+                        .await?;
+                    Ok(())
+                })
+            })
+            .await
+            .unwrap();
+        writer.close().await.unwrap();
+        commit(&directory, lease.clone(), owner, current, target.clone())
+            .await
+            .unwrap();
+        assert_eq!(read(&directory, lease).await.unwrap(), (target, None));
+        drop(RootOwner::open(&root_path, &namespaces).unwrap());
+    }
+}
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
index fedcb14fff..7a9b6221ad 100644
--- a/crates/cli/src/host_client.rs
+++ b/crates/cli/src/host_client.rs
@@ -53,7 +53,9 @@ struct Discovery {
     websocket_endpoints: Vec<String>,
 }
 
+#[derive(serde::Serialize)]
 pub(super) struct LiveHost {
+    #[serde(rename = "hostEpoch")]
     pub epoch: String,
     pub pid: NonZeroU32,
     pub port: NonZeroU16,
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index f06c2de177..85fa021407 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -61,6 +61,13 @@ service. Installation does not yet register OS services or automate updates.
 Deployment authority lives in account-level
 SQLite, outside the State Root; startup checks it before database migrations.
 
+For an on-demand code update, run the new binary with
+`host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`.
+Active clients or non-cooperative work defer the switch; `host reconcile` with the
+same identity arguments finishes the recorded update. A committed target is never
+automatically rolled back, even if startup fails. Supervised updates and unattended
+release selection are not yet connected.
+
 The single binary also provides `host candidate` for Desktop-owned startup,
 `code --log <file>` for a JavaScript cell read from stdin, and
 `inspect --log <file>` for committed execution facts.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index cebad0c8f0..e5d76cbe76 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -54,6 +54,11 @@ Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要
 复用就绪 Host,或启动固定的按需版本;supervised 模式要求服务已运行。安装尚不注册 OS 服务或自动更新。
 部署权威保存在 State Root 之外的账户级 SQLite 中,启动时先校验再执行数据库迁移。
 
+更新按需部署时,用新二进制运行
+`host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`。
+活跃客户端或不可交接任务会推迟切换;随后用相同身份参数运行 `host reconcile` 完成已记录的更新。
+目标一旦提交,即使启动失败也不自动回退。Supervised 更新和无人值守版本选择尚未接通。
+
 唯一二进制还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
 `code --log <file>`,以及查看已提交执行事实的 `inspect --log <file>`。
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 24/29: feat(host): bridge native connections and repair explicit WSL remounts

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit a41cb84e60c1a6e16f21ca2c3fa1f2229c9f62e4
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 23:29:39 2026 +0800

    feat(host): bridge native connections and repair explicit WSL remounts
    
    Generated-by: OpenAI Codex
---
 crates/cli/Cargo.toml                      |   2 +-
 crates/cli/src/args.rs                     |   3 +
 crates/cli/src/deployment.rs               |   2 +
 crates/cli/src/deployment/activation.rs    |  61 ++++++---
 crates/cli/src/deployment/connect.rs       | 192 +++++++++++++++++++++++++++++
 crates/cli/src/host_client.rs              |  50 +++++---
 crates/cli/src/main.rs                     |   1 +
 crates/cli/src/stdio.rs                    |  69 +++++++++++
 crates/cli/tests/integration/deployment.rs |  13 +-
 crates/event-log/src/root.rs               |  92 +++++++++++++-
 crates/event-log/tests/root_authority.rs   |  87 +++++++------
 crates/event-log/tests/root_lease.rs       |  65 ++++++++++
 docs/rust-runtime.md                       |   7 ++
 docs/rust-runtime.zh-CN.md                 |   7 +-
 tests/fixtures/client-bridge-entry.mjs     | 136 ++++++++++++++++++++
 tests/fixtures/client.mjs                  |   1 +
 16 files changed, 707 insertions(+), 81 deletions(-)

diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 1899c46650..0ca19955ba 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -50,7 +50,7 @@ serde_json.workspace = true
 sha2.workspace = true
 sqlx.workspace = true
 tokio = { workspace = true, features = ["process"] }
-tokio-util.workspace = true
+tokio-util = { workspace = true, features = ["io-util"] }
 uuid.workspace = true
 tempfile.workspace = true
 
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index c3a46e826d..7b74506679 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -53,6 +53,8 @@ enum HostCommand {
     Install(crate::deployment::Install),
     /// Activate an installed Host and report its verified loopback endpoint.
     Activate(crate::deployment::Activate),
+    /// Connect an installed Host to stdin/stdout using the client wire protocol.
+    Connect(crate::deployment::Connect),
     /// Update a deployment to this executable at a safe boundary.
     Update(crate::deployment::Update),
     /// Finish an interrupted deployment update without choosing another target.
@@ -117,6 +119,7 @@ impl Cli {
             Command::Host(HostCommand::Candidate(args)) => args.run().await,
             Command::Host(HostCommand::Install(args)) => args.run().await,
             Command::Host(HostCommand::Activate(args)) => args.run().await,
+            Command::Host(HostCommand::Connect(args)) => args.run().await,
             Command::Host(HostCommand::Update(args)) => args.run(false).await,
             Command::Host(HostCommand::Reconcile(args)) => args.run(true).await,
             Command::Host(HostCommand::Stop(args)) => {
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index 7e4a9146e2..cf118f5570 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -18,6 +18,7 @@
  */
 
 mod activation;
+mod connect;
 mod control;
 mod entry;
 mod logs;
@@ -28,6 +29,7 @@ mod store;
 mod update;
 mod updates;
 pub(super) use activation::Activate;
+pub(super) use connect::Connect;
 pub(super) use control::{Control, ControlAction};
 pub(super) use entry::ServiceRun;
 pub(super) use logs::Logs;
diff --git a/crates/cli/src/deployment/activation.rs b/crates/cli/src/deployment/activation.rs
index 9933162d32..244f5e0971 100644
--- a/crates/cli/src/deployment/activation.rs
+++ b/crates/cli/src/deployment/activation.rs
@@ -40,6 +40,9 @@ pub(crate) struct Activate {
     root_id: RootId,
     #[arg(long)]
     framed: bool,
+    /// Confirm the same Linux filesystem was remounted, preserving root identity.
+    #[arg(long)]
+    repair_root_after_remount: bool,
 }
 
 #[derive(Serialize)]
@@ -102,22 +105,7 @@ impl Activate {
     }
 
     async fn activate(&self) -> Result<(), HostError> {
-        let directory = directory(&self.root_id.0)?;
-        // Activation never creates a deployment database.
-        if !directory.is_dir() {
-            return Err("Host deployment is not installed".into());
-        }
-        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
-        let store::Installation::Installed(deployment) = store::read(&directory).await? else {
-            return Err("Host deployment is absent or incomplete".into());
-        };
-        let root = root::resolve(&deployment.root_path)?;
-        if root.root_id() != self.root_id.0 {
-            return Err("deployment root identity changed".into());
-        }
-        deployment.validate(&root, &directory)?;
-        deployment.require_active()?;
-        lease.validate()?;
+        let (deployment, lease) = prepare(&self.root_id, self.repair_root_after_remount).await?;
         let (client, live) = connect_or_launch(&deployment, lease.clone()).await?;
         lease.validate()?;
         emit(
@@ -143,6 +131,47 @@ impl Activate {
     }
 }
 
+pub(super) async fn prepare(
+    root_id: &RootId,
+    repair: bool,
+) -> Result<(Deployment, Arc<FileLease>), HostError> {
+    let directory = directory(&root_id.0)?;
+    // Activation never creates a deployment database.
+    if !directory.is_dir() {
+        return Err("Host deployment is not installed".into());
+    }
+    let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+    let store::Installation::Installed(deployment) = store::read(&directory).await? else {
+        return Err("Host deployment is absent or incomplete".into());
+    };
+    deployment.validate_record(&directory)?;
+    deployment.require_active()?;
+    if deployment.root_id != root_id.0 {
+        return Err("deployment root identity changed".into());
+    }
+    if repair {
+        let path = deployment.root_path.clone();
+        let expected = root_id.0.clone();
+        let lease = lease.clone();
+        tokio::task::spawn_blocking(move || {
+            lease.validate()?;
+            if path.canonicalize()? != path {
+                return Err(std::io::Error::other("deployment root path was rebound"));
+            }
+            root::repair_after_remount(&path, &expected, &RootNamespaces::for_current_account()?)?;
+            lease.validate()
+        })
+        .await??;
+    }
+    let root = root::resolve(&deployment.root_path)?;
+    if root.root_id() != root_id.0 {
+        return Err("deployment root identity changed".into());
+    }
+    deployment.validate(&root, &directory)?;
+    lease.validate()?;
+    Ok((deployment, lease))
+}
+
 pub(super) async fn connect_or_launch(
     deployment: &Deployment,
     lease: Arc<FileLease>,
diff --git a/crates/cli/src/deployment/connect.rs b/crates/cli/src/deployment/connect.rs
new file mode 100644
index 0000000000..1134abc951
--- /dev/null
+++ b/crates/cli/src/deployment/connect.rs
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{RootId, activation};
+use clap::Args;
+use maka_protocol::{
+    MAX_MESSAGE_BYTES,
+    handshake::{HostHandshake, decode_hello, decode_host_handshake},
+};
+use maka_runtime_host::server::HostError;
+use std::time::Duration;
+use tokio::io::{
+    AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader,
+};
+use tokio_util::sync::CancellationToken;
+
+#[derive(Args)]
+pub(crate) struct Connect {
+    #[arg(long)]
+    root_id: RootId,
+    #[arg(long = "framed", required = true)]
+    _framed: bool,
+    /// Confirm the same Linux filesystem was remounted, preserving root identity.
+    #[arg(long)]
+    repair_root_after_remount: bool,
+}
+
+impl Connect {
+    pub async fn run(self) -> Result<(), HostError> {
+        let crate::stdio::Stdio {
+            input,
+            mut output,
+            mut output_done,
+        } = crate::stdio::Stdio::open()?;
+        let cancel = CancellationToken::new();
+        let _signals = crate::signals::watch(cancel.clone())?;
+        let result = tokio::select! {
+            result = self.bridge(BufReader::new(input), &mut output) => result,
+            _ = cancel.cancelled() => Ok(()),
+            result = &mut output_done => {
+                result??;
+                return Err("Host bridge output closed".into());
+            }
+        };
+        // Duplex shutdown alone only proves that bytes were queued. Wait for
+        // the stdout worker's final flush, bounded even if the consumer stalls.
+        drop(output);
+        let flushed = tokio::time::timeout(Duration::from_secs(5), output_done)
+            .await
+            .map_err(HostError::from)
+            .and_then(|result| result.map_err(HostError::from))
+            .and_then(|result| result.map_err(HostError::from));
+        result.and(flushed)
+    }
+
+    async fn bridge(
+        &self,
+        mut input: impl AsyncBufRead + Unpin,
+        output: &mut (impl AsyncWrite + Unpin),
+    ) -> Result<(), HostError> {
+        // A silent client must not reserve the deployment executor or start a Host.
+        let hello = tokio::time::timeout(Duration::from_secs(5), frame(&mut input)).await??;
+        decode_hello(&maka_protocol::decode_message(&hello)?)?;
+        let (deployment, lease) =
+            activation::prepare(&self.root_id, self.repair_root_after_remount).await?;
+        let (probe, live) = activation::connect_or_launch(&deployment, lease.clone()).await?;
+        let mut stream = probe.open_bridge().await?;
+        let mut stream = BufReader::new(&mut stream);
+        let accepted = tokio::time::timeout(Duration::from_secs(5), async {
+            stream.write_all(&hello).await?;
+            stream.write_all(b"\n").await?;
+            stream.flush().await?;
+            let reply = frame(&mut stream).await?;
+            let handshake = decode_host_handshake(&maka_protocol::decode_message(&reply)?)?;
+            if let HostHandshake::Accepted {
+                root_id,
+                host_epoch,
+                ..
+            } = &handshake
+                && (root_id != &deployment.root_id || host_epoch != &live.epoch)
+            {
+                return Err::<_, HostError>(
+                    "Host changed between activation and bridge handshake".into(),
+                );
+            }
+            Ok((reply, matches!(handshake, HostHandshake::Accepted { .. })))
+        })
+        .await??;
+        lease.validate()?;
+        drop(probe);
+        drop(lease);
+        output.write_all(&accepted.0).await?;
+        output.write_all(b"\n").await?;
+        output.flush().await?;
+        if !accepted.1 {
+            return Ok(());
+        }
+        // Keep both BufReaders: either may already own pipelined bytes beyond hello.
+        relay(&mut input, output, stream).await?;
+        Ok(())
+    }
+}
+
+async fn relay(
+    input: &mut (impl AsyncRead + Unpin),
+    output: &mut (impl AsyncWrite + Unpin),
+    stream: impl AsyncRead + AsyncWrite + Unpin,
+) -> std::io::Result<()> {
+    let (mut read, mut write) = tokio::io::split(stream);
+    let downstream = tokio::io::copy(&mut read, output);
+    tokio::pin!(downstream);
+    tokio::select! {
+        result = tokio::io::copy(input, &mut write) => {
+            result?;
+            #[cfg(unix)]
+            {
+                // Preserve this same copy future: it may own a partially
+                // written response buffer while stdout applies backpressure.
+                write.shutdown().await?;
+                downstream.await?;
+            }
+            // Windows named pipes cannot half-close. EOF disconnects the
+            // client without promising queued writes or final responses;
+            // it never retires the independently owned Host.
+        },
+        result = &mut downstream => { result?; },
+    }
+    Ok(())
+}
+
+async fn frame(reader: &mut (impl AsyncBufRead + Unpin)) -> Result<Vec<u8>, HostError> {
+    let mut bytes = Vec::new();
+    reader
+        .take(MAX_MESSAGE_BYTES as u64 + 2)
+        .read_until(b'\n', &mut bytes)
+        .await?;
+    if bytes.last() != Some(&b'\n') || bytes.len() > MAX_MESSAGE_BYTES + 1 {
+        return Err("Host bridge greeting is incomplete or too large".into());
+    }
+    bytes.pop();
+    Ok(bytes)
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn input_eof_preserves_a_response_already_blocked_on_output() {
+        tokio::time::timeout(Duration::from_secs(5), async {
+            let (mut sender, mut input) = tokio::io::duplex(16);
+            let (mut output, mut receiver) = tokio::io::duplex(1);
+            let (stream, server) = tokio::io::duplex(16 * 1024);
+            let bridge = tokio::spawn(async move { relay(&mut input, &mut output, stream).await });
+            let (mut server_read, mut server_write) = tokio::io::split(server);
+            let payload: Vec<_> = (0..128 * 1024).map(|index| (index % 251) as u8).collect();
+            let expected = payload.clone();
+            let server = tokio::spawn(async move {
+                server_write.write_all(&payload).await.unwrap();
+                server_write.shutdown().await.unwrap();
+            });
+            let first = receiver.read_u8().await.unwrap();
+            sender.shutdown().await.unwrap();
+            // This EOF proves the bridge handled input EOF while downstream
+            // was blocked, before allowing the output consumer to drain.
+            assert_eq!(server_read.read(&mut [0]).await.unwrap(), 0);
+            let mut actual = vec![first];
+            receiver.read_to_end(&mut actual).await.unwrap();
+            assert_eq!(actual, expected);
+            bridge.await.unwrap().unwrap();
+            server.await.unwrap();
+        })
+        .await
+        .unwrap();
+    }
+}
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
index 7a9b6221ad..2916dbd4cd 100644
--- a/crates/cli/src/host_client.rs
+++ b/crates/cli/src/host_client.rs
@@ -37,9 +37,9 @@ use tokio::io::{ReadHalf, WriteHalf};
 use tokio_util::sync::CancellationToken;
 
 #[cfg(unix)]
-type Stream = tokio::net::UnixStream;
+pub(super) type Stream = tokio::net::UnixStream;
 #[cfg(windows)]
-type Stream = tokio::net::windows::named_pipe::NamedPipeClient;
+pub(super) type Stream = tokio::net::windows::named_pipe::NamedPipeClient;
 
 /// Discovery is only a hint. The live handshake must confirm both root and epoch.
 #[derive(Deserialize)]
@@ -72,22 +72,7 @@ impl HostClient {
         let root = root.to_owned();
         tokio::time::timeout(Duration::from_secs(5), async move {
             let discovery = tokio::task::spawn_blocking(move || read_discovery(&root)).await??;
-            #[cfg(unix)]
-            let stream = Stream::connect(&discovery.endpoint).await?;
-            #[cfg(windows)]
-            let stream = {
-                use tokio::net::windows::named_pipe::ClientOptions;
-                loop {
-                    match ClientOptions::new().open(&discovery.endpoint) {
-                        Ok(stream) => break stream,
-                        // An existing pipe instance may be between accepts.
-                        Err(error) if error.raw_os_error() == Some(231) => {
-                            tokio::time::sleep(Duration::from_millis(10)).await
-                        }
-                        Err(error) => return Err(error.into()),
-                    }
-                }
-            };
+            let stream = open_stream(&discovery.endpoint).await?;
             let (mut reader, mut writer) =
                 maka_transport::ndjson::split(stream, CancellationToken::new());
             writer
@@ -128,6 +113,16 @@ impl HostClient {
         .await?
     }
 
+    /// A fresh connection to this verified epoch, not a second discovery lookup.
+    /// The bridge must still check its own handshake before forwarding requests.
+    pub async fn open_bridge(&self) -> Result<Stream, HostError> {
+        tokio::time::timeout(
+            Duration::from_secs(5),
+            open_stream(&self.discovery.endpoint),
+        )
+        .await?
+    }
+
     pub async fn status(&mut self) -> Result<Value, HostError> {
         let result = self.request(Operation::HostStatus, json!({})).await?;
         if result["hostEpoch"] != self.discovery.host_epoch {
@@ -235,6 +230,25 @@ impl HostClient {
     }
 }
 
+async fn open_stream(endpoint: &Path) -> Result<Stream, HostError> {
+    #[cfg(unix)]
+    return Ok(Stream::connect(endpoint).await?);
+    #[cfg(windows)]
+    {
+        use tokio::net::windows::named_pipe::ClientOptions;
+        loop {
+            match ClientOptions::new().open(endpoint) {
+                Ok(stream) => return Ok(stream),
+                // An existing pipe instance may be between accepts.
+                Err(error) if error.raw_os_error() == Some(231) => {
+                    tokio::time::sleep(Duration::from_millis(10)).await;
+                }
+                Err(error) => return Err(error.into()),
+            }
+        }
+    }
+}
+
 fn read_discovery(path: &Path) -> Result<Discovery, HostError> {
     let root = root::resolve(path)?;
     let path = RootNamespaces::for_current_account()?
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index d50ec1fc8b..54565ddcad 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -26,6 +26,7 @@ mod endpoint;
 mod host_client;
 mod serve;
 mod signals;
+mod stdio;
 #[cfg(windows)]
 mod windows;
 
diff --git a/crates/cli/src/stdio.rs b/crates/cli/src/stdio.rs
new file mode 100644
index 0000000000..76f6cd587f
--- /dev/null
+++ b/crates/cli/src/stdio.rs
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::io::{self, Read, Write};
+use tokio::{io::DuplexStream, sync::oneshot};
+use tokio_util::io::SyncIoBridge;
+
+/// Process-lifetime stdio workers own no Host, deployment or effect leases.
+/// An open input pipe or blocked output must not hold Tokio shutdown hostage.
+pub(super) struct Stdio {
+    pub input: DuplexStream,
+    pub output: DuplexStream,
+    pub output_done: oneshot::Receiver<io::Result<()>>,
+}
+
+impl Stdio {
+    pub fn open() -> io::Result<Self> {
+        let (input, writer) = tokio::io::duplex(32 * 1024);
+        let (output, reader) = tokio::io::duplex(32 * 1024);
+        let mut writer = SyncIoBridge::new(writer);
+        let mut reader = SyncIoBridge::new(reader);
+        let (done, output_done) = oneshot::channel();
+        std::thread::Builder::new()
+            .name("bridge-stdin".into())
+            .spawn(move || {
+                // EOF drops the duplex writer. The async caller also checks for
+                // a partial greeting before allowing any Host activation.
+                let _ = io::copy(&mut io::stdin().lock(), &mut writer);
+            })?;
+        std::thread::Builder::new()
+            .name("bridge-stdout".into())
+            .spawn(move || {
+                let result = (|| {
+                    let mut stdout = io::stdout().lock();
+                    let mut bytes = [0; 16 * 1024];
+                    loop {
+                        let count = reader.read(&mut bytes)?;
+                        if count == 0 {
+                            return stdout.flush();
+                        }
+                        stdout.write_all(&bytes[..count])?;
+                        stdout.flush()?;
+                    }
+                })();
+                let _ = done.send(result);
+            })?;
+        Ok(Self {
+            input,
+            output,
+            output_done,
+        })
+    }
+}
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index 49c2cee458..bcd8baa928 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -242,9 +242,16 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(observed["host"]["identity"]["pid"], registration["pid"]);
         assert_eq!(observed["host"]["activity"]["state"], "ready");
         assert!(RootOwner::open(&fixture.root, &namespaces).is_err());
-        let retired = Command::new(env!("CARGO_BIN_EXE_maka"))
-            .args(["host", "retire", "--root"])
-            .arg(&fixture.root)
+        let retired = Command::new("node")
+            .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
+            .args([
+                "--bridge",
+                env!("CARGO_BIN_EXE_maka"),
+                "--root-id",
+                &fixture.root_id,
+                "--host-epoch",
+                registration["hostEpoch"].as_str().unwrap(),
+            ])
             .output()
             .unwrap();
         assert!(
diff --git a/crates/event-log/src/root.rs b/crates/event-log/src/root.rs
index 29d23ae572..afb5d74bc8 100644
--- a/crates/event-log/src/root.rs
+++ b/crates/event-log/src/root.rs
@@ -38,6 +38,7 @@ pub const ROOT_MARKER: &str = ".maka-storage-root.json";
 pub const RUST_ROOT_MARKER: &str = ".maka-rust-runtime.json";
 pub const ROOT_DATABASE: &str = "runtime-rust.sqlite";
 const PROTOTYPE: &[u8] = b"{\"schemaVersion\":1,\"runtime\":\"rust-prototype\"}\n";
+const REMOUNT_STAGING: &str = ".maka-storage-root.next";
 
 #[derive(Debug, Clone)]
 pub struct RootNamespaces {
@@ -66,7 +67,7 @@ impl RootNamespaces {
     }
 }
 
-#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 struct Marker {
     schema_version: u8,
@@ -75,7 +76,7 @@ struct Marker {
     root_identity: Identity,
 }
 
-#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
 struct Identity {
     dev: String,
     ino: String,
@@ -117,6 +118,72 @@ pub fn resolve(path: &Path) -> io::Result<RootLocation> {
     })
 }
 
+/// Explicitly confirm an unchanged Linux directory after its filesystem was
+/// remounted. Equal inode numbers alone do not establish identity across volumes.
+/// A matching root is a read-only no-op, including while its Host is running.
+pub fn repair_after_remount(
+    path: &Path,
+    expected_root_id: &str,
+    namespaces: &RootNamespaces,
+) -> io::Result<()> {
+    let canonical_path = path.canonicalize()?;
+    let identity = directory_identity(&canonical_path)?;
+    check_layout(&canonical_path)?;
+    let previous = read_marker(&canonical_path)?;
+    if previous.root_id != expected_root_id
+        || directory_identity(&canonical_path)? != identity
+        || canonical_path.canonicalize()? != canonical_path
+    {
+        return Err(io::Error::other("root changed before remount confirmation"));
+    }
+    if previous.root_identity == identity {
+        return Ok(());
+    }
+    if !cfg!(target_os = "linux") || previous.root_identity.ino != identity.ino {
+        return Err(io::Error::other("root is not an unchanged Linux remount"));
+    }
+    let mut next = previous.clone();
+    next.root_identity = identity;
+    // Use the same two ownership locks as normal Host startup, not executor.lock
+    // or FileLease: Linux's TS-compatible OFD locks are distinct from flock.
+    let owner = RootOwner::acquire(canonical_path, next, namespaces)?;
+    let validate = || {
+        owner.check_directory()?;
+        owner.validate_leases()?;
+        if read_marker(&owner.canonical_path)? != previous {
+            return Err(io::Error::other(
+                "root marker changed during remount confirmation",
+            ));
+        }
+        owner.check_directory()
+    };
+    validate()?;
+    let staging = owner.canonical_path.join(REMOUNT_STAGING);
+    match staging.symlink_metadata() {
+        Ok(metadata) if metadata.is_file() => fs::remove_file(&staging)?,
+        Ok(_) => return Err(io::Error::other("remount staging is not a regular file")),
+        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+        Err(error) => return Err(error),
+    }
+    let mut options = fs::OpenOptions::new();
+    options.write(true).create_new(true);
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::OpenOptionsExt;
+        options.mode(0o600);
+    }
+    let mut file = options.open(&staging)?;
+    file.write_all(&serde_json::to_vec(&owner.marker)?)?;
+    file.sync_all()?;
+    lock::stable(&file, &staging)?;
+    validate()?;
+    fs::rename(&staging, owner.canonical_path.join(ROOT_MARKER))?;
+    // On an uncertain rename/fsync result, stop. The next call re-reads the
+    // official marker; it never promotes or trusts an abandoned staging file.
+    File::open(&owner.canonical_path)?.sync_all()?;
+    owner.validate_current()
+}
+
 fn inspect(path: &Path) -> io::Result<(PathBuf, Marker)> {
     let canonical_path = path.canonicalize()?;
     let identity = directory_identity(&canonical_path)?;
@@ -170,6 +237,16 @@ impl RootOwner {
     /// Open only a marked Rust prototype root, rejecting legacy layouts before database access.
     pub fn open(path: &Path, namespaces: &RootNamespaces) -> io::Result<Self> {
         let (canonical_path, marker) = inspect(path)?;
+        let owner = Self::acquire(canonical_path, marker, namespaces)?;
+        owner.validate_current()?;
+        Ok(owner)
+    }
+
+    fn acquire(
+        canonical_path: PathBuf,
+        marker: Marker,
+        namespaces: &RootNamespaces,
+    ) -> io::Result<Self> {
         lock::private_directory(&namespaces.ownership)?;
         let lock_path = namespaces
             .ownership
@@ -179,16 +256,14 @@ impl RootOwner {
         let control_directory = namespaces.control.join(&marker.root_id);
         lock::private_directory(&control_directory)?;
         let compatibility_lease = lock::acquire(&control_directory.join("owner.lock"))?;
-        let owner = Self {
+        Ok(Self {
             canonical_path,
             marker,
             control_directory,
             lock_path,
             durable_lease,
             compatibility_lease,
-        };
-        owner.validate_current()?;
-        Ok(owner)
+        })
     }
 
     pub fn canonical_path(&self) -> &Path {
@@ -215,6 +290,10 @@ impl RootOwner {
         if marker != self.marker {
             return Err(io::Error::other("root marker identity changed"));
         }
+        self.validate_leases()
+    }
+
+    fn validate_leases(&self) -> io::Result<()> {
         lock::stable(&self.durable_lease, &self.lock_path)?;
         lock::stable(
             &self.compatibility_lease,
@@ -306,6 +385,7 @@ fn check_layout(path: &Path) -> io::Result<()> {
             name.to_str(),
             Some(
                 ROOT_MARKER
+                    | REMOUNT_STAGING
                     | RUST_ROOT_MARKER
                     | ROOT_DATABASE
                     | "runtime-rust.sqlite-wal"
diff --git a/crates/event-log/tests/root_authority.rs b/crates/event-log/tests/root_authority.rs
index 2c8b448af8..5121d35320 100644
--- a/crates/event-log/tests/root_authority.rs
+++ b/crates/event-log/tests/root_authority.rs
@@ -18,7 +18,7 @@
  */
 
 use maka_event_log::root::{ROOT_MARKER, RUST_ROOT_MARKER, RootNamespaces, RootOwner};
-use std::{fs, path::Path, process::Command};
+use std::{fs, path::Path};
 
 fn namespaces(base: &Path) -> RootNamespaces {
     RootNamespaces {
@@ -64,17 +64,67 @@ fn copied_marker_and_rebound_path_cannot_reuse_identity() {
     let path = temp.path().join("root");
     let owner = RootOwner::create(&path, &ns).unwrap();
     let copy = temp.path().join("copy");
+    maka_event_log::root::repair_after_remount(&path, owner.root_id(), &ns).unwrap();
+    assert!(maka_event_log::root::repair_after_remount(&path, &"0".repeat(64), &ns).is_err());
     fs::create_dir(&copy).unwrap();
     for name in [ROOT_MARKER, RUST_ROOT_MARKER] {
         fs::copy(path.join(name), copy.join(name)).unwrap();
     }
     assert!(RootOwner::open(&copy, &ns).is_err());
     assert!(maka_event_log::root::resolve(&copy).is_err());
+    assert!(maka_event_log::root::repair_after_remount(&copy, owner.root_id(), &ns).is_err());
     fs::rename(&path, temp.path().join("moved")).unwrap();
     fs::rename(&copy, &path).unwrap();
     assert!(owner.validate_current().is_err());
 }
 
+#[test]
+#[cfg(target_os = "linux")]
+fn explicit_remount_preserves_authority_and_recovers_only_its_staging_file() {
+    use maka_event_log::root::{repair_after_remount, resolve};
+    let temp = tempfile::tempdir().unwrap();
+    let ns = namespaces(temp.path());
+    let path = temp.path().join("root");
+    let owner = RootOwner::create(&path, &ns).unwrap();
+    let id = owner.root_id().to_owned();
+    let marker = path.join(ROOT_MARKER);
+    let original: serde_json::Value = serde_json::from_slice(&fs::read(&marker).unwrap()).unwrap();
+    let mut stale = original.clone();
+    stale["rootIdentity"]["dev"] = serde_json::json!("0");
+    fs::write(&marker, serde_json::to_vec(&stale).unwrap()).unwrap();
+    assert!(resolve(&path).is_err());
+    assert!(
+        repair_after_remount(&path, &id, &ns).is_err(),
+        "repair bypassed the live owner lease"
+    );
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&fs::read(&marker).unwrap()).unwrap(),
+        stale
+    );
+    drop(owner);
+    let staging = path.join(".maka-storage-root.next");
+    fs::write(&staging, b"abandoned partial write").unwrap();
+    repair_after_remount(&path, &id, &ns).unwrap();
+    assert!(!staging.exists());
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&fs::read(&marker).unwrap()).unwrap(),
+        original
+    );
+    let owner = RootOwner::open(&path, &ns).unwrap();
+    fs::write(&staging, b"never promote this").unwrap();
+    repair_after_remount(&path, &id, &ns).unwrap();
+    assert!(
+        staging.exists(),
+        "no-op must not acquire leases just to clean staging"
+    );
+    owner.validate_current().unwrap();
+    drop(owner);
+    stale["rootIdentity"]["ino"] = serde_json::json!("0");
+    fs::write(&marker, serde_json::to_vec(&stale).unwrap()).unwrap();
+    assert!(repair_after_remount(&path, &id, &ns).is_err());
+    assert_eq!(fs::read(staging).unwrap(), b"never promote this");
+}
+
 #[test]
 #[cfg(unix)]
 fn legacy_and_unsafe_markers_fail_closed() {
@@ -104,38 +154,3 @@ fn legacy_and_unsafe_markers_fail_closed() {
     fs::write(path.join(ROOT_MARKER), vec![b' '; 1025]).unwrap();
     assert!(owner.validate_current().is_err());
 }
-
-#[test]
-fn durable_lease_blocks_other_process_even_after_cache_removal() {
-    if let Some(base) = std::env::var_os("MAKA_ROOT_AUTHORITY_TEST_BASE") {
-        let base = Path::new(&base);
-        let error = RootOwner::open(&base.join("root"), &namespaces(base))
-            .err()
-            .unwrap();
-        assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
-        return;
-    }
-    let temp = tempfile::tempdir().unwrap();
-    let ns = namespaces(temp.path());
-    let path = temp.path().join("root");
-    let owner = RootOwner::create(&path, &ns).unwrap();
-    fs::create_dir(path.join("skills")).unwrap();
-    assert_eq!(
-        maka_event_log::root::initialize(&path, &ns).unwrap(),
-        owner.root_id()
-    );
-    fs::remove_dir_all(&ns.control).unwrap();
-    let status = Command::new(std::env::current_exe().unwrap())
-        .args([
-            "--exact",
-            "durable_lease_blocks_other_process_even_after_cache_removal",
-            "--nocapture",
-        ])
-        .env("MAKA_ROOT_AUTHORITY_TEST_BASE", temp.path())
-        .status()
-        .unwrap();
-    assert!(status.success());
-    assert!(owner.validate_current().is_err());
-    drop(owner);
-    RootOwner::open(&path, &ns).unwrap();
-}
diff --git a/crates/event-log/tests/root_lease.rs b/crates/event-log/tests/root_lease.rs
new file mode 100644
index 0000000000..5051c77305
--- /dev/null
+++ b/crates/event-log/tests/root_lease.rs
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Keep this process-spawning check in its own binary: fork temporarily inherits
+// other test threads' OFD leases even when their files are close-on-exec.
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use std::{fs, path::Path, process::Command};
+
+fn namespaces(base: &Path) -> RootNamespaces {
+    RootNamespaces {
+        ownership: base.join("owners"),
+        control: base.join("control"),
+    }
+}
+
+#[test]
+fn durable_lease_blocks_other_process_even_after_cache_removal() {
+    if let Some(base) = std::env::var_os("MAKA_ROOT_AUTHORITY_TEST_BASE") {
+        let base = Path::new(&base);
+        let error = RootOwner::open(&base.join("root"), &namespaces(base))
+            .err()
+            .unwrap();
+        assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
+        return;
+    }
+    let temp = tempfile::tempdir().unwrap();
+    let ns = namespaces(temp.path());
+    let path = temp.path().join("root");
+    let owner = RootOwner::create(&path, &ns).unwrap();
+    fs::create_dir(path.join("skills")).unwrap();
+    assert_eq!(
+        maka_event_log::root::initialize(&path, &ns).unwrap(),
+        owner.root_id()
+    );
+    fs::remove_dir_all(&ns.control).unwrap();
+    let status = Command::new(std::env::current_exe().unwrap())
+        .args([
+            "--exact",
+            "durable_lease_blocks_other_process_even_after_cache_removal",
+            "--nocapture",
+        ])
+        .env("MAKA_ROOT_AUTHORITY_TEST_BASE", temp.path())
+        .status()
+        .unwrap();
+    assert!(status.success());
+    assert!(owner.validate_current().is_err());
+    drop(owner);
+    RootOwner::open(&path, &ns).unwrap();
+}
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index cbbf61e1fa..db9af85680 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -58,6 +58,13 @@ Windows MSVC builds use the static CRT required by the official V8 archive.
 The two vendored Deno TypeScript files must match `deno_telemetry` exactly;
 update them together when upgrading that dependency. They avoid a build-time V8.
 
+`host connect --root-id <rootId> --framed` activates that deployment and bridges the
+client protocol over stdin/stdout; diagnostics use stderr. Linux/macOS input EOF
+half-closes the connection and drains responses. Windows pipe EOF disconnects;
+clients must receive their responses before closing stdin. WSL passes
+`--repair-root-after-remount`: this explicitly confirms an unchanged Linux inode
+after remount, preserving Root ID. Do not use it to adopt copied or legacy roots.
+
 `host install --root <directory>` pins the current executable and on-demand policy;
 `--mode supervised` selects persistent serving. Only the returned `executable`
 may start that managed root. `host activate --root-id <rootId> --framed` reuses a ready
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 74b138f3ba..1e87f813a6 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -53,8 +53,13 @@ Windows MSVC 构建使用与官方 V8 静态库一致的静态 CRT。
 两份随仓库保存的 Deno TypeScript 须与 `deno_telemetry` 完全一致,升级依赖时须同步;
 构建直接转译它们,不再加载构建期 V8。
 
+`host connect --root-id <rootId> --framed` 激活该部署,通过 stdin/stdout 桥接客户端协议,
+诊断写入 stderr。Linux/macOS 的输入 EOF 半关闭连接并排空响应;Windows 管道 EOF 表示断连,
+客户端须先收完响应再关闭 stdin。WSL 传入 `--repair-root-after-remount`,显式确认重挂载后
+Linux inode 未变并保留 Root ID。不要用它接管复制的根或旧版数据。
+
 `host install --root <目录>` 固定当前可执行文件和按需策略;`--mode supervised` 选择持续运行。
-此后只有返回的 `executable` 可以启动该托管根。`host activate --root-id <rootId> --framed`
+只有返回的 `executable` 可以启动该托管根。`host activate --root-id <rootId> --framed`
 复用就绪 Host,或启动固定版本;supervised 模式会注册并启动账户级 systemd 服务、LaunchAgent
 或 Windows 计划任务。Linux 要求用户服务管理器已运行且启用 linger;macOS 要求 Aqua 登录,
 Windows 要求交互式用户会话。单独安装不会启动服务或修改账户策略。
diff --git a/tests/fixtures/client-bridge-entry.mjs b/tests/fixtures/client-bridge-entry.mjs
new file mode 100644
index 0000000000..d578320190
--- /dev/null
+++ b/tests/fixtures/client-bridge-entry.mjs
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { createInterface } from 'node:readline';
+import { parseArgs } from 'node:util';
+import { connectRuntimeHostWslEnvironment } from '../../packages/runtime-host/src/client/wsl-environment.js';
+import { RUNTIME_HOST_COMPATIBILITY_EPOCH } from '../../packages/runtime-host/src/protocol/index.js';
+
+const { values } = parseArgs({
+  options: {
+    bridge: { type: 'string' },
+    'root-id': { type: 'string' },
+    'host-epoch': { type: 'string' },
+  },
+});
+const children = [];
+const args = ['host', 'connect', '--framed', '--root-id', values['root-id']];
+function launch(input = args) {
+  const child = spawn(values.bridge, input, { stdio: 'pipe', timeout: 8_000 });
+  child.stderr.pipe(process.stderr, { end: false });
+  const exited = new Promise((resolve) => {
+    child.once('exit', (code, signal) => resolve({ code, signal }));
+    child.once('error', (error) => resolve({ error }));
+  });
+  children.push({ child, exited });
+  return { child, exited };
+}
+const hello = {
+  kind: 'hello',
+  clientInstanceId: 'native-bridge-fixture',
+  protocolMin: 0,
+  protocolMax: 0,
+  compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
+  compositionId: 'maka.interactive',
+};
+const line = (frame) => JSON.stringify(frame) + '\n';
+const request = (operation, input, id = 'bridge-request') => ({
+  requestId: id,
+  operation,
+  input,
+});
+
+try {
+  // Exercise the real WSL client and its unchanged launch arguments/transport.
+  // Only the OS process factory is replaced for ordinary three-platform tests.
+  const connection = await connectRuntimeHostWslEnvironment(
+    {
+      distribution: 'Maka-Test',
+      operator: { kind: 'native', platform: 'posix', executablePath: '/maka' },
+      rootId: values['root-id'],
+      clientInstanceId: 'original-wsl-client',
+    },
+    {
+      wslExecutable: 'fixture-wsl',
+      processFactory: (executable, input) => {
+        assert.equal(executable, 'fixture-wsl');
+        assert.deepEqual(input.slice(0, 4), ['--distribution', 'Maka-Test', '--exec', '/maka']);
+        assert(input.includes('--repair-root-after-remount'));
+        return launch(input.slice(4)).child;
+      },
+    },
+  );
+  try {
+    assert.equal(connection.rootId, values['root-id']);
+    assert.equal(connection.hostEpoch, values['host-epoch']);
+    assert.equal(
+      (await connection.request('host.diagnostics.query', {})).hostEpoch,
+      values['host-epoch'],
+    );
+  } finally {
+    await connection.close();
+  }
+
+  if (process.platform !== 'win32') {
+    const { child, exited } = launch();
+    // One write pipelines the request beyond hello; EOF must not discard either
+    // the input prefetch or the Host's final response.
+    child.stdin.end(line(hello) + line(request('host.diagnostics.query', {})));
+    let response;
+    for await (const text of createInterface({ input: child.stdout })) {
+      const frame = JSON.parse(text);
+      if (frame.requestId === 'bridge-request') response = frame;
+    }
+    assert.deepEqual(await exited, { code: 0, signal: null });
+    assert.equal(response?.ok, true, JSON.stringify(response));
+    assert.equal(response.result.hostEpoch, values['host-epoch']);
+  }
+
+  const { child, exited } = launch();
+  child.stdin.write(line(hello));
+  let prepared;
+  for await (const text of createInterface({ input: child.stdout })) {
+    const frame = JSON.parse(text);
+    if (frame.kind === 'accepted') {
+      assert.equal(frame.rootId, values['root-id']);
+      assert.equal(frame.hostEpoch, values['host-epoch']);
+      child.stdin.write(
+        line(
+          request('host.upgrade.prepare', {
+            expectedHostEpoch: values['host-epoch'],
+            allowInterruptActiveTasks: false,
+            allowCooperativeHandoff: true,
+          }),
+        ),
+      );
+    }
+    if (frame.requestId === 'bridge-request') prepared = frame;
+  }
+  // stdin was never ended: a process-lifetime stdio read must not prevent exit.
+  assert.deepEqual(await exited, { code: 0, signal: null });
+  assert.equal(prepared?.ok, true, JSON.stringify(prepared));
+  assert.equal(prepared.result.kind, 'prepared', JSON.stringify(prepared));
+} finally {
+  for (const { child, exited } of children) {
+    if (child.exitCode === null && child.signalCode === null) child.kill();
+    await exited;
+  }
+}
diff --git a/tests/fixtures/client.mjs b/tests/fixtures/client.mjs
index 9afe61c4a5..5fbf98bab4 100644
--- a/tests/fixtures/client.mjs
+++ b/tests/fixtures/client.mjs
@@ -36,6 +36,7 @@ const entries = [
   ['--project-workspace', 'client-project-entry.mjs'],
   ['--native-candidate', 'client-candidate-entry.mjs'],
   ['--activation-frame', 'client-activation-entry.mjs'],
+  ['--bridge', 'client-bridge-entry.mjs'],
   ['--system-prompt-workspace', 'client-system-prompt-entry.mjs'],
   ['--model-overrides-workspace', 'client-model-overrides-entry.mjs'],
   ['--runtime-policy-workspace', 'client-runtime-policy-entry.mjs'],


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 26/29: perf(desktop): reuse native Host without redundant initialization

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit e17a375b936b1d47f49fa9fb76e9ab2892e86ee4
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 00:04:47 2026 +0800

    perf(desktop): reuse native Host without redundant initialization
    
    Generated-by: OpenAI Codex
---
 apps/desktop/src/main/native-runtime-host.ts | 5 +++--
 tests/fixtures/client-candidate-entry.mjs    | 7 ++++++-
 2 files changed, 9 insertions(+), 3 deletions(-)

diff --git a/apps/desktop/src/main/native-runtime-host.ts b/apps/desktop/src/main/native-runtime-host.ts
index 65eeaf13cb..b4a81e6637 100644
--- a/apps/desktop/src/main/native-runtime-host.ts
+++ b/apps/desktop/src/main/native-runtime-host.ts
@@ -56,8 +56,9 @@ export function createNativeRuntimeHostCandidateLaunchBarrier(
   return createRuntimeHostCandidateLaunchBarrierWithDependencies({
     retireTimeoutMs: 1000,
     launchCandidate: (input) => launchCandidate(executable, input),
-    async connect(input, launchCandidate) {
-      await initializeNativeRuntimeHost(executable, input.rootPath, input.signal);
+    connect(input, launchCandidate) {
+      // Startup initializes the root once. Every connection still verifies its
+      // identity through discovery; reconnecting needs no initializer process.
       return connectOrSpawnRuntimeHostWithDependencies(input, {
         launchCandidate,
         random: Math.random,
diff --git a/tests/fixtures/client-candidate-entry.mjs b/tests/fixtures/client-candidate-entry.mjs
index 73028f15e6..ec90f5d5c2 100644
--- a/tests/fixtures/client-candidate-entry.mjs
+++ b/tests/fixtures/client-candidate-entry.mjs
@@ -69,14 +69,19 @@ try {
     }),
     (error) => error.code === 'operation_conflict',
   );
-  const observer = await connectExistingRuntimeHost({
+  const observer = await barrier.connect({
     rootPath: values.root,
     protocol: { min: 0, max: 0 },
     compositionId: 'maka.interactive',
     generation: 'native-cli-test',
+    candidateEntrypoint: 'host',
+    closeOnLauncherExit: true,
+    electionDeadlineMs: 8_000,
   });
   assert.equal(observer.kind, 'connected');
   try {
+    assert.equal(observer.spawnedProcess, undefined, 'reconnect must reuse the current Host');
+    assert.equal(observer.registration.hostEpoch, result.registration.hostEpoch);
     const busyDiagnostics = await result.connection.request('host.diagnostics.query', {});
     assert.equal(busyDiagnostics.connections, 2);
     assert.equal(busyDiagnostics.upgradeBlockingActivity, true);


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 12/29: feat(desktop): accept native host operators across SSH and WSL

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 4e66509fce68aa10b0f8812fe9f3742794e01991
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 16:41:58 2026 +0800

    feat(desktop): accept native host operators across SSH and WSL
    
    Generated-by: OpenAI Codex
---
 .../__tests__/runtime-host-wsl-controller.test.ts  | 13 +++---
 apps/desktop/src/main/runtime-host-onboarding.ts   |  3 +-
 .../src/main/runtime-host-wsl-controller.ts        | 11 +++--
 .../src/__tests__/ssh-operator-activation.test.ts  | 38 ++++++++++++++++
 .../src/client/ssh-operator-activation.ts          |  8 ++--
 packages/runtime-host/src/operator/index.ts        |  2 +
 .../runtime-host/src/operator/operator-command.ts  | 50 ++++++++++++++++++++++
 packages/runtime-host/src/operator/setup-frame.ts  |  4 +-
 8 files changed, 111 insertions(+), 18 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
index 67e0c24cdc..78688e36cf 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
@@ -66,7 +66,7 @@ test('WSL management invokes the stable operator directly with the exact deploym
   });
   const result = await runDesktopRuntimeHostWslManagement({
     distribution: 'Ubuntu',
-    operator: OPERATOR,
+    operator: { kind: 'native', platform: 'posix', executablePath: '/opt/Maka Host/maka' },
     action: 'configure',
     expectedTarget: {
       serviceId: 'a'.repeat(64),
@@ -102,8 +102,8 @@ test('WSL management invokes the stable operator directly with the exact deploym
     '--distribution',
     'Ubuntu',
     '--exec',
-    '/usr/bin/node',
-    '/home/operator/.local/share/maka/operator.mjs',
+    '/opt/Maka Host/maka',
+    'host',
     'configure',
   ]);
   assert.ok(launch?.args.includes('--expected-deployment-id'));
@@ -145,7 +145,7 @@ test('WSL setup forwards the development archive and its exact evidence', async
           version: '0.2.0-development',
           serviceId: 'b'.repeat(64),
           deploymentId: '00000000-0000-4000-8000-000000000001',
-          operator: { ...OPERATOR, modulePath: '/tmp/maka/operator.mjs' },
+          operator: { kind: 'native', platform: 'posix', executablePath: '/tmp/maka/maka' },
           rootPath: '/tmp/maka/root',
           rootId: 'a'.repeat(64),
           endpoint: 'ws://127.0.0.1:7443/runtime-host',
@@ -160,7 +160,7 @@ test('WSL setup forwards the development archive and its exact evidence', async
   };
   const integrity = `sha512-${createHash('sha512').update('archive evidence').digest('base64')}`;
 
-  await runDesktopRuntimeHostWslSetup({
+  const installed = await runDesktopRuntimeHostWslSetup({
     distribution: 'Ubuntu',
     setupPackage: {
       kind: 'development_archive',
@@ -170,6 +170,9 @@ test('WSL setup forwards the development archive and its exact evidence', async
     principalId: 'desktop-owner:pairing',
   }, () => undefined, undefined, { processFactory, wslExecutable: 'wsl.exe' });
 
+  assert.deepEqual(installed.operator, {
+    kind: 'native', platform: 'posix', executablePath: '/tmp/maka/maka',
+  });
   assert.deepEqual(launches[0], [
     '--distribution',
     'Ubuntu',
diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts
index 14dac44db2..54e55b0b92 100644
--- a/apps/desktop/src/main/runtime-host-onboarding.ts
+++ b/apps/desktop/src/main/runtime-host-onboarding.ts
@@ -22,6 +22,7 @@ import type { IpcMain } from 'electron';
 import {
   parseRuntimeHostSetupEndpoint,
   type RuntimeHostNodeOperatorCommand,
+  type RuntimeHostNativeOperatorCommand,
   type RuntimeHostOperatorCommand,
   type RuntimeHostSetupPhase,
 } from '@maka/runtime-host/operator';
@@ -77,7 +78,7 @@ export function createDesktopRuntimeHostOnboarding(input: {
     readonly rootPath: string;
     readonly serviceId: string;
     readonly deploymentId: string;
-    readonly operator: RuntimeHostNodeOperatorCommand<'posix'>;
+    readonly operator: RuntimeHostNodeOperatorCommand<'posix'> | RuntimeHostNativeOperatorCommand<'posix'>;
   }>;
   readonly listWslDistributions: () => Promise<readonly string[]>;
   readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void;
diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts
index ba440ef916..59ef1a7af1 100644
--- a/apps/desktop/src/main/runtime-host-wsl-controller.ts
+++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts
@@ -32,10 +32,10 @@ import {
   RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
   RUNTIME_HOST_SETUP_FRAME_PREFIX,
   RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV,
-  createRuntimeHostOperatorCommand,
   decodeRuntimeHostPosixOperatorCommand,
   runtimeHostOperatorInvocation,
   type RuntimeHostNodeOperatorCommand,
+  type RuntimeHostNativeOperatorCommand,
   type RuntimeHostPosixOperatorCommand,
   type RuntimeHostSetupFrame,
   type RuntimeHostSetupPhase,
@@ -50,7 +50,7 @@ const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024;
 
 type RuntimeHostSetupCompleteFrame = Extract<RuntimeHostSetupFrame, { kind: 'complete' | 'existing_environment' }>;
 type RuntimeHostWslSetupCompleteFrame = Omit<RuntimeHostSetupCompleteFrame, 'operator'> & {
-  readonly operator: RuntimeHostNodeOperatorCommand<'posix'>;
+  readonly operator: RuntimeHostNodeOperatorCommand<'posix'> | RuntimeHostNativeOperatorCommand<'posix'>;
 };
 type RuntimeHostManagementTerminalFrame = Exclude<
   RuntimeHostServiceManagementFrame,
@@ -202,11 +202,10 @@ export async function runDesktopRuntimeHostWslSetup(
       }
       return {
         ...frame,
-        operator: createRuntimeHostOperatorCommand({
+        operator: {
+          ...frame.operator,
           platform: 'posix',
-          nodePath: frame.operator.nodePath,
-          modulePath: frame.operator.modulePath,
-        }),
+        },
       };
     },
     onResult: () => onComplete?.(),
diff --git a/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts b/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts
index 927423add1..32d4ef2c95 100644
--- a/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts
+++ b/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts
@@ -36,6 +36,7 @@ import {
   encodeRuntimeHostActivationFrame,
 } from '../operator/activation-frame.js';
 import { RUNTIME_HOST_PROTOCOL_VERSION } from '../protocol/index.js';
+import { decodeRuntimeHostOperatorCommand } from '../operator/operator-command.js';
 
 const ROOT_ID = 'a'.repeat(64);
 const execFileAsync = promisify(execFile);
@@ -114,6 +115,43 @@ test('POSIX operator commands apply environment before exec', async (t) => {
 
   const { stdout } = await execFileAsync('/bin/sh', ['-c', command]);
   assert.equal(stdout, "value with ' quotes");
+  const native = decodeRuntimeHostOperatorCommand({
+    kind: 'native',
+    platform: 'posix',
+    executablePath: '/bin/echo',
+  });
+  const nativeResult = await execFileAsync('/bin/sh', [
+    '-c',
+    runtimeHostSshOperatorRemoteCommand(native, ['activate', "literal ' $HOME ; value"]),
+  ]);
+  assert.equal(nativeResult.stdout, "host activate literal ' $HOME ; value\n");
+  const windows = runtimeHostSshOperatorRemoteCommand(
+    {
+      kind: 'native',
+      platform: 'win32',
+      executablePath: String.raw`C:\Program Files\Maka\maka.exe`,
+    },
+    ['activate', '--root-id', ROOT_ID],
+  );
+  const script = Buffer.from(windows.split(' ').at(-1)!, 'base64').toString('utf16le');
+  const payload = JSON.parse(
+    Buffer.from(script.match(/FromBase64String\('([^']+)'\)/u)![1], 'base64').toString('utf8'),
+  );
+  assert.equal(payload.executable, String.raw`C:\Program Files\Maka\maka.exe`);
+  assert.deepEqual(payload.args, ['host', 'activate', '--root-id', ROOT_ID]);
+  assert.throws(() =>
+    decodeRuntimeHostOperatorCommand({
+      kind: 'native',
+      platform: 'posix',
+      executablePath: 'maka',
+    }),
+  );
+  assert.throws(() =>
+    decodeRuntimeHostOperatorCommand({
+      ...native,
+      nodePath: '/usr/bin/node',
+    }),
+  );
   assert.equal(
     runtimeHostSshOperatorRemoteCommand(
       { kind: 'legacy_posix_executable', executablePath: '/opt/maka/operator' },
diff --git a/packages/runtime-host/src/client/ssh-operator-activation.ts b/packages/runtime-host/src/client/ssh-operator-activation.ts
index 3f49f217cf..aae1daecc4 100644
--- a/packages/runtime-host/src/client/ssh-operator-activation.ts
+++ b/packages/runtime-host/src/client/ssh-operator-activation.ts
@@ -143,20 +143,20 @@ export function runtimeHostSshOperatorRemoteCommand(
     const deploymentRoot = quotePosix(posix.dirname(operatorPath));
     return `if [ ! -e ${artifact} ]; then [ ! -e ${deploymentRoot} ] && exit 0; exit 1; fi; ${execute}`;
   }
-  if (operator.kind !== 'node') throw new Error('Windows Runtime Host operator must use Node');
+  const operatorPath = operator.kind === 'node' ? operator.modulePath : operator.executablePath;
   const payload = Buffer.from(
     JSON.stringify({
       ...invocation,
       environment,
-      modulePath: operator.modulePath,
-      deploymentRoot: win32.dirname(operator.modulePath),
+      operatorPath,
+      deploymentRoot: win32.dirname(operatorPath),
       missingOperatorIsSuccess: options.missingOperatorIsSuccess === true,
     }),
     'utf8',
   ).toString('base64');
   const script = [
     `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}'))|ConvertFrom-Json`,
-    `if($p.missingOperatorIsSuccess -and -not (Test-Path -LiteralPath $p.modulePath -PathType Leaf)){if(-not (Test-Path -LiteralPath $p.deploymentRoot)){exit 0}else{exit 1}}`,
+    `if($p.missingOperatorIsSuccess -and -not (Test-Path -LiteralPath $p.operatorPath -PathType Leaf)){if(-not (Test-Path -LiteralPath $p.deploymentRoot)){exit 0}else{exit 1}}`,
     `foreach($e in $p.environment.psobject.Properties){[Environment]::SetEnvironmentVariable($e.Name,[string]$e.Value,'Process')}`,
     `$code=1`,
     `try{& ([string]$p.executable) @($p.args|ForEach-Object {[string]$_});$code=if($null -eq $LASTEXITCODE){1}else{$LASTEXITCODE}}catch{$code=1}`,
diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts
index dc28112338..37a2250874 100644
--- a/packages/runtime-host/src/operator/index.ts
+++ b/packages/runtime-host/src/operator/index.ts
@@ -98,6 +98,7 @@ export {
 } from './setup-frame.js';
 export {
   createRuntimeHostOperatorCommand,
+  createRuntimeHostNativeOperatorCommand,
   createRuntimeHostLegacyPosixOperatorCommand,
   decodeRuntimeHostOperatorCommand,
   decodeRuntimeHostPosixOperatorCommand,
@@ -109,6 +110,7 @@ export {
   type RuntimeHostPosixOperatorCommand,
   type RuntimeHostLegacyPosixOperatorCommand,
   type RuntimeHostNodeOperatorCommand,
+  type RuntimeHostNativeOperatorCommand,
 } from './operator-command.js';
 export {
   RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV,
diff --git a/packages/runtime-host/src/operator/operator-command.ts b/packages/runtime-host/src/operator/operator-command.ts
index 37581840e3..15f4d59d62 100644
--- a/packages/runtime-host/src/operator/operator-command.ts
+++ b/packages/runtime-host/src/operator/operator-command.ts
@@ -33,6 +33,15 @@ export interface RuntimeHostNodeOperatorCommand<
   readonly modulePath: string;
 }
 
+/** The native Maka CLI owns the complete host operator command tree. */
+export interface RuntimeHostNativeOperatorCommand<
+  Platform extends RuntimeHostOperatorPlatform = RuntimeHostOperatorPlatform,
+> {
+  readonly kind: 'native';
+  readonly platform: Platform;
+  readonly executablePath: string;
+}
+
 /** Compatibility route for managed deployments created before the Node operator shipped. */
 export interface RuntimeHostLegacyPosixOperatorCommand {
   readonly kind: 'legacy_posix_executable';
@@ -41,10 +50,12 @@ export interface RuntimeHostLegacyPosixOperatorCommand {
 
 export type RuntimeHostOperatorCommand =
   | RuntimeHostNodeOperatorCommand
+  | RuntimeHostNativeOperatorCommand
   | RuntimeHostLegacyPosixOperatorCommand;
 
 export type RuntimeHostPosixOperatorCommand =
   | RuntimeHostNodeOperatorCommand<'posix'>
+  | RuntimeHostNativeOperatorCommand<'posix'>
   | RuntimeHostLegacyPosixOperatorCommand;
 
 export function createRuntimeHostOperatorCommand<
@@ -71,6 +82,23 @@ export function createRuntimeHostLegacyPosixOperatorCommand(
   });
 }
 
+export function createRuntimeHostNativeOperatorCommand<
+  Platform extends RuntimeHostOperatorPlatform,
+>(input: {
+  readonly platform: Platform;
+  readonly executablePath: string;
+}): RuntimeHostNativeOperatorCommand<Platform> {
+  return Object.freeze({
+    kind: 'native',
+    platform: input.platform,
+    executablePath: requireAbsolutePath(
+      input.executablePath,
+      input.platform,
+      'operator executable',
+    ),
+  });
+}
+
 export function runtimeHostManagedOperatorCommand<Platform extends RuntimeHostOperatorPlatform>(
   deployment: {
     readonly deploymentRoot: string;
@@ -109,6 +137,19 @@ export function decodeRuntimeHostOperatorCommand(value: unknown): RuntimeHostOpe
     );
   }
   const keys = Object.keys(record).sort();
+  if (record.kind === 'native') {
+    const expected = ['executablePath', 'kind', 'platform'];
+    if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
+      throw new Error('Runtime Host operator command has unexpected fields');
+    }
+    if (record.platform !== 'posix' && record.platform !== 'win32') {
+      throw new Error('Runtime Host operator platform is invalid');
+    }
+    return createRuntimeHostNativeOperatorCommand({
+      platform: record.platform,
+      executablePath: requireString(record.executablePath, 'Runtime Host operator executable'),
+    });
+  }
   const expected = ['kind', 'modulePath', 'nodePath', 'platform'];
   if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
     throw new Error('Runtime Host operator command has unexpected fields');
@@ -132,6 +173,12 @@ export function decodeRuntimeHostPosixOperatorCommand(
   if (command.platform !== 'posix') {
     throw new Error('Runtime Host operator must target POSIX');
   }
+  if (command.kind === 'native') {
+    return createRuntimeHostNativeOperatorCommand({
+      platform: 'posix',
+      executablePath: command.executablePath,
+    });
+  }
   return createRuntimeHostOperatorCommand({
     platform: 'posix',
     nodePath: command.nodePath,
@@ -147,6 +194,9 @@ export function runtimeHostOperatorInvocation(
   if (normalized.kind === 'legacy_posix_executable') {
     return { executable: normalized.executablePath, args };
   }
+  if (normalized.kind === 'native') {
+    return { executable: normalized.executablePath, args: ['host', ...args] };
+  }
   return {
     executable: normalized.nodePath,
     args: [normalized.modulePath, ...args],
diff --git a/packages/runtime-host/src/operator/setup-frame.ts b/packages/runtime-host/src/operator/setup-frame.ts
index 24cd3350fa..aab67407e7 100644
--- a/packages/runtime-host/src/operator/setup-frame.ts
+++ b/packages/runtime-host/src/operator/setup-frame.ts
@@ -54,8 +54,8 @@ const environmentBindingFields = {
   operator: z.unknown().transform((value, context) => {
     try {
       const command = decodeRuntimeHostOperatorCommand(value);
-      if (command.kind !== 'node') {
-        throw new Error('Runtime Host setup operator must be a Node command');
+      if (command.kind === 'legacy_posix_executable') {
+        throw new Error('Runtime Host setup operator must declare its target platform');
       }
       return command;
     } catch (error) {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 06/29: feat(runtime-host): cancel sealed handoffs without provider recovery

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d046fbf3b55d9f957850b075bb5402465001ead4
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 14:32:35 2026 +0800

    feat(runtime-host): cancel sealed handoffs without provider recovery
    
    Freeze successor admission evidence before sealing and independently verify it at continuation. Claim and cancel an unowned pause atomically, preserving public identity and reserving terminal capacity.
    
    Generated-by: Codex
---
 crates/agent/src/cancellation.rs                   |  24 +--
 crates/agent/src/compact.rs                        |   1 +
 crates/agent/src/continuation.rs                   |  54 +++--
 crates/agent/src/handoff.rs                        |   6 +-
 crates/agent/src/lib.rs                            |   3 +-
 crates/agent/src/model_attempt.rs                  |   4 +-
 crates/agent/src/steps.rs                          |  34 +++-
 crates/agent/tests/integration/handoff.rs          |  42 +++-
 crates/event-log/src/context/frozen.rs             |  24 ++-
 crates/event-log/src/handoff.rs                    |   1 +
 crates/event-log/src/handoff/budget.rs             |  39 +++-
 crates/event-log/src/handoff/cancel.rs             | 116 +++++++++++
 crates/event-log/tests/continuation.rs             |   2 +
 crates/event-log/tests/continuation/handoff.rs     |   6 +-
 .../event-log/tests/continuation/handoff_budget.rs |   9 +-
 .../event-log/tests/continuation/handoff_cancel.rs | 190 ++++++++++++++++++
 crates/presentation/tests/projection.rs            |   6 +-
 crates/runtime-host/src/execution.rs               |  27 ++-
 crates/runtime-host/tests/integration/handoff.rs   | 222 +++++++++++++++++++++
 crates/runtime-host/tests/integration/main.rs      |   1 +
 crates/runtime/src/continuation.rs                 |  17 +-
 crates/runtime/src/event.rs                        |  19 ++
 crates/runtime/src/handoff.rs                      |   2 +-
 crates/runtime/src/handoff/execution.rs            |   7 +-
 docs/rust-runtime.md                               |   1 +
 docs/rust-runtime.zh-CN.md                         |   1 +
 26 files changed, 781 insertions(+), 77 deletions(-)

diff --git a/crates/agent/src/cancellation.rs b/crates/agent/src/cancellation.rs
index 782ea6e502..061048b18c 100644
--- a/crates/agent/src/cancellation.rs
+++ b/crates/agent/src/cancellation.rs
@@ -17,17 +17,10 @@
  * under the License.
  */
 
-use maka_runtime::workhub::ActionId;
+pub use maka_runtime::event::CancellationCause;
 use std::sync::{Arc, Mutex};
 use tokio_util::sync::CancellationToken;
 
-#[derive(Clone, Debug)]
-pub enum CancellationCause {
-    Runtime,
-    WorkhubStop { action_id: ActionId },
-    WorkhubCorrection { action_id: ActionId },
-}
-
 #[derive(Clone)]
 pub struct RunCancellation {
     token: CancellationToken,
@@ -69,15 +62,12 @@ impl RunCancellation {
     }
 
     pub(crate) fn source(&self) -> String {
-        match self.cause.lock().unwrap().as_ref() {
-            Some(CancellationCause::WorkhubCorrection { action_id }) => {
-                maka_runtime::workhub::correction_abort_source(action_id)
-            }
-            Some(CancellationCause::WorkhubStop { action_id }) => {
-                maka_runtime::workhub::stop_abort_source(action_id)
-            }
-            Some(CancellationCause::Runtime) | None => "runtime_cancellation".into(),
-        }
+        self.cause
+            .lock()
+            .unwrap()
+            .as_ref()
+            .unwrap_or(&CancellationCause::Runtime)
+            .source()
     }
 }
 
diff --git a/crates/agent/src/compact.rs b/crates/agent/src/compact.rs
index d3b8c4da1c..5749bf4b07 100644
--- a/crates/agent/src/compact.rs
+++ b/crates/agent/src/compact.rs
@@ -84,6 +84,7 @@ pub(super) async fn run(
         ModelPurpose::Summary,
         cancellation,
         continuation_base,
+        &input.invocation.invocation_id,
     )
     .await?;
     let base = format!(
diff --git a/crates/agent/src/continuation.rs b/crates/agent/src/continuation.rs
index e67220a27f..587e16a710 100644
--- a/crates/agent/src/continuation.rs
+++ b/crates/agent/src/continuation.rs
@@ -153,20 +153,9 @@ pub(super) async fn inspect(
             crate::RunWork::Handoff { pause, .. } => pause.execution.replay_base,
             _ => Some(base.high_water),
         },
+        &input.invocation.invocation_id,
     )
     .await?;
-    if !matches!(
-        prompt.iter().find(|m| !matches!(m, Message::System { .. })),
-        Some(Message::User { .. })
-    ) || !matches!(
-        prompt.last(),
-        Some(Message::User { .. } | Message::Tool { .. })
-    ) && !matches!(prompt.last(), Some(Message::Assistant { content, .. }) if matches!(content.last(), Some(AssistantPart::ToolResult { .. })))
-    {
-        return Err(invalid(
-            "continuation requires stable user/tool replay boundaries",
-        ));
-    }
     let tools = maka_tools::RunTools::new(
         inner.log.clone(),
         input.invocation.clone(),
@@ -201,18 +190,43 @@ pub(super) async fn inspect(
             }
         }
     }
+    let replay = replay(input, prompt, definitions, cancellation)?;
+    if let crate::RunWork::Handoff { pause, .. } = &input.work
+        && replay != pause.execution.replay
+    {
+        return Err(invalid("handoff admission replay changed after sealing"));
+    }
+    Ok((base, replay))
+}
+
+/// Shared by reversible handoff preparation and independently verified admission.
+pub(super) fn replay(
+    input: &RunInput,
+    prompt: Vec<Message>,
+    definitions: Vec<maka_model::ToolDefinition>,
+    cancellation: &CancellationToken,
+) -> Result<ReplayEvidence, RunError> {
+    if !matches!(
+        prompt.iter().find(|m| !matches!(m, Message::System { .. })),
+        Some(Message::User { .. })
+    ) || !matches!(
+        prompt.last(),
+        Some(Message::User { .. } | Message::Tool { .. })
+    ) && !matches!(prompt.last(), Some(Message::Assistant { content, .. }) if matches!(content.last(), Some(AssistantPart::ToolResult { .. })))
+    {
+        return Err(invalid(
+            "continuation requires stable user/tool replay boundaries",
+        ));
+    }
     let request = model_attempt::prepare_request(input, prompt, definitions, ModelPurpose::Main)?;
     if cancellation.is_cancelled() {
         return Err(RunError::Cancelled);
     }
-    Ok((
-        base,
-        ReplayEvidence {
-            version: REPLAY_VERSION,
-            digest: request.input_digest,
-            route_identity: request.route_identity,
-        },
-    ))
+    Ok(ReplayEvidence {
+        version: REPLAY_VERSION,
+        digest: request.input_digest,
+        route_identity: request.route_identity,
+    })
 }
 
 fn invalid(reason: &str) -> RunError {
diff --git a/crates/agent/src/handoff.rs b/crates/agent/src/handoff.rs
index 7c0f0ad891..45256964db 100644
--- a/crates/agent/src/handoff.rs
+++ b/crates/agent/src/handoff.rs
@@ -256,7 +256,11 @@ mod tests {
             intent,
             remaining_steps: std::num::NonZeroU16::new(2).unwrap(),
             execution: Box::new(HandoffExecution {
-                route_identity: format!("sha256:{}", "b".repeat(64)),
+                replay: maka_runtime::continuation::ReplayEvidence {
+                    version: maka_runtime::continuation::REPLAY_VERSION,
+                    digest: format!("sha256:{}", "a".repeat(64)),
+                    route_identity: format!("sha256:{}", "b".repeat(64)),
+                },
                 context: None,
                 provider_options: serde_json::json!({}),
                 main_output_limit: None,
diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs
index 7003fc2dcf..6a1a5436b8 100644
--- a/crates/agent/src/lib.rs
+++ b/crates/agent/src/lib.rs
@@ -198,7 +198,8 @@ impl Engine {
                     || input.provider_options != pause.execution.provider_options
                     || input.main_output_limit != pause.execution.main_output_limit
                     || input.supports_vision != pause.execution.supports_vision
-                    || model_attempt::route_identity(&input)? != pause.execution.route_identity
+                    || model_attempt::route_identity(&input)?
+                        != pause.execution.replay.route_identity
                 {
                     return Err(RunError::InvalidInput(
                         "handoff execution settings changed".into(),
diff --git a/crates/agent/src/model_attempt.rs b/crates/agent/src/model_attempt.rs
index c79a585e95..e905636524 100644
--- a/crates/agent/src/model_attempt.rs
+++ b/crates/agent/src/model_attempt.rs
@@ -49,11 +49,12 @@ pub(super) async fn prompt(
     purpose: ModelPurpose,
     cancellation: &CancellationToken,
     continuation_base: Option<u64>,
+    current: &str,
 ) -> Result<Vec<Message>, RunError> {
     let route = route_identity(input)?;
     let replay = continuation_base.map(|base| history::Replay {
         base,
-        current: &input.invocation.invocation_id,
+        current,
         route: &route,
         model: &input.provider.model,
     });
@@ -176,6 +177,7 @@ pub(super) async fn execute(
                 ModelPurpose::Main,
                 cancellation,
                 continuation_base,
+                &input.invocation.invocation_id,
             )
             .await?;
             if replay != prompt {
diff --git a/crates/agent/src/steps.rs b/crates/agent/src/steps.rs
index 5ee315a11e..4360e39dd1 100644
--- a/crates/agent/src/steps.rs
+++ b/crates/agent/src/steps.rs
@@ -56,12 +56,42 @@ pub(super) async fn run(
         inner.log.commit_pending_steering(&input.invocation).await?;
         if let Some(pause) = handoff
             .boundary(cancellation, |intent| async {
+                let source = inner
+                    .log
+                    .read_model_context(
+                        &input.invocation.session_id,
+                        Some(&input.invocation.invocation_id),
+                        10_000,
+                        8 * 1024 * 1024,
+                    )
+                    .await
+                    .ok()?;
+                // Read the live source, but project from the successor's point of
+                // view: manual replay cuts distinguish current from inherited work.
+                let prompt = model_attempt::prompt(
+                    inner,
+                    input,
+                    &source,
+                    ModelPurpose::Main,
+                    cancellation,
+                    continuation_base,
+                    &intent.successor_invocation_id,
+                )
+                .await
+                .ok()?;
+                let replay = crate::continuation::replay(
+                    input,
+                    prompt,
+                    tools.capture().definitions(),
+                    cancellation,
+                )
+                .ok()?;
                 let pause = maka_runtime::handoff::HandoffPause {
                     intent,
                     remaining_steps: std::num::NonZeroU16::new((max_steps - step) as u16)
                         .expect("validated step budget"),
                     execution: Box::new(maka_runtime::handoff::HandoffExecution {
-                        route_identity: model_attempt::route_identity(input).ok()?,
+                        replay,
                         context: input.context.clone(),
                         provider_options: input.provider_options.clone(),
                         main_output_limit: input.main_output_limit,
@@ -126,6 +156,7 @@ pub(super) async fn run(
             ModelPurpose::Main,
             cancellation,
             continuation_base,
+            &input.invocation.invocation_id,
         )
         .await?;
         let result = model_attempt::execute(
@@ -206,6 +237,7 @@ pub(super) async fn run(
                 ModelPurpose::Main,
                 cancellation,
                 continuation_base,
+                &input.invocation.invocation_id,
             )
             .await?;
             let ids: Vec<_> = local_calls.iter().map(|call| call.id.as_str()).collect();
diff --git a/crates/agent/tests/integration/handoff.rs b/crates/agent/tests/integration/handoff.rs
index cfb824ffd3..dd03bb220b 100644
--- a/crates/agent/tests/integration/handoff.rs
+++ b/crates/agent/tests/integration/handoff.rs
@@ -26,7 +26,7 @@ use maka_event_log::EventLog;
 use maka_js_runtime::{CellLimits, CodeExecutor};
 use maka_model::ModelExecutor;
 use maka_runtime::{
-    event::{Fact, InvocationOutcome, TerminalStatus},
+    event::{EventWrite, Fact, InvocationInput, InvocationOutcome, RuntimeEvent, TerminalStatus},
     handoff::HandoffIntent,
 };
 use std::{
@@ -53,7 +53,8 @@ fn intent(id: &str) -> HandoffIntent {
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reopen() {
-    tokio::time::timeout(Duration::from_secs(20), async {
+    for manual in [false, true] {
+        tokio::time::timeout(Duration::from_secs(20), async {
         let directory = tempfile::tempdir().unwrap();
         let path = directory.path().join("events.sqlite");
         let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -88,6 +89,36 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         run.configuration.workspace_identity = Some(maka_runtime::execution::WorkspaceIdentity::from_marker_id(
             "ef751105-55b5-4d65-a364-646281586a17").unwrap());
         run.main_output_limit = Some(120);
+        if manual {
+            run.request_fingerprint = Some(maka_runtime::artifact::content_digest(b"manual-resume"));
+            let mut source = run.invocation.clone();
+            source.turn_id = "turn-source".into();
+            source.run_id = "run-source".into();
+            source.invocation_id = "invocation-source".into();
+            for (invocation, text) in [(source.clone(), "question source"), ({
+                let mut branch = source.clone();
+                branch.turn_id = "turn-branch".into();
+                branch.run_id = "run-branch".into();
+                branch.invocation_id = "invocation-branch".into();
+                branch
+            }, "unrelated branch")] {
+                for fact in [
+                    Fact::InvocationOpened { configuration: Some(run.configuration.clone()),
+                        input: InvocationInput::Message { content: text.into(), request_fingerprint: None,
+                            source_messages: Vec::new(), skill_invocation: None } },
+                    Fact::InvocationEnded { outcome: InvocationOutcome::Cancelled { source: "user".into() } },
+                ] {
+                    log.append(&EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap()).await.unwrap();
+                }
+            }
+            let prefix = log.run_prefix("session", &source.run_id, None, 100, 128 * 1024).await.unwrap().unwrap();
+            let maka_agent::RunWork::Message { tools, max_steps, .. } = run.work else { unreachable!() };
+            run.work = maka_agent::RunWork::Continuation {
+                source: maka_runtime::continuation::RunBoundary {
+                    invocation: source, high_water: prefix.high_water, digest: prefix.digest,
+                }, workhub_resume: None, tools, max_steps,
+            };
+        }
         let configuration = run.configuration.clone();
         let running = engine.start(run, CancellationToken::new()).await.unwrap();
         first.await.unwrap();
@@ -111,6 +142,7 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         let held = next.ready().await.unwrap();
         assert_eq!(held.preview().remaining_steps.get(), 1);
         let expected = held.preview().clone();
+        assert_eq!(expected.execution.replay_base.is_some(), manual);
         let receipt = held.commit().unwrap().wait().await.unwrap();
         assert_eq!(receipt, expected);
         running.wait().await.unwrap();
@@ -119,7 +151,7 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         assert!(tokio::time::timeout(Duration::from_millis(30), &mut third).await.is_err(),
             "no model request follows the committed hold");
         let before = log.prefix(100, 128 * 1024).await.unwrap();
-        assert_eq!(before.events.iter().filter(|e| matches!(e.event.fact, Fact::InvocationOpened { .. })).count(), 1);
+        assert_eq!(before.events.iter().filter(|e| matches!(e.event.fact, Fact::InvocationOpened { .. })).count(), if manual { 3 } else { 1 });
         assert!(matches!(&before.events.last().unwrap().event.fact,
             Fact::InvocationEnded { outcome: InvocationOutcome::HandoffPaused { pause } } if pause == &expected));
         let effect = Arc::new(Effect { log: log.clone(), count: count.clone() });
@@ -176,10 +208,14 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         let messages = requests[2]["messages"].as_array().unwrap();
         assert_eq!(messages.iter().filter(|m| m["role"] == "user").count(), 1);
         assert_eq!(messages.iter().filter(|m| m["role"] == "tool").count(), 2);
+        let prompt = serde_json::to_string(messages).unwrap();
+        assert!(prompt.contains(if manual { "question source" } else { "question first" }));
+        assert!(!prompt.contains("unrelated branch"));
         assert_eq!(count.load(Ordering::SeqCst), 1, "settled effects are replayed as facts, never executed");
         let finished = reopened.prefix(100, 128 * 1024).await.unwrap();
         assert_eq!(finished.project_invocation("invocation-committed").terminal, Some(TerminalStatus::Completed));
         assert!(!serde_json::to_string(&finished.events).unwrap().contains("do-not-persist-this-secret"));
         reopened.shutdown().await.unwrap();
     }).await.unwrap();
+    }
 }
diff --git a/crates/event-log/src/context/frozen.rs b/crates/event-log/src/context/frozen.rs
index f20581d3c3..7a2fa46e33 100644
--- a/crates/event-log/src/context/frozen.rs
+++ b/crates/event-log/src/context/frozen.rs
@@ -195,15 +195,23 @@ pub(crate) async fn verify_base(
     opening_sequence: u64,
     expected: &maka_runtime::continuation::SessionBase,
 ) -> Result<(), StoreError> {
-    let session = &invocation.session_id;
-    let through = evidence::high_water(connection, session, opening_sequence as i64).await?;
-    if through != expected.high_water
-        || evidence::evidence(connection, session, through)
-            .await?
-            .digest
-            != expected.digest
-    {
+    if fresh_base(connection, invocation, opening_sequence).await? != *expected {
         return Err(invalid("continuation's fresh Session base changed"));
     }
     Ok(())
 }
+
+pub(crate) async fn fresh_base(
+    connection: &mut SqliteConnection,
+    invocation: &Invocation,
+    opening_sequence: u64,
+) -> Result<maka_runtime::continuation::SessionBase, StoreError> {
+    let session = &invocation.session_id;
+    let through = evidence::high_water(connection, session, opening_sequence as i64).await?;
+    Ok(maka_runtime::continuation::SessionBase {
+        high_water: through,
+        digest: evidence::evidence(connection, session, through)
+            .await?
+            .digest,
+    })
+}
diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
index d2d8e17e8d..897c1307ac 100644
--- a/crates/event-log/src/handoff.rs
+++ b/crates/event-log/src/handoff.rs
@@ -22,6 +22,7 @@ use maka_runtime::event::{Fact, InvocationInput, InvocationOutcome, RuntimeEvent
 use sqlx::{Connection, SqliteConnection};
 
 mod budget;
+mod cancel;
 
 impl EventLog {
     /// Eligibility only. The source still owns execution until its seal commits.
diff --git a/crates/event-log/src/handoff/budget.rs b/crates/event-log/src/handoff/budget.rs
index 73a92af124..f2ebf099c8 100644
--- a/crates/event-log/src/handoff/budget.rs
+++ b/crates/event-log/src/handoff/budget.rs
@@ -20,10 +20,9 @@
 use crate::StoreError;
 use maka_runtime::{
     continuation::{
-        ContinuationClaim, MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, REPLAY_VERSION, ReplayEvidence,
-        RunBoundary, SessionBase,
+        ContinuationClaim, MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, RunBoundary, SessionBase,
     },
-    event::{Fact, InvocationInput, RuntimeEvent},
+    event::{CancellationCause, Fact, InvocationInput, InvocationOutcome, RuntimeEvent},
     handoff::HandoffPause,
 };
 use sqlx::SqliteConnection;
@@ -53,7 +52,7 @@ pub(super) async fn check(
         ));
     };
     // Sizing only: no claim is acquired and no guessed proof is persisted.
-    // Reserve the widest ordinals, fixed-width digests and both new envelopes.
+    // Reserve both new envelopes and room to cancel the successor immediately.
     let digest = format!("sha256:{}", "0".repeat(64));
     let successor = RuntimeEvent::new(
         pause.intent.successor(&seal.invocation),
@@ -72,19 +71,37 @@ pub(super) async fn check(
                         high_water: u64::MAX,
                         digest: digest.clone(),
                     },
-                    replay: ReplayEvidence {
-                        version: REPLAY_VERSION,
-                        digest,
-                        route_identity: pause.execution.route_identity.clone(),
-                    },
+                    replay: pause.execution.replay.clone(),
                 }),
             },
         },
     );
+    // Attribution strings are fixed prefixes plus fixed-width digests, regardless
+    // of the action ID. Size every supported cause, never a guessed byte allowance.
+    let action_id = maka_runtime::workhub::ActionId::new(pause.intent.claim_id.clone())
+        .map_err(super::invalid)?;
+    let mut cancelled_bytes = 0;
+    for cause in [
+        CancellationCause::Runtime,
+        CancellationCause::WorkhubStop {
+            action_id: action_id.clone(),
+        },
+        CancellationCause::WorkhubCorrection { action_id },
+    ] {
+        let cancelled = RuntimeEvent::new(
+            successor.invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::Cancelled {
+                    source: cause.source(),
+                },
+            },
+        );
+        cancelled_bytes = cancelled_bytes.max(envelope_bytes(&cancelled)?);
+    }
     let bytes = MAX_SOURCE_BYTES
-        .checked_sub(seal_bytes + envelope_bytes(&successor)?)
+        .checked_sub(seal_bytes + envelope_bytes(&successor)? + cancelled_bytes)
         .ok_or(StoreError::PrefixTooLarge)?;
-    crate::context::read::check_handoff_capacity(tx, opening, MAX_SOURCE_EVENTS - 2, bytes).await
+    crate::context::read::check_handoff_capacity(tx, opening, MAX_SOURCE_EVENTS - 3, bytes).await
 }
 
 fn envelope_bytes(event: &RuntimeEvent) -> Result<usize, StoreError> {
diff --git a/crates/event-log/src/handoff/cancel.rs b/crates/event-log/src/handoff/cancel.rs
new file mode 100644
index 0000000000..b6d71bc707
--- /dev/null
+++ b/crates/event-log/src/handoff/cancel.rs
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{EventLog, StoreError, turns::TurnBoundary};
+use maka_runtime::{
+    continuation::{ContinuationClaim, MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, RunBoundary},
+    event::{
+        CancellationCause, EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome,
+        RuntimeEvent,
+    },
+};
+use sqlx::Connection;
+
+impl EventLog {
+    /// Settle an unclaimed seal without loading a provider or replaying effects.
+    /// If another actor already claimed it, return the current physical owner;
+    /// stopping that live execution remains the caller's responsibility.
+    pub async fn cancel_handoff(
+        &self,
+        source: &Invocation,
+        cause: CancellationCause,
+    ) -> Result<TurnBoundary, StoreError> {
+        self.validate_root()?;
+        for id in [
+            &source.session_id,
+            &source.turn_id,
+            &source.run_id,
+            &source.invocation_id,
+        ] {
+            crate::sessions::validate_id(id)?;
+        }
+        let source = source.clone();
+        let commits = self.commits.clone();
+        self.connection.run(move |connection| Box::pin(async move {
+            let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+            let prefix = crate::run_prefix::read(
+                &mut tx, &source.session_id, &source.run_id, &source.invocation_id,
+                i64::MAX, MAX_SOURCE_EVENTS, MAX_SOURCE_BYTES,
+            ).await?;
+            if prefix.invocation != source {
+                return Err(super::invalid("handoff cancellation source changed"));
+            }
+            let Some(seal) = prefix.events.last() else {
+                return Err(super::invalid("handoff cancellation source is missing"));
+            };
+            let Fact::InvocationEnded { outcome: InvocationOutcome::HandoffPaused { pause } } = &seal.event.fact else {
+                return Err(super::invalid("handoff cancellation requires a sealed pause"));
+            };
+            let claimed: bool = sqlx::query_scalar(
+                "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened')"
+            ).bind(&pause.intent.successor_invocation_id).fetch_one(&mut *tx).await?;
+            if claimed {
+                return crate::turns::read(&mut tx, &source.session_id, Some(&source.turn_id)).await?
+                    .ok_or_else(|| super::invalid("claimed handoff has no Turn"));
+            }
+            let opening = prefix.events.first().expect("checked nonempty prefix");
+            let Fact::InvocationOpened { input, configuration: Some(configuration) } = &opening.event.fact else {
+                return Err(super::invalid("handoff source has no admitted configuration"));
+            };
+            let base = match input {
+                InvocationInput::Message { .. } => {
+                    crate::context::frozen::fresh_base(&mut tx, &source, opening.sequence).await?
+                }
+                InvocationInput::Handoff { claim, .. } | InvocationInput::Continuation { claim, .. } => claim.base.clone(),
+                _ => return Err(super::invalid("handoff source is not a model Run")),
+            };
+            let invocation = pause.intent.successor(&source);
+            let opening = RuntimeEvent::new(invocation.clone(), Fact::InvocationOpened {
+                configuration: Some(configuration.clone()),
+                input: InvocationInput::Handoff {
+                    pause: Box::new(pause.clone()),
+                    claim: Box::new(ContinuationClaim {
+                        id: pause.intent.claim_id.clone(),
+                        source: RunBoundary { invocation: source, high_water: prefix.high_water, digest: prefix.digest },
+                        base,
+                        replay: pause.execution.replay.clone(),
+                    }),
+                },
+            });
+            let writes = [
+                EventWrite::plain(opening.clone()),
+                EventWrite::plain(RuntimeEvent::new(invocation, Fact::InvocationEnded {
+                    outcome: InvocationOutcome::Cancelled { source: cause.source() },
+                })),
+            ].into_iter().collect::<Result<Vec<_>, _>>().map_err(|e| super::invalid(&e.to_string()))?;
+            let mut last = 0;
+            for write in &writes {
+                last = match EventLog::append_in_transaction(&mut tx, write).await? {
+                    crate::append::AppendResult::Existing(sequence)
+                    | crate::append::AppendResult::Inserted(sequence) => sequence,
+                };
+            }
+            crate::context::validate_batch(&mut tx, &writes).await?;
+            let boundary = crate::turns::project(&mut tx, opening).await?;
+            tx.commit().await.map_err(StoreError::CommitUnknown)?;
+            commits.send_replace(last);
+            Ok(boundary)
+        })).await
+    }
+}
diff --git a/crates/event-log/tests/continuation.rs b/crates/event-log/tests/continuation.rs
index c150636d9b..50f4f02cd9 100644
--- a/crates/event-log/tests/continuation.rs
+++ b/crates/event-log/tests/continuation.rs
@@ -35,6 +35,8 @@ mod fixtures;
 mod handoff;
 #[path = "continuation/handoff_budget.rs"]
 mod handoff_budget;
+#[path = "continuation/handoff_cancel.rs"]
+mod handoff_cancel;
 #[path = "continuation/handoff_claim.rs"]
 mod handoff_claim;
 #[path = "continuation/handoff_consumers.rs"]
diff --git a/crates/event-log/tests/continuation/handoff.rs b/crates/event-log/tests/continuation/handoff.rs
index c8ebd3efef..b1ba90ccac 100644
--- a/crates/event-log/tests/continuation/handoff.rs
+++ b/crates/event-log/tests/continuation/handoff.rs
@@ -29,7 +29,11 @@ use std::num::NonZeroU16;
 pub(super) fn execution() -> Box<maka_runtime::handoff::HandoffExecution> {
     use maka_runtime::handoff::{HandoffExecution, HandoffTools};
     Box::new(HandoffExecution {
-        route_identity: digest('b'),
+        replay: ReplayEvidence {
+            version: REPLAY_VERSION,
+            digest: digest('a'),
+            route_identity: digest('b'),
+        },
         context: None,
         provider_options: serde_json::json!({}),
         main_output_limit: None,
diff --git a/crates/event-log/tests/continuation/handoff_budget.rs b/crates/event-log/tests/continuation/handoff_budget.rs
index 40f7a813d9..4534cb51b7 100644
--- a/crates/event-log/tests/continuation/handoff_budget.rs
+++ b/crates/event-log/tests/continuation/handoff_budget.rs
@@ -29,7 +29,7 @@ use std::{
 };
 
 #[tokio::test]
-async fn handoff_reserves_both_envelopes_before_sealing_a_near_capacity_context() {
+async fn handoff_reserves_successor_and_cancellation_before_sealing_a_near_capacity_context() {
     for reserve_successor in [false, true] {
         let directory = tempfile::tempdir().unwrap();
         let log = EventLog::open(&directory.path().join("capacity.sqlite"))
@@ -110,6 +110,13 @@ async fn handoff_reserves_both_envelopes_before_sealing_a_near_capacity_context(
         });
         if reserve_successor {
             reserved_bytes += serde_json::to_vec(&future).unwrap().len();
+            let mut cancelled = future.clone();
+            cancelled["fact"] = serde_json::json!({"kind":"invocation_ended", "outcome":{
+                "kind":"cancelled", "source": maka_runtime::event::CancellationCause::WorkhubCorrection {
+                    action_id: pause.intent.claim_id.parse().unwrap(),
+                }.source(),
+            }});
+            reserved_bytes += serde_json::to_vec(&cancelled).unwrap().len();
         }
         let mut completed = RuntimeEvent::new(
             source.invocation.clone(),
diff --git a/crates/event-log/tests/continuation/handoff_cancel.rs b/crates/event-log/tests/continuation/handoff_cancel.rs
new file mode 100644
index 0000000000..509c40d3db
--- /dev/null
+++ b/crates/event-log/tests/continuation/handoff_cancel.rs
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_event_log::turns::InvocationState;
+use maka_runtime::{
+    event::CancellationCause,
+    handoff::{HandoffIntent, HandoffPause},
+};
+use std::num::NonZeroU16;
+
+#[tokio::test]
+async fn paused_cancellation_claim_is_atomic_idempotent_and_never_cancels_an_existing_owner() {
+    for already_claimed in [false, true] {
+        let directory = tempfile::tempdir().unwrap();
+        let path = directory.path().join("events.sqlite");
+        let log = EventLog::open(&path).await.unwrap();
+        let source = opening("source", None);
+        append(&log, &source).await;
+        let before = log
+            .context_before_run(&source.invocation, 100, 65536)
+            .await
+            .unwrap();
+        let pause = HandoffPause {
+            intent: HandoffIntent {
+                handoff_id: "handoff".into(),
+                host_epoch: "host".into(),
+                root_run_id: source.invocation.run_id.clone(),
+                successor_run_id: "next-run".into(),
+                successor_invocation_id: "next-invocation".into(),
+                claim_id: "claim".into(),
+            },
+            remaining_steps: NonZeroU16::new(2).unwrap(),
+            execution: super::handoff::execution(),
+        };
+        append(
+            &log,
+            &RuntimeEvent::new(
+                source.invocation.clone(),
+                Fact::InvocationEnded {
+                    outcome: InvocationOutcome::HandoffPaused {
+                        pause: pause.clone(),
+                    },
+                },
+            ),
+        )
+        .await;
+        let frozen = log
+            .run_prefix("session", "source", None, 100, 65536)
+            .await
+            .unwrap()
+            .unwrap();
+        if already_claimed {
+            let mut target = opening("unused", None);
+            target.invocation = pause.intent.successor(&source.invocation);
+            let Fact::InvocationOpened { input, .. } = &mut target.fact else {
+                unreachable!()
+            };
+            *input = InvocationInput::Handoff {
+                claim: Box::new(
+                    claim(
+                        &log,
+                        "claim",
+                        &source,
+                        SessionBase {
+                            high_water: before.source_evidence.high_water,
+                            digest: before.source_evidence.digest,
+                        },
+                    )
+                    .await,
+                ),
+                pause: Box::new(pause.clone()),
+            };
+            append(&log, &target).await;
+            let before = log.prefix(100, 65536).await.unwrap();
+            let result = log
+                .cancel_handoff(&source.invocation, CancellationCause::Runtime)
+                .await
+                .unwrap();
+            assert_eq!(result.invocation, target.invocation);
+            assert!(
+                !matches!(result.state, InvocationState::Ended { .. }),
+                "the existing worker still owns cancellation"
+            );
+            assert_eq!(log.prefix(100, 65536).await.unwrap().digest, before.digest);
+            close(&log, &target).await;
+        } else {
+            let database = rusqlite::Connection::open(&path).unwrap();
+            database.execute_batch(
+                "CREATE TRIGGER reject_cancel BEFORE INSERT ON event_log
+                 WHEN NEW.kind='invocation_ended' AND json_extract(NEW.event_json,'$.fact.outcome.kind')='cancelled'
+                 BEGIN SELECT RAISE(ABORT, 'cancel fault'); END;"
+            ).unwrap();
+            let before = log.prefix(100, 65536).await.unwrap();
+            assert!(
+                log.cancel_handoff(&source.invocation, CancellationCause::Runtime)
+                    .await
+                    .is_err()
+            );
+            assert_eq!(
+                log.prefix(100, 65536).await.unwrap().digest,
+                before.digest,
+                "failed terminal must roll back its opening and claim"
+            );
+            database
+                .execute_batch("DROP TRIGGER reject_cancel")
+                .unwrap();
+            let (first, retry) = tokio::join!(
+                log.cancel_handoff(&source.invocation, CancellationCause::Runtime),
+                log.cancel_handoff(
+                    &source.invocation,
+                    CancellationCause::WorkhubStop {
+                        action_id: "stop".parse().unwrap()
+                    }
+                ),
+            );
+            let first = first.unwrap();
+            let retry = retry.unwrap();
+            assert_eq!(first.invocation, pause.intent.successor(&source.invocation));
+            assert_eq!(first.root_invocation(), &source.invocation);
+            assert_eq!(first.invocation, retry.invocation);
+            let InvocationState::Ended { event_id, outcome } = first.state else {
+                panic!("cancellation did not settle")
+            };
+            let InvocationState::Ended {
+                event_id: retried_id,
+                outcome: retried_outcome,
+            } = retry.state
+            else {
+                panic!("retry lost cancellation")
+            };
+            assert_eq!(event_id, retried_id);
+            assert_eq!(
+                outcome, retried_outcome,
+                "the first cancellation retains its attribution"
+            );
+            assert!(matches!(outcome, InvocationOutcome::Cancelled { .. }));
+            let prefix = log
+                .run_prefix("session", "next-run", None, 100, 65536)
+                .await
+                .unwrap()
+                .unwrap();
+            assert_eq!(
+                prefix.events.len(),
+                2,
+                "cancel requires neither model request nor tool dispatch"
+            );
+        }
+        assert_eq!(
+            log.run_prefix("session", "source", None, 100, 65536)
+                .await
+                .unwrap()
+                .unwrap()
+                .digest,
+            frozen.digest
+        );
+        let later = opening("later-turn", None);
+        append(&log, &later).await;
+        close(&log, &later).await;
+        let before = log.prefix(100, 65536).await.unwrap().digest;
+        log.close().await.unwrap();
+        let log = EventLog::open(&path).await.unwrap();
+        let replay = log
+            .cancel_handoff(&source.invocation, CancellationCause::Runtime)
+            .await
+            .unwrap();
+        assert_eq!(
+            replay.invocation.turn_id, source.invocation.turn_id,
+            "a later Session Turn is not the claim owner"
+        );
+        assert_eq!(log.prefix(100, 65536).await.unwrap().digest, before);
+        log.close().await.unwrap();
+    }
+}
diff --git a/crates/presentation/tests/projection.rs b/crates/presentation/tests/projection.rs
index 4d7c61b01d..eec3ed42aa 100644
--- a/crates/presentation/tests/projection.rs
+++ b/crates/presentation/tests/projection.rs
@@ -173,7 +173,11 @@ fn completed_and_interrupted_rows_keep_overlay_identity_time_and_original_decode
                 },
                 remaining_steps: std::num::NonZeroU16::new(2).unwrap(),
                 execution: Box::new(maka_runtime::handoff::HandoffExecution {
-                    route_identity: format!("sha256:{}", "b".repeat(64)),
+                    replay: maka_runtime::continuation::ReplayEvidence {
+                        version: maka_runtime::continuation::REPLAY_VERSION,
+                        digest: format!("sha256:{}", "a".repeat(64)),
+                        route_identity: format!("sha256:{}", "b".repeat(64)),
+                    },
                     context: None,
                     provider_options: serde_json::json!({}),
                     main_output_limit: None,
diff --git a/crates/runtime-host/src/execution.rs b/crates/runtime-host/src/execution.rs
index 45b5407009..9078f25bea 100644
--- a/crates/runtime-host/src/execution.rs
+++ b/crates/runtime-host/src/execution.rs
@@ -186,11 +186,32 @@ impl Executions {
             .await
             .map_err(internal)?
             .ok_or_else(|| failure(Code::NotFound, "Turn does not exist"))?;
-        let invocation = boundary.invocation.clone();
-        let snapshot = snapshot::project(boundary).snapshot;
-        if snapshot.run_id != input.run_id {
+        if boundary.root_invocation().run_id != input.run_id {
             return Err(failure(Code::OperationConflict, "Run identity changed"));
         }
+        let boundary = if matches!(
+            boundary.state,
+            maka_event_log::turns::InvocationState::Ended {
+                outcome: maka_runtime::event::InvocationOutcome::HandoffPaused { .. },
+                ..
+            }
+        ) {
+            self.log
+                .cancel_handoff(&boundary.invocation, maka_agent::CancellationCause::Runtime)
+                .await
+                .map_err(|error| match error {
+                    StoreError::CommitUnknown(_) | StoreError::OperationUnknown => {
+                        self.begin_drain();
+                        // turn.stop has no commit_outcome_unknown wire outcome.
+                        // Fence admission without inventing a client protocol code.
+                        failure(Code::HostDraining, &error.to_string())
+                    }
+                    _ => internal(error),
+                })?
+        } else {
+            boundary
+        };
+        let invocation = boundary.invocation;
         let cancellation = self
             .active
             .lock()
diff --git a/crates/runtime-host/tests/integration/handoff.rs b/crates/runtime-host/tests/integration/handoff.rs
new file mode 100644
index 0000000000..5eeef215c6
--- /dev/null
+++ b/crates/runtime-host/tests/integration/handoff.rs
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+use super::support::{client_probe::ClientFixture, peer::Peer};
+use maka_protocol::session::{PermissionMode, WorkspaceProjection, WorkspaceTarget};
+use maka_runtime::{
+    artifact::content_digest,
+    continuation::{REPLAY_VERSION, ReplayEvidence},
+    event::{EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, RuntimeEvent},
+    execution::ToolMode,
+    handoff::{HandoffExecution, HandoffIntent, HandoffPause, HandoffTools},
+};
+use maka_runtime_host::{
+    server::{Host, local::LocalListener},
+    session::{PreparedSession, SessionModel},
+};
+use serde_json::json;
+use std::{num::NonZeroU16, time::Duration};
+use tokio_util::sync::CancellationToken;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn stop_sealed_turn_uses_public_identity_without_provider_and_survives_restart() {
+    let fixture = ClientFixture::new("maka-handoff-stop-");
+    let log = fixture.log().await;
+    let cwd = fixture.workspace.to_string_lossy().into_owned();
+    let configuration = PreparedSession::new(
+        serde_json::from_value(json!({
+            "sessionId":"session", "workspace":{"kind":"host_path", "path":cwd},
+            "modelTarget":{"kind":"default"}
+        }))
+        .unwrap(),
+    )
+    .unwrap()
+    .bind(
+        WorkspaceProjection {
+            target: WorkspaceTarget::HostPath { path: cwd.clone() },
+            host_cwd: cwd,
+        },
+        SessionModel {
+            connection_id: "removed-provider".into(),
+            connection_slug: "removed".into(),
+            model: "unavailable".into(),
+        },
+        PermissionMode::Explore,
+        ToolMode::Direct,
+    );
+    log.create_session("session", "fixture", &configuration, 1)
+        .await
+        .unwrap();
+    let invocation = Invocation {
+        session_id: "session".into(),
+        turn_id: "turn".into(),
+        run_id: "public-run".into(),
+        invocation_id: "source-invocation".into(),
+    };
+    let pause = HandoffPause {
+        intent: HandoffIntent {
+            handoff_id: "handoff".into(),
+            host_epoch: "old-host".into(),
+            root_run_id: invocation.run_id.clone(),
+            successor_run_id: "physical-successor".into(),
+            successor_invocation_id: "successor-invocation".into(),
+            claim_id: "claim".into(),
+        },
+        remaining_steps: NonZeroU16::new(1).unwrap(),
+        execution: Box::new(HandoffExecution {
+            replay: ReplayEvidence {
+                version: REPLAY_VERSION,
+                digest: content_digest(b"admission"),
+                route_identity: content_digest(b"missing-provider"),
+            },
+            context: None,
+            provider_options: json!({}),
+            main_output_limit: None,
+            supports_vision: false,
+            tools: HandoffTools {
+                catalog_digest: content_digest(b"unavailable-tools"),
+                loaded: Default::default(),
+            },
+            compaction_attempted: false,
+            replay_base: None,
+        }),
+    };
+    for fact in [
+        Fact::InvocationOpened {
+            configuration: Some(configuration.invocation_configuration().await.unwrap()),
+            input: InvocationInput::Message {
+                content: "stop this task".into(),
+                request_fingerprint: None,
+                source_messages: Vec::new(),
+                skill_invocation: None,
+            },
+        },
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::HandoffPaused { pause },
+        },
+    ] {
+        log.append(&EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap())
+            .await
+            .unwrap();
+    }
+    let sealed = log.prefix(100, 128 * 1024).await.unwrap();
+    log.close().await.unwrap();
+    let mut result = None;
+    let mut settled = None;
+    for reopened in [false, true] {
+        let host = Host::open(fixture.owner()).await.unwrap();
+        #[cfg(unix)]
+        let endpoint = fixture.workspace.parent().unwrap().join("handoff.sock");
+        #[cfg(windows)]
+        let endpoint =
+            std::path::PathBuf::from(format!(r"\\.\pipe\maka-handoff-{}", uuid::Uuid::new_v4()));
+        let cancel = CancellationToken::new();
+        let server = tokio::spawn(
+            LocalListener::bind(&endpoint)
+                .unwrap()
+                .serve(host.clone(), cancel.clone()),
+        );
+        let mut peer = Peer::new(host.clone(), "handoff-stop").await;
+        let turn = peer
+            .rpc(
+                "turn.query",
+                json!({"sessionId":"session", "turnId":"turn"}),
+            )
+            .await;
+        assert_eq!(turn["result"]["runId"], "public-run", "{turn}");
+        assert_eq!(
+            turn["result"]["status"],
+            if reopened { "cancelled" } else { "running" },
+            "{turn}"
+        );
+        let wrong = peer
+            .rpc(
+                "turn.stop",
+                json!({
+                    "sessionId":"session", "turnId":"turn", "runId":"physical-successor"
+                }),
+            )
+            .await;
+        assert_eq!(wrong["error"]["code"], "operation_conflict", "{wrong}");
+        let after_wrong = peer
+            .rpc(
+                "turn.query",
+                json!({"sessionId":"session", "turnId":"turn"}),
+            )
+            .await;
+        assert_eq!(
+            after_wrong, turn,
+            "wrong public identity must not claim or cancel the seal"
+        );
+        for _ in 0..2 {
+            let stopped = peer
+                .rpc(
+                    "turn.stop",
+                    json!({
+                        "sessionId":"session", "turnId":"turn", "runId":"public-run"
+                    }),
+                )
+                .await;
+            assert_eq!(stopped["ok"], true, "{stopped}");
+            assert_eq!(stopped["result"]["status"], "cancelled", "{stopped}");
+            assert_eq!(stopped["result"]["runId"], "public-run", "{stopped}");
+            if let Some(expected) = &result {
+                assert_eq!(&stopped, expected);
+            } else {
+                result = Some(stopped);
+            }
+        }
+        peer.close().await;
+        cancel.cancel();
+        tokio::time::timeout(Duration::from_secs(10), server)
+            .await
+            .unwrap()
+            .unwrap()
+            .unwrap();
+        drop(host);
+        let log = fixture.log().await;
+        let prefix = log.prefix(100, 128 * 1024).await.unwrap();
+        assert_eq!(
+            prefix.events.len(),
+            4,
+            "only one claim and one cancellation are added"
+        );
+        assert_eq!(
+            serde_json::to_value(&prefix.events[..2]).unwrap(),
+            serde_json::to_value(&sealed.events).unwrap()
+        );
+        assert!(matches!(
+            &prefix.events[2].event.fact,
+            Fact::InvocationOpened {
+                input: InvocationInput::Handoff { .. },
+                ..
+            }
+        ));
+        assert!(
+            matches!(&prefix.events[3].event.fact, Fact::InvocationEnded {
+            outcome: InvocationOutcome::Cancelled { source }
+        } if source == "runtime_cancellation")
+        );
+        if let Some(expected) = &settled {
+            assert_eq!(&prefix.digest, expected);
+        } else {
+            settled = Some(prefix.digest);
+        }
+        log.close().await.unwrap();
+    }
+}
diff --git a/crates/runtime-host/tests/integration/main.rs b/crates/runtime-host/tests/integration/main.rs
index 96ce6c3248..e0a08bac8b 100644
--- a/crates/runtime-host/tests/integration/main.rs
+++ b/crates/runtime-host/tests/integration/main.rs
@@ -44,6 +44,7 @@ mod connection_multiplex;
 mod context_compaction;
 mod execution_boundary;
 mod execution_drain;
+mod handoff;
 mod host_drain;
 mod large_outputs;
 mod live_pipes;
diff --git a/crates/runtime/src/continuation.rs b/crates/runtime/src/continuation.rs
index 4458d92912..2eb50fb1ac 100644
--- a/crates/runtime/src/continuation.rs
+++ b/crates/runtime/src/continuation.rs
@@ -63,6 +63,18 @@ pub const MAX_ANCESTRY: usize = 64;
 pub const MAX_SOURCE_EVENTS: usize = 10_000;
 pub const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024;
 
+impl ReplayEvidence {
+    pub(crate) fn validate(&self) -> Result<(), &'static str> {
+        if self.version != REPLAY_VERSION
+            || !valid_projection_digest(&self.digest)
+            || !valid_projection_digest(&self.route_identity)
+        {
+            return Err("invalid provider replay evidence");
+        }
+        Ok(())
+    }
+}
+
 impl ContinuationClaim {
     pub fn validate(&self, target: &Invocation) -> Result<(), &'static str> {
         self.validate_boundary(target)?;
@@ -96,12 +108,9 @@ impl ContinuationClaim {
             || self.base.high_water >= i64::MAX as u64
             || !valid_projection_digest(&self.source.digest)
             || !valid_projection_digest(&self.base.digest)
-            || self.replay.version != REPLAY_VERSION
-            || !valid_projection_digest(&self.replay.digest)
-            || !valid_projection_digest(&self.replay.route_identity)
         {
             return Err("invalid continuation boundary or replay evidence");
         }
-        Ok(())
+        self.replay.validate()
     }
 }
diff --git a/crates/runtime/src/event.rs b/crates/runtime/src/event.rs
index 8078cd9332..a99e570c87 100644
--- a/crates/runtime/src/event.rs
+++ b/crates/runtime/src/event.rs
@@ -61,6 +61,25 @@ pub enum InvocationOutcome {
     },
 }
 
+#[derive(Clone, Debug)]
+pub enum CancellationCause {
+    Runtime,
+    WorkhubStop { action_id: crate::workhub::ActionId },
+    WorkhubCorrection { action_id: crate::workhub::ActionId },
+}
+
+impl CancellationCause {
+    pub fn source(&self) -> String {
+        match self {
+            Self::Runtime => "runtime_cancellation".into(),
+            Self::WorkhubStop { action_id } => crate::workhub::stop_abort_source(action_id),
+            Self::WorkhubCorrection { action_id } => {
+                crate::workhub::correction_abort_source(action_id)
+            }
+        }
+    }
+}
+
 impl InvocationOutcome {
     pub fn status(&self) -> TerminalStatus {
         match self {
diff --git a/crates/runtime/src/handoff.rs b/crates/runtime/src/handoff.rs
index f4a5d6e6b2..82286b0f10 100644
--- a/crates/runtime/src/handoff.rs
+++ b/crates/runtime/src/handoff.rs
@@ -88,7 +88,7 @@ impl HandoffPause {
         claim.validate_boundary(target)?;
         self.validate(&claim.source.invocation)?;
         if claim.id != self.intent.claim_id
-            || claim.replay.route_identity != self.execution.route_identity
+            || claim.replay != self.execution.replay
             || self
                 .execution
                 .replay_base
diff --git a/crates/runtime/src/handoff/execution.rs b/crates/runtime/src/handoff/execution.rs
index db3a5adf4c..6ee5c3cfd8 100644
--- a/crates/runtime/src/handoff/execution.rs
+++ b/crates/runtime/src/handoff/execution.rs
@@ -26,7 +26,8 @@ use std::collections::BTreeSet;
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(deny_unknown_fields)]
 pub struct HandoffExecution {
-    pub route_identity: String,
+    /// Admission projection, before any successor-side automatic compaction.
+    pub replay: crate::continuation::ReplayEvidence,
     pub context: Option<ModelRequestContext>,
     /// Provider-specific options intentionally retain their extensible JSON shape.
     pub provider_options: Value,
@@ -50,8 +51,8 @@ pub struct HandoffTools {
 
 impl HandoffExecution {
     pub(super) fn validate(&self) -> Result<(), &'static str> {
-        if !valid_projection_digest(&self.route_identity)
-            || !valid_projection_digest(&self.tools.catalog_digest)
+        self.replay.validate()?;
+        if !valid_projection_digest(&self.tools.catalog_digest)
             || self
                 .main_output_limit
                 .is_some_and(|n| n == 0 || n > 10_000_000_000)
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 120d44f161..5adc1f12e6 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -70,6 +70,7 @@ Do not run untrusted code or point test instances at existing user data.
 - A State Root has one writer and execution authority. Session, Turn, Run and
   invocation identities remain distinct. Cooperative continuation preserves the
   public Run identity; claims and cleanup still address exact physical Runs.
+  Sealed work can be cancelled without loading a provider or repeating effects.
 - Host diagnostics and retirement share active-work accounting, including pending
   OAuth authorization. Retirement targets an exact Host epoch and retains authority
   through response flush and resource cleanup. Cooperative step handoff is not yet
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 050ac03d02..1b31aeb5c6 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -62,6 +62,7 @@ Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 每个 State Root 只有一个写入与执行 authority;Session、Turn、Run、invocation
   身份保持独立。协作续接保留对外 Run 身份;恢复凭证与清理仍绑定精确物理 Run。
+  已封口的任务可直接取消,无需加载 provider,也不重做副作用。
 - Host 诊断与退出共用活动工作统计,包含等待授权的 OAuth 登录。退出绑定精确 Host epoch,
   回复发送与资源清理完成前不释放 authority。尚未公布协作步骤交接能力;旧协议字段
   `nodeVersion` 如实返回 `not applicable (Rust)`。


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 17/29: fix(desktop): preserve candidate ownership when drain observation expires

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 65801518123694f3f87a35f71979222d31fb18f9
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 17:56:34 2026 +0800

    fix(desktop): preserve candidate ownership when drain observation expires
    
    Generated-by: OpenAI Codex
---
 apps/desktop/src/main/native-runtime-host.ts          | 12 ++++++++----
 apps/desktop/src/main/runtime-host-desktop-manager.ts |  4 ++--
 2 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/apps/desktop/src/main/native-runtime-host.ts b/apps/desktop/src/main/native-runtime-host.ts
index 9c087341a6..65eeaf13cb 100644
--- a/apps/desktop/src/main/native-runtime-host.ts
+++ b/apps/desktop/src/main/native-runtime-host.ts
@@ -141,10 +141,14 @@ function launchCandidate(executable: string, input: Parameters<Launch>[0]): Retu
         async settle(timeoutMs) {
           child.stdin.end();
           const result = await within(exited, timeoutMs);
-          if (result) return result.code === 0 && result.signal === null;
-          child.kill('SIGKILL');
-          await within(exited, timeoutMs);
-          return false;
+          // Recovery may start committed work before discovery becomes visible.
+          // EOF requests drain; a deadline cannot authorize killing its effects.
+          if (result === undefined) {
+            // The barrier must retain this attempt and keep the current owner
+            // running until the candidate can no longer become a late winner.
+            throw new Error('Native Runtime Host candidate has not finished draining');
+          }
+          return result.code === 0 && result.signal === null;
         },
       };
     }),
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index ffa9837eef..8bc05d2f90 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -1613,8 +1613,8 @@ function isSessionGuestProfile(
 }
 
 async function waitForProcessExit(pid: number): Promise<void> {
-  // The Host owns a 10-second graceful-shutdown deadline. Keep a separate
-  // observation margin so Desktop cannot race the Host's final process.exit.
+  // Bound Desktop's observation, not Host cleanup. Timeout is not permission
+  // to kill the process or treat its writer authority as released.
   const deadline = Date.now() + 12_000;
   while (isProcessAlive(pid)) {
     if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before retirement');


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 19/29: feat(host): preserve native Windows background lifetimes

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit bc18d173b034079ac0ed811c161820f032f7cb58
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 19:26:41 2026 +0800

    feat(host): preserve native Windows background lifetimes
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                  |   2 +
 apps/desktop/electron-builder.config.mjs    |   3 +
 crates/cli/Cargo.toml                       |  11 ++
 crates/cli/src/args.rs                      |  21 +++-
 crates/cli/src/deployment.rs                |   2 +-
 crates/cli/src/deployment/activation.rs     |  62 +++++------
 crates/cli/src/deployment/package.rs        |  18 ++++
 crates/cli/src/deployment/update.rs         |   2 +-
 crates/cli/src/main.rs                      |   9 +-
 crates/cli/src/serve.rs                     |   8 +-
 crates/cli/src/{main.rs => service_main.rs} |  21 +---
 crates/cli/src/windows.rs                   | 157 ++++++++++++++++++++++++++++
 crates/cli/tests/integration/deployment.rs  |  53 ++++++++++
 crates/process/src/detached.rs              |  50 +++++++++
 crates/process/src/detached/windows.rs      | 116 ++++++++++++++++++++
 crates/process/src/lib.rs                   |   1 +
 crates/process/src/windows.rs               |  30 ++++--
 crates/process/src/windows/attributes.rs    |  92 ++++++++++++++++
 crates/process/src/windows/spawn.rs         |  56 +---------
 docs/rust-runtime.md                        |   6 +-
 docs/rust-runtime.zh-CN.md                  |   4 +-
 21 files changed, 597 insertions(+), 127 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 9f103ffdba..a3988a7610 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3942,6 +3942,7 @@ dependencies = [
  "libc",
  "maka-event-log",
  "maka-js-runtime",
+ "maka-process",
  "maka-protocol",
  "maka-runtime",
  "maka-runtime-host",
@@ -3954,6 +3955,7 @@ dependencies = [
  "tokio",
  "tokio-util",
  "uuid",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs
index 001b31505d..7927055a10 100644
--- a/apps/desktop/electron-builder.config.mjs
+++ b/apps/desktop/electron-builder.config.mjs
@@ -59,6 +59,9 @@ async function stageReleaseManifests({ packager, arch }) {
   ));
   mkdirSync(nativeStage, { recursive: true });
   copyFileSync(binary, join(nativeStage, basename(binary)));
+  if (packager.platform.nodeName === 'win32') {
+    copyFileSync(join(dirname(binary), 'maka-service.exe'), join(nativeStage, 'maka-service.exe'));
+  }
   const stage = await packager.info.tempDirManager.createTempDir({
     prefix: 'maka-release-manifests',
   });
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 83e7764f66..1775df4e1b 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -18,6 +18,7 @@
 
 [package]
 name = "maka-cli"
+default-run = "maka"
 version.workspace = true
 edition.workspace = true
 rust-version.workspace = true
@@ -28,6 +29,12 @@ publish.workspace = true
 name = "maka"
 path = "src/main.rs"
 
+# Internal Windows service entry; the public console command remains `maka`.
+[[bin]]
+name = "maka-service"
+path = "src/service_main.rs"
+test = false
+
 [dependencies]
 base64 = "0.22"
 clap.workspace = true
@@ -37,6 +44,7 @@ maka-event-log.workspace = true
 maka-protocol.workspace = true
 maka-transport.workspace = true
 maka-js-runtime.workspace = true
+maka-process.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 sha2.workspace = true
@@ -51,3 +59,6 @@ tempfile.workspace = true
 
 [target.'cfg(unix)'.dependencies]
 libc.workspace = true
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 180b38a8bc..e2d1401650 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -93,6 +93,25 @@ struct Log {
 }
 
 impl Cli {
+    pub(super) async fn run_service(self) -> Result<(), HostError> {
+        match self.command {
+            Command::Host(HostCommand::Serve {
+                root,
+                websocket: None,
+            }) => {
+                #[cfg(windows)]
+                crate::windows::own_process_tree()?;
+                serve::run(&root.root, None, true).await
+            }
+            Command::Host(HostCommand::Serve { .. }) => {
+                Err("service listener belongs to deployment configuration".into())
+            }
+            // The installed artifact also remains a usable captured-stdio
+            // operator for SSH/WSL; the interactive console command is maka.
+            command => Self { command }.run().await,
+        }
+    }
+
     pub(super) fn error_exit_code(&self) -> i32 {
         if matches!(self.command, Command::Host(HostCommand::Candidate(_))) {
             70
@@ -114,7 +133,7 @@ impl Cli {
                 Ok(())
             }
             Command::Host(HostCommand::Serve { root, websocket }) => {
-                serve::run(&root.root, websocket).await
+                serve::run(&root.root, websocket, false).await
             }
             Command::Host(HostCommand::Status(root)) => {
                 let mut client = crate::host_client::HostClient::connect(&root.root, None).await?;
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index ed4aae3f44..fdf4580619 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -152,7 +152,7 @@ impl Install {
         let stage_lease = lease.clone();
         let (executable, sha256) = tokio::task::spawn_blocking(move || {
             stage_lease.validate()?;
-            let package = package::stage(&stage_directory, &std::env::current_exe()?)?;
+            let package = package::stage(&stage_directory, &package::source(self.mode)?)?;
             stage_lease.validate()?;
             Ok::<_, HostError>(package)
         })
diff --git a/crates/cli/src/deployment/activation.rs b/crates/cli/src/deployment/activation.rs
index 5cc2b289c9..19d7973e60 100644
--- a/crates/cli/src/deployment/activation.rs
+++ b/crates/cli/src/deployment/activation.rs
@@ -22,18 +22,15 @@ use crate::host_client::{HostClient, LiveHost};
 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
 use clap::Args;
 use maka_event_log::root::{self, FileLease, RootNamespaces, RootOwner};
+use maka_process::detached::{self, Child};
 use maka_runtime_host::server::HostError;
 use serde::Serialize;
 use std::{
     io::Write,
     num::{NonZeroU16, NonZeroU32},
-    process::Stdio,
     time::Duration,
 };
-use tokio::{
-    io::AsyncWriteExt,
-    process::{Child, Command},
-};
+use tokio::io::AsyncWriteExt;
 use uuid::Uuid;
 
 #[derive(Args)]
@@ -173,39 +170,28 @@ pub(super) async fn connect_or_launch(
                             return Err("State Root changed before launch".into());
                         }
                         drop(owner);
-                        let mut command = Command::new(&deployment.executable);
-                        command
-                            .args(["host", "candidate", "--root"])
-                            .arg(&deployment.root_path)
-                            .args([
-                                "--expected-root-id",
-                                &deployment.root_id,
-                                "--startup-attempt-id",
-                                &Uuid::new_v4().to_string(),
-                                "--owner-stdin",
-                                "--initial-connection-timeout-ms",
-                                "30000",
-                            ])
-                            .stdin(Stdio::piped())
-                            .stdout(Stdio::null())
-                            // No inherited SSH capture pipes may outlive the operator.
-                            .stderr(Stdio::null());
-                        #[cfg(unix)]
-                        {
-                            // The SSH operator may exit while its released Host continues.
-                            // SAFETY: setsid is async-signal-safe and touches no Rust state.
-                            unsafe {
-                                command.pre_exec(|| {
-                                    if libc::setsid() == -1 {
-                                        return Err(std::io::Error::last_os_error());
-                                    }
-                                    Ok(())
-                                });
-                            }
-                        }
-                        #[cfg(windows)]
-                        command.creation_flags(0x00000008 | 0x00000200); // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
-                        child = Some(command.spawn()?);
+                        child = Some(
+                            detached::spawn(
+                                &deployment.executable,
+                                &[
+                                    "host",
+                                    "candidate",
+                                    "--root",
+                                    deployment
+                                        .root_path
+                                        .to_str()
+                                        .ok_or("State Root must be UTF-8")?,
+                                    "--expected-root-id",
+                                    &deployment.root_id,
+                                    "--startup-attempt-id",
+                                    &Uuid::new_v4().to_string(),
+                                    "--owner-stdin",
+                                    "--initial-connection-timeout-ms",
+                                    "30000",
+                                ],
+                            )
+                            .await?,
+                        );
                     }
                     Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
                     Err(error) => return Err(error.into()),
diff --git a/crates/cli/src/deployment/package.rs b/crates/cli/src/deployment/package.rs
index 330eb2bb93..0bcca300f7 100644
--- a/crates/cli/src/deployment/package.rs
+++ b/crates/cli/src/deployment/package.rs
@@ -32,6 +32,24 @@ pub(super) fn path(directory: &Path, digest: &str) -> PathBuf {
         .join(format!("maka-{digest}{suffix}"))
 }
 
+pub(super) fn source(mode: super::Mode) -> Result<PathBuf, HostError> {
+    let current = std::env::current_exe()?.canonicalize()?;
+    #[cfg(windows)]
+    if mode == super::Mode::Supervised && env!("CARGO_BIN_NAME") != "maka-service" {
+        let service = current
+            .parent()
+            .ok_or("executable directory is missing")?
+            .join("maka-service.exe");
+        if !service.is_file() {
+            return Err("Windows services require the sibling maka-service.exe artifact".into());
+        }
+        return Ok(service);
+    }
+    #[cfg(not(windows))]
+    let _ = mode;
+    Ok(current)
+}
+
 pub(super) fn stage(directory: &Path, source: &Path) -> Result<(PathBuf, String), HostError> {
     let packages = directory.join("packages");
     maka_event_log::root::private_directory(&packages)?;
diff --git a/crates/cli/src/deployment/update.rs b/crates/cli/src/deployment/update.rs
index 14c1a6f479..6f7b22f628 100644
--- a/crates/cli/src/deployment/update.rs
+++ b/crates/cli/src/deployment/update.rs
@@ -80,7 +80,7 @@ impl Update {
         let target = if reconcile {
             pending
         } else {
-            let source = std::env::current_exe()?.canonicalize()?;
+            let source = package::source(current.mode)?;
             let (executable, sha256) = if source == current.executable {
                 (source, current.sha256.clone())
             } else {
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 733f408bb5..26508f5680 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -26,12 +26,19 @@ mod endpoint;
 mod host_client;
 mod serve;
 mod signals;
+#[cfg(windows)]
+mod windows;
 
 #[tokio::main]
 async fn main() {
     let cli = args::Cli::parse();
     let error_exit = cli.error_exit_code();
-    if let Err(error) = cli.run().await {
+    let result = if env!("CARGO_BIN_NAME") == "maka-service" {
+        cli.run_service().await
+    } else {
+        cli.run().await
+    };
+    if let Err(error) = result {
         eprintln!("{error}");
         std::process::exit(error_exit);
     }
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index 4a0dbca7be..c2afd197ea 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -24,16 +24,22 @@ use tokio_util::sync::CancellationToken;
 pub(super) async fn run(
     path: &Path,
     websocket: Option<std::net::SocketAddr>,
+    require_managed: bool,
 ) -> Result<(), maka_runtime_host::server::HostError> {
     use maka_event_log::root::{ROOT_MARKER, RootNamespaces, RootOwner};
     use maka_runtime_host::server::{Host, websocket::WebSocketListener};
     let namespaces = RootNamespaces::for_current_account()?;
     let owner = if path.join(ROOT_MARKER).exists() {
         RootOwner::open(path, &namespaces)?
-    } else {
+    } else if !require_managed {
         RootOwner::create(path, &namespaces)?
+    } else {
+        return Err("service State Root must already be installed".into());
     };
     let deployment = crate::deployment::admit(&owner, crate::deployment::Mode::Supervised).await?;
+    if require_managed && deployment.is_none() {
+        return Err("service State Root must have a supervised deployment".into());
+    }
     let websocket = match deployment
         .as_ref()
         .map(|deployment| deployment.websocket)
diff --git a/crates/cli/src/main.rs b/crates/cli/src/service_main.rs
similarity index 69%
copy from crates/cli/src/main.rs
copy to crates/cli/src/service_main.rs
index 733f408bb5..19bb61454d 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/service_main.rs
@@ -17,22 +17,7 @@
  * under the License.
  */
 
-use clap::Parser;
-mod args;
-mod candidate;
-mod code;
-mod deployment;
-mod endpoint;
-mod host_client;
-mod serve;
-mod signals;
+#![cfg_attr(windows, windows_subsystem = "windows")]
 
-#[tokio::main]
-async fn main() {
-    let cli = args::Cli::parse();
-    let error_exit = cli.error_exit_code();
-    if let Err(error) = cli.run().await {
-        eprintln!("{error}");
-        std::process::exit(error_exit);
-    }
-}
+// Same Host implementation and admission gate, with a windowless PE entry.
+include!("main.rs");
diff --git a/crates/cli/src/windows.rs b/crates/cli/src/windows.rs
new file mode 100644
index 0000000000..fd64ccb6d6
--- /dev/null
+++ b/crates/cli/src/windows.rs
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::{
+    io,
+    mem::size_of,
+    os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle},
+    ptr,
+    sync::OnceLock,
+};
+use windows_sys::Win32::System::{
+    JobObjects::{
+        AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+        JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
+        SetInformationJobObject,
+    },
+    Threading::GetCurrentProcess,
+};
+
+// Statics are not dropped by Rust at shutdown. The OS closes this handle when
+// the Host exits, terminating descendants without killing the Host during Drop.
+static OWNER: OnceLock<io::Result<OwnedHandle>> = OnceLock::new();
+
+pub(super) fn own_process_tree() -> io::Result<()> {
+    OWNER
+        .get_or_init(create)
+        .as_ref()
+        .map(|_| ())
+        .map_err(|error| io::Error::new(error.kind(), error.to_string()))
+}
+
+fn create() -> io::Result<OwnedHandle> {
+    // SAFETY: an anonymous non-inheritable Job; every native argument is either
+    // a live owned handle or correctly sized initialized input.
+    unsafe {
+        let handle = CreateJobObjectW(ptr::null(), ptr::null());
+        if handle.is_null() {
+            return Err(io::Error::last_os_error());
+        }
+        let job = OwnedHandle::from_raw_handle(handle);
+        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
+        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+        if SetInformationJobObject(
+            job.as_raw_handle(),
+            JobObjectExtendedLimitInformation,
+            (&raw const limits).cast(),
+            size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
+        ) == 0
+            || AssignProcessToJobObject(job.as_raw_handle(), GetCurrentProcess()) == 0
+        {
+            return Err(io::Error::last_os_error());
+        }
+        Ok(job)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::{
+        io::{BufRead, Read, Write},
+        os::windows::process::CommandExt,
+        process::{Command, Stdio},
+    };
+    use windows_sys::Win32::{
+        Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT},
+        System::Threading::{
+            CREATE_NO_WINDOW, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
+            TerminateProcess, WaitForSingleObject,
+        },
+    };
+
+    #[test]
+    fn process_exit_reaps_descendants_without_changing_success_status() {
+        const CHILD: &str = "MAKA_SERVICE_JOB_TEST_CHILD";
+        if std::env::var_os(CHILD).is_some() {
+            own_process_tree().unwrap();
+            #[expect(
+                clippy::zombie_processes,
+                reason = "the descendant must outlive this process to verify Job cleanup"
+            )]
+            let child = Command::new("ping.exe")
+                .args(["-n", "300", "127.0.0.1"])
+                .creation_flags(CREATE_NO_WINDOW)
+                .stdin(Stdio::null())
+                .stdout(Stdio::null())
+                .stderr(Stdio::null())
+                .spawn()
+                .unwrap();
+            println!("MAKA_JOB_PID={}", child.id());
+            io::stdout().flush().unwrap();
+            // The parent captures a handle before exit, so PID reuse cannot
+            // turn the descendant cleanup assertion into a false success.
+            io::stdin().read_exact(&mut [0]).unwrap();
+            return;
+        }
+        let mut host = Command::new(std::env::current_exe().unwrap())
+            .args([
+                "--exact",
+                "windows::tests::process_exit_reaps_descendants_without_changing_success_status",
+                "--nocapture",
+            ])
+            .env(CHILD, "1")
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .spawn()
+            .unwrap();
+        let mut output = io::BufReader::new(host.stdout.take().unwrap());
+        let pid = output
+            .by_ref()
+            .lines()
+            .map(Result::unwrap)
+            .find_map(|line| {
+                line.split_once("MAKA_JOB_PID=")
+                    .map(|(_, pid)| pid.parse::<u32>().unwrap())
+            })
+            .expect("child did not publish its descendant");
+        // SAFETY: this is the exact test descendant, held through verification
+        // and failure cleanup so a recycled PID can never be terminated.
+        let descendant = unsafe {
+            let handle = OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, 0, pid);
+            assert!(!handle.is_null(), "{}", io::Error::last_os_error());
+            OwnedHandle::from_raw_handle(handle)
+        };
+        assert_eq!(
+            unsafe { WaitForSingleObject(descendant.as_raw_handle(), 0) },
+            WAIT_TIMEOUT
+        );
+        host.stdin.take().unwrap().write_all(&[1]).unwrap();
+        assert!(host.wait().unwrap().success());
+        let ended = unsafe { WaitForSingleObject(descendant.as_raw_handle(), 5000) };
+        if ended != WAIT_OBJECT_0 {
+            // A failing cleanup test must not itself leave its process running.
+            unsafe {
+                TerminateProcess(descendant.as_raw_handle(), 1);
+                WaitForSingleObject(descendant.as_raw_handle(), 5000);
+            }
+        }
+        assert_eq!(ended, WAIT_OBJECT_0);
+    }
+}
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index d7dd613903..0475a7280a 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -190,6 +190,22 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
             assert_eq!(decode_activation(&activated.stdout)["kind"], "error");
             continue;
         }
+        #[cfg(windows)]
+        if requires_containment() {
+            // Cargo's own Job forbids independent children. Refusal must not
+            // report Ready for a Host that will disappear with the launcher.
+            assert!(!activated.status.success(), "{activated:?}");
+            let error = decode_activation(&activated.stdout);
+            assert_eq!(error["kind"], "error");
+            assert!(
+                error["error"]["message"]
+                    .as_str()
+                    .unwrap()
+                    .ends_with("(os error 5)")
+            );
+            drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
+            continue;
+        }
         assert!(activated.status.success(), "{activated:?}");
         assert!(
             started.elapsed() < Duration::from_secs(15),
@@ -228,3 +244,40 @@ fn decode_activation(bytes: &[u8]) -> Value {
         .unwrap();
     serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()).unwrap()
 }
+
+#[cfg(windows)]
+fn requires_containment() -> bool {
+    use windows_sys::Win32::System::{
+        JobObjects::{
+            IsProcessInJob, JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK,
+            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
+            QueryInformationJobObject,
+        },
+        Threading::GetCurrentProcess,
+    };
+    let mut member = 0;
+    let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
+    // SAFETY: queries only this test's enclosing Job; never changes its policy.
+    unsafe {
+        assert_ne!(
+            IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &mut member),
+            0
+        );
+        if member == 0 {
+            return false;
+        }
+        assert_ne!(
+            QueryInformationJobObject(
+                std::ptr::null_mut(),
+                JobObjectExtendedLimitInformation,
+                (&raw mut limits).cast(),
+                std::mem::size_of_val(&limits) as u32,
+                std::ptr::null_mut(),
+            ),
+            0
+        );
+    }
+    limits.BasicLimitInformation.LimitFlags
+        & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)
+        == 0
+}
diff --git a/crates/process/src/detached.rs b/crates/process/src/detached.rs
new file mode 100644
index 0000000000..33cf6c9ad8
--- /dev/null
+++ b/crates/process/src/detached.rs
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Independent background processes with a single launcher-owned stdin pipe.
+//! Closing stdin, including on drop, lets the application's protocol request drain.
+
+#[cfg(windows)]
+#[path = "detached/windows.rs"]
+mod platform;
+#[cfg(windows)]
+pub use platform::{Child, spawn};
+#[cfg(unix)]
+pub use tokio::process::Child;
+
+#[cfg(unix)]
+pub async fn spawn(executable: &std::path::Path, args: &[&str]) -> std::io::Result<Child> {
+    use std::process::Stdio;
+    let mut command = tokio::process::Command::new(executable);
+    command
+        .args(args)
+        .stdin(Stdio::piped())
+        .stdout(Stdio::null())
+        .stderr(Stdio::null());
+    // SAFETY: setsid is async-signal-safe and touches no Rust state.
+    unsafe {
+        command.pre_exec(|| {
+            if libc::setsid() == -1 {
+                return Err(std::io::Error::last_os_error());
+            }
+            Ok(())
+        });
+    }
+    command.spawn()
+}
diff --git a/crates/process/src/detached/windows.rs b/crates/process/src/detached/windows.rs
new file mode 100644
index 0000000000..7e193d7708
--- /dev/null
+++ b/crates/process/src/detached/windows.rs
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{
+    shell::command::{quote, wide},
+    windows::{attributes::Attributes, checked, owned, pipe, try_wait_process, wait_process},
+};
+use std::{
+    fs::OpenOptions,
+    io,
+    mem::size_of,
+    os::windows::io::{AsRawHandle, OwnedHandle},
+    path::Path,
+    process::ExitStatus,
+    ptr,
+};
+use tokio::net::windows::named_pipe::NamedPipeServer;
+use windows_sys::Win32::{
+    Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation},
+    System::Threading::*,
+};
+
+pub struct Child {
+    process: OwnedHandle,
+    pub stdin: Option<NamedPipeServer>,
+}
+
+impl Child {
+    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
+        try_wait_process(&self.process)
+    }
+
+    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
+        drop(self.stdin.take());
+        wait_process(&self.process).await
+    }
+}
+
+pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
+    if !executable.is_absolute() {
+        return Err(io::Error::other("detached executable must be absolute"));
+    }
+    let path = executable
+        .to_str()
+        .ok_or_else(|| io::Error::other("executable must be UTF-8"))?;
+    let application = wide(executable)?;
+    let arguments = std::iter::once(path)
+        .chain(args.iter().copied())
+        .map(quote)
+        .collect::<Vec<_>>()
+        .join(" ");
+    let mut line = wide(Path::new(&arguments))?;
+    let (stdin, reader) = pipe::pty_input().await?;
+    let null = OpenOptions::new().read(true).write(true).open("NUL")?;
+    let handles = [reader.as_raw_handle(), null.as_raw_handle()];
+    // SAFETY: owned pipe/NUL handles, inherited only through the explicit list.
+    for handle in handles {
+        unsafe {
+            checked(SetHandleInformation(
+                handle,
+                HANDLE_FLAG_INHERIT,
+                HANDLE_FLAG_INHERIT,
+            ))?;
+        }
+    }
+    let mut attributes = Attributes::stdio(&handles, &[])?;
+    let mut startup = STARTUPINFOEXW::default();
+    startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
+    startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
+    startup.StartupInfo.hStdInput = handles[0];
+    startup.StartupInfo.hStdOutput = handles[1];
+    startup.StartupInfo.hStdError = handles[1];
+    startup.lpAttributeList = attributes.as_ptr();
+    let mut process = PROCESS_INFORMATION::default();
+    // SAFETY: all buffers/handles outlive this call; environment and cwd inherit.
+    // Breakaway denied by an enclosing Job is an error, never a coupled fallback.
+    unsafe {
+        checked(CreateProcessW(
+            application.as_ptr(),
+            line.as_mut_ptr(),
+            ptr::null(),
+            ptr::null(),
+            1,
+            EXTENDED_STARTUPINFO_PRESENT
+                | DETACHED_PROCESS
+                | CREATE_NEW_PROCESS_GROUP
+                | CREATE_BREAKAWAY_FROM_JOB,
+            ptr::null(),
+            ptr::null(),
+            &startup.StartupInfo,
+            &mut process,
+        ))?;
+    }
+    let process_handle = owned(process.hProcess)?;
+    let _thread = owned(process.hThread)?;
+    Ok(Child {
+        process: process_handle,
+        stdin: Some(stdin),
+    })
+}
diff --git a/crates/process/src/lib.rs b/crates/process/src/lib.rs
index d2263dabbf..d3a655eb13 100644
--- a/crates/process/src/lib.rs
+++ b/crates/process/src/lib.rs
@@ -20,6 +20,7 @@
 //! Trusted native process primitives and foreground execution, not a sandbox.
 //! Durable background/PTY resource ownership belongs to the runtime.
 
+pub mod detached;
 mod output;
 pub mod system;
 pub use output::{Captured as PipeResult, Outcome as ProcessOutcome, Termination};
diff --git a/crates/process/src/windows.rs b/crates/process/src/windows.rs
index e5670d5573..cdc98c8bfa 100644
--- a/crates/process/src/windows.rs
+++ b/crates/process/src/windows.rs
@@ -41,6 +41,7 @@ use windows_sys::Win32::{
     System::Threading::{GetExitCodeProcess, WaitForSingleObject},
 };
 
+pub(crate) mod attributes;
 pub(crate) mod job;
 pub(crate) mod pipe;
 mod spawn;
@@ -90,19 +91,26 @@ pub(crate) async fn run(
 
 pub(crate) async fn wait_process(process: &OwnedHandle) -> io::Result<ExitStatus> {
     loop {
-        // SAFETY: the owned handle pins the exact process throughout this wait.
-        match unsafe { WaitForSingleObject(process.as_raw_handle(), 0) } {
-            WAIT_OBJECT_0 => {
-                let mut code = 0;
-                // SAFETY: signalled process and valid output pointer.
-                unsafe {
-                    checked(GetExitCodeProcess(process.as_raw_handle(), &mut code))?;
-                }
-                return Ok(ExitStatus::from_raw(code));
+        if let Some(status) = try_wait_process(process)? {
+            return Ok(status);
+        }
+        tokio::time::sleep(Duration::from_millis(10)).await;
+    }
+}
+
+pub(crate) fn try_wait_process(process: &OwnedHandle) -> io::Result<Option<ExitStatus>> {
+    // SAFETY: the owned handle pins the exact process throughout this wait.
+    match unsafe { WaitForSingleObject(process.as_raw_handle(), 0) } {
+        WAIT_OBJECT_0 => {
+            let mut code = 0;
+            // SAFETY: signalled process and valid output pointer.
+            unsafe {
+                checked(GetExitCodeProcess(process.as_raw_handle(), &mut code))?;
             }
-            WAIT_TIMEOUT => tokio::time::sleep(Duration::from_millis(10)).await,
-            _ => return Err(io::Error::last_os_error()),
+            Ok(Some(ExitStatus::from_raw(code)))
         }
+        WAIT_TIMEOUT => Ok(None),
+        _ => Err(io::Error::last_os_error()),
     }
 }
 
diff --git a/crates/process/src/windows/attributes.rs b/crates/process/src/windows/attributes.rs
new file mode 100644
index 0000000000..21a21f02a0
--- /dev/null
+++ b/crates/process/src/windows/attributes.rs
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::checked;
+use std::{io, marker::PhantomData, mem::size_of, ptr};
+use windows_sys::Win32::{Foundation::HANDLE, System::Threading::*};
+
+/// Borrows the exact inheritance and Job lists through CreateProcess.
+pub(crate) struct Attributes<'a> {
+    storage: Vec<usize>,
+    _inputs: PhantomData<&'a [HANDLE]>,
+}
+
+impl<'a> Attributes<'a> {
+    pub fn stdio(handles: &'a [HANDLE], jobs: &'a [HANDLE]) -> io::Result<Self> {
+        let count = 1 + u32::from(!jobs.is_empty());
+        let mut bytes = 0;
+        // SAFETY: documented size query; then aligned, sufficiently sized storage.
+        unsafe {
+            InitializeProcThreadAttributeList(ptr::null_mut(), count, 0, &mut bytes);
+        }
+        if bytes == 0 {
+            return Err(io::Error::last_os_error());
+        }
+        let mut storage = vec![0usize; bytes.div_ceil(size_of::<usize>())];
+        unsafe {
+            checked(InitializeProcThreadAttributeList(
+                storage.as_mut_ptr().cast(),
+                count,
+                0,
+                &mut bytes,
+            ))?;
+        }
+        let mut attributes = Self {
+            storage,
+            _inputs: PhantomData,
+        };
+        // SAFETY: caller-owned arrays are borrowed for the lifetime of the list.
+        unsafe {
+            checked(UpdateProcThreadAttribute(
+                attributes.as_ptr(),
+                0,
+                PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
+                handles.as_ptr().cast(),
+                size_of_val(handles),
+                ptr::null_mut(),
+                ptr::null(),
+            ))?;
+            if !jobs.is_empty() {
+                checked(UpdateProcThreadAttribute(
+                    attributes.as_ptr(),
+                    0,
+                    PROC_THREAD_ATTRIBUTE_JOB_LIST as usize,
+                    jobs.as_ptr().cast(),
+                    size_of_val(jobs),
+                    ptr::null_mut(),
+                    ptr::null(),
+                ))?;
+            }
+        }
+        Ok(attributes)
+    }
+
+    pub fn as_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST {
+        self.storage.as_mut_ptr().cast()
+    }
+}
+
+impl Drop for Attributes<'_> {
+    fn drop(&mut self) {
+        // SAFETY: only successfully initialized lists construct this owner.
+        unsafe {
+            DeleteProcThreadAttributeList(self.as_ptr());
+        }
+    }
+}
diff --git a/crates/process/src/windows/spawn.rs b/crates/process/src/windows/spawn.rs
index 00a81866fa..7dcdae34e6 100644
--- a/crates/process/src/windows/spawn.rs
+++ b/crates/process/src/windows/spawn.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{checked, job::Job, owned, pipe};
+use super::{attributes::Attributes, checked, job::Job, owned, pipe};
 use crate::shell::{ShellPlan, command};
 use std::{
     fs::{File, OpenOptions},
@@ -43,16 +43,6 @@ pub(super) struct Spawned {
     pub stderr: NamedPipeServer,
 }
 
-struct Attributes(Vec<usize>);
-impl Drop for Attributes {
-    fn drop(&mut self) {
-        // SAFETY: only successfully initialized lists construct this owner.
-        unsafe {
-            DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast());
-        }
-    }
-}
-
 pub(super) async fn spawn(
     shell: &ShellPlan,
     cwd: &Path,
@@ -79,54 +69,14 @@ pub(super) async fn spawn(
         stdout_writer.as_raw_handle(),
         stderr_writer.as_raw_handle(),
     ];
-    let mut bytes = 0;
-    // SAFETY: documented size query, without a supplied list.
-    unsafe {
-        InitializeProcThreadAttributeList(ptr::null_mut(), 2, 0, &mut bytes);
-    }
-    if bytes == 0 {
-        return Err(io::Error::last_os_error());
-    }
-    let mut storage = vec![0usize; bytes.div_ceil(size_of::<usize>())];
-    // SAFETY: usize storage provides sufficient pointer alignment and size.
-    unsafe {
-        checked(InitializeProcThreadAttributeList(
-            storage.as_mut_ptr().cast(),
-            2,
-            0,
-            &mut bytes,
-        ))?;
-    }
-    let mut attributes = Attributes(storage);
-    // SAFETY: both arrays outlive the list and CreateProcess call. Only the three
-    // stdio handles can be inherited; the Job itself is not inheritable.
-    unsafe {
-        checked(UpdateProcThreadAttribute(
-            attributes.0.as_mut_ptr().cast(),
-            0,
-            PROC_THREAD_ATTRIBUTE_JOB_LIST as usize,
-            jobs.as_ptr().cast(),
-            size_of_val(&jobs),
-            ptr::null_mut(),
-            ptr::null(),
-        ))?;
-        checked(UpdateProcThreadAttribute(
-            attributes.0.as_mut_ptr().cast(),
-            0,
-            PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
-            handles.as_ptr().cast(),
-            size_of_val(&handles),
-            ptr::null_mut(),
-            ptr::null(),
-        ))?;
-    }
+    let mut attributes = Attributes::stdio(&handles, &jobs)?;
     let mut startup = STARTUPINFOEXW::default();
     startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
     startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
     startup.StartupInfo.hStdInput = handles[0];
     startup.StartupInfo.hStdOutput = handles[1];
     startup.StartupInfo.hStdError = handles[2];
-    startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
+    startup.lpAttributeList = attributes.as_ptr();
     let mut process = PROCESS_INFORMATION::default();
     if cancellation.is_cancelled() {
         return Err(io::Error::new(
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 85fa021407..6de921fc26 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -60,6 +60,10 @@ Host or starts the pinned on-demand executable; supervised mode requires a runni
 service. Installation does not yet register OS services or automate updates.
 Deployment authority lives in account-level
 SQLite, outside the State Root; startup checks it before database migrations.
+On Windows, supervised installations use the sibling `maka-service.exe`, a
+windowless entry to the same Host. Distribute it alongside `maka.exe`.
+On-demand activation requires permission to leave the launcher's Windows Job;
+run the built executable directly, not through `cargo run`.
 
 For an on-demand code update, run the new binary with
 `host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`.
@@ -68,7 +72,7 @@ same identity arguments finishes the recorded update. A committed target is neve
 automatically rolled back, even if startup fails. Supervised updates and unattended
 release selection are not yet connected.
 
-The single binary also provides `host candidate` for Desktop-owned startup,
+The `maka` command also provides `host candidate` for Desktop-owned startup,
 `code --log <file>` for a JavaScript cell read from stdin, and
 `inspect --log <file>` for committed execution facts.
 
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index e5d76cbe76..d338797a51 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -53,13 +53,15 @@ Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要
 此后只有返回的 `executable` 可以启动该托管根。`host activate --root-id <rootId> --framed`
 复用就绪 Host,或启动固定的按需版本;supervised 模式要求服务已运行。安装尚不注册 OS 服务或自动更新。
 部署权威保存在 State Root 之外的账户级 SQLite 中,启动时先校验再执行数据库迁移。
+Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 的无窗口入口,须与 `maka.exe` 一起分发。
+按需激活要求启动环境允许脱离 Windows Job;应直接运行已构建的程序,不要通过 `cargo run` 激活。
 
 更新按需部署时,用新二进制运行
 `host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`。
 活跃客户端或不可交接任务会推迟切换;随后用相同身份参数运行 `host reconcile` 完成已记录的更新。
 目标一旦提交,即使启动失败也不自动回退。Supervised 更新和无人值守版本选择尚未接通。
 
-唯一二进制还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
+`maka` 命令还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
 `code --log <file>`,以及查看已提交执行事实的 `inspect --log <file>`。
 
 **没有 OS 沙箱。** 代码与工具使用当前用户的系统权限。不要执行不可信代码,也不要让测试


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 23/29: fix(build): align Windows CRT without build-time V8

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d3c4ea2a5e74d06384d4349cd0f441a35103e8ee
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 22:48:01 2026 +0800

    fix(build): align Windows CRT without build-time V8
    
    Generated-by: OpenAI Codex
---
 .cargo/config.toml                                 |   20 +
 LICENSE                                            |   28 +
 biome.jsonc                                        |    3 +
 crates/js-runtime/Cargo.toml                       |    5 -
 crates/js-runtime/build.rs                         |   29 +-
 crates/js-runtime/src/trusted/engine.rs            |   27 +
 .../third-party/deno-telemetry/telemetry.ts        | 1984 ++++++++++++++++++++
 .../js-runtime/third-party/deno-telemetry/util.ts  |   88 +
 docs/rust-runtime.md                               |    3 +
 docs/rust-runtime.zh-CN.md                         |    3 +
 scripts/asf-license-headers.mjs                    |    3 +
 scripts/rust/bundle-providers.mjs                  |   16 +-
 12 files changed, 2173 insertions(+), 36 deletions(-)

diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000000..86ae5cbed7
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Match the official rusty_v8 Windows archive and avoid a second CRT at runtime.
+[target.'cfg(all(windows, target_env = "msvc"))']
+rustflags = ["-C", "target-feature=+crt-static"]
diff --git a/LICENSE b/LICENSE
index c56b84302e..8bbdfa4aef 100644
--- a/LICENSE
+++ b/LICENSE
@@ -788,3 +788,31 @@ based on revision 624ccf794703e2d84e748fc3ef547d6191a8c0a4. Maka removes filesys
 shell, streaming and execution-service dependencies, adds bounded pure APIs,
 and excludes unsupported patch operations. Detailed provenance and modifications
 are recorded in crates/apply-patch/SOURCE.md.
+
+Deno telemetry TypeScript sources (unmodified)
+
+crates/js-runtime/third-party/deno-telemetry/{telemetry,util}.ts are copied from
+deno_telemetry 0.80.0, revision e518fbd66dda5debcbdefc0beb0b3756b37b64fa:
+https://github.com/denoland/deno/tree/e518fbd66dda5debcbdefc0beb0b3756b37b64fa/ext/telemetry
+They are transpiled during the build; tests compare them with the locked extension.
+
+MIT License
+
+Copyright 2018-2026 the Deno authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/biome.jsonc b/biome.jsonc
index 414347cdc4..2593c28e08 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -32,6 +32,9 @@
       "!**/dist",
       "!**/node_modules",
       "!package-lock.json",
+      // Verbatim Deno sources; tests verify their exact bytes.
+      "!crates/js-runtime/third-party/deno-telemetry/telemetry.ts",
+      "!crates/js-runtime/third-party/deno-telemetry/util.ts",
       // Generated shadcn/ui config — not hand-authored source.
       "!components.json"
     ]
diff --git a/crates/js-runtime/Cargo.toml b/crates/js-runtime/Cargo.toml
index ca1c6dbecf..14e75bb812 100644
--- a/crates/js-runtime/Cargo.toml
+++ b/crates/js-runtime/Cargo.toml
@@ -47,8 +47,3 @@ tokio-tungstenite.workspace = true
 
 [target.'cfg(unix)'.dev-dependencies]
 maka-process.workspace = true
-
-[build-dependencies]
-deno_core.workspace = true
-deno_telemetry = "=0.80.0"
-serde_json.workspace = true
diff --git a/crates/js-runtime/build.rs b/crates/js-runtime/build.rs
index e959380ecf..8a84f29632 100644
--- a/crates/js-runtime/build.rs
+++ b/crates/js-runtime/build.rs
@@ -49,7 +49,6 @@ fn main() {
 }
 
 fn bundle_providers() {
-    use std::io::Write;
     println!("cargo:rerun-if-changed=trusted/adapter.js");
     println!("cargo:rerun-if-changed=trusted/codex-auth.js");
     println!("cargo:rerun-if-changed=trusted/provider-errors.js");
@@ -58,32 +57,14 @@ fn bundle_providers() {
     println!("cargo:rerun-if-changed=trusted/responses-transport.js");
     println!("cargo:rerun-if-changed=trusted/network-fetch.js");
     println!("cargo:rerun-if-changed=../../scripts/rust/bundle-providers.mjs");
-    let sources: std::collections::BTreeMap<_, _> = deno_telemetry::deno_telemetry::init()
-        .lazy_loaded_js_files
-        .iter()
-        .map(|source| {
-            (
-                source.specifier,
-                source
-                    .load()
-                    .expect("embedded telemetry source")
-                    .to_string(),
-            )
-        })
-        .collect();
-    let mut child = std::process::Command::new("node")
+    println!("cargo:rerun-if-changed=third-party/deno-telemetry/telemetry.ts");
+    println!("cargo:rerun-if-changed=third-party/deno-telemetry/util.ts");
+    let status = std::process::Command::new("node")
         .arg("../../scripts/rust/bundle-providers.mjs")
         .arg(std::env::var("OUT_DIR").expect("Cargo OUT_DIR"))
-        .stdin(std::process::Stdio::piped())
-        .spawn()
+        .stdin(std::process::Stdio::null())
+        .status()
         .expect("Node is required to bundle the provider SDKs at build time");
-    child
-        .stdin
-        .take()
-        .unwrap()
-        .write_all(&serde_json::to_vec(&sources).unwrap())
-        .unwrap();
-    let status = child.wait().expect("provider bundler process");
     assert!(
         status.success(),
         "provider bundle failed; install repository npm dependencies first"
diff --git a/crates/js-runtime/src/trusted/engine.rs b/crates/js-runtime/src/trusted/engine.rs
index 3aafda04ac..eb4302066c 100644
--- a/crates/js-runtime/src/trusted/engine.rs
+++ b/crates/js-runtime/src/trusted/engine.rs
@@ -172,3 +172,30 @@ pub(super) fn string(runtime: &mut JsRuntime, value: v8::Global<v8::Value>) -> R
     let value = v8::Local::new(scope, value);
     deno_core::serde_v8::from_v8(scope, value).map_err(failed)
 }
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn bundled_telemetry_matches_the_locked_extension_sources() {
+        let extension = deno_telemetry::deno_telemetry::init();
+        assert_eq!(extension.lazy_loaded_js_files.len(), 2);
+        for source in extension.lazy_loaded_js_files.iter() {
+            let expected = match source.specifier {
+                "ext:deno_telemetry/telemetry.ts" => {
+                    include_str!("../../third-party/deno-telemetry/telemetry.ts")
+                }
+                "ext:deno_telemetry/util.ts" => {
+                    include_str!("../../third-party/deno-telemetry/util.ts")
+                }
+                other => panic!("unbundled extension source: {other}"),
+            };
+            // Deno stores build-machine paths here. Never load them in production.
+            assert_eq!(
+                source.load().unwrap().as_str(),
+                expected,
+                "{}",
+                source.specifier
+            );
+        }
+    }
+}
diff --git a/crates/js-runtime/third-party/deno-telemetry/telemetry.ts b/crates/js-runtime/third-party/deno-telemetry/telemetry.ts
new file mode 100644
index 0000000000..e9dbcfb453
--- /dev/null
+++ b/crates/js-runtime/third-party/deno-telemetry/telemetry.ts
@@ -0,0 +1,1984 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+
+(function () {
+const { core, internals, primordials } = __bootstrap;
+const {
+  op_otel_collect_isolate_metrics,
+  op_otel_enable_isolate_metrics,
+  op_otel_log,
+  op_otel_log_foreign,
+  op_otel_metric_attribute3,
+  op_otel_metric_observable_record0,
+  op_otel_metric_observable_record1,
+  op_otel_metric_observable_record2,
+  op_otel_metric_observable_record3,
+  op_otel_metric_observation_done,
+  op_otel_metric_record0,
+  op_otel_metric_record1,
+  op_otel_metric_record2,
+  op_otel_metric_record3,
+  op_otel_metric_wait_to_observe,
+  op_otel_span_add_link,
+  op_otel_span_attribute1,
+  op_otel_span_attribute2,
+  op_otel_span_attribute3,
+  op_otel_span_update_name,
+  OtelMeter,
+  OtelTracer,
+} = core.ops;
+const { Console } = core.loadExtScript("ext:deno_web/01_console.js");
+
+const {
+  ArrayFrom,
+  ArrayIsArray,
+  ArrayPrototypeConcat,
+  ArrayPrototypeFilter,
+  ArrayPrototypeForEach,
+  ArrayPrototypeJoin,
+  ArrayPrototypeMap,
+  ArrayPrototypePush,
+  ArrayPrototypeReduce,
+  ArrayPrototypeReverse,
+  ArrayPrototypeShift,
+  ArrayPrototypeSlice,
+  DatePrototype,
+  DatePrototypeGetTime,
+  decodeURIComponent,
+  encodeURIComponent,
+  Error,
+  MapPrototypeEntries,
+  MapPrototypeKeys,
+  Number,
+  NumberParseInt,
+  NumberPrototypeToString,
+  ObjectAssign,
+  ObjectDefineProperty,
+  ObjectEntries,
+  ObjectKeys,
+  ObjectPrototypeIsPrototypeOf,
+  ObjectValues,
+  ReflectApply,
+  SafeArrayIterator,
+  SafeMap,
+  SafeMapIterator,
+  SafePromiseAll,
+  SafeRegExp,
+  SafeSet,
+  SafeWeakSet,
+  StringPrototypeIndexOf,
+  StringPrototypeSlice,
+  StringPrototypeSplit,
+  StringPrototypeSubstring,
+  StringPrototypeTrim,
+  SymbolFor,
+  TypeError,
+} = primordials;
+const { AsyncVariable, getAsyncContext, setAsyncContext } = core;
+
+let TRACING_ENABLED = false;
+let METRICS_ENABLED = false;
+let PROPAGATORS: TextMapPropagator[] = [];
+let ISOLATE_METRICS = false;
+
+// Note: These start at 0 in the JS library,
+// but start at 1 when serialized with JSON.
+enum SpanKind {
+  INTERNAL = 0,
+  SERVER = 1,
+  CLIENT = 2,
+  PRODUCER = 3,
+  CONSUMER = 4,
+}
+
+interface TraceState {
+  set(key: string, value: string): TraceState;
+  unset(key: string): TraceState;
+  get(key: string): string | undefined;
+  serialize(): string;
+}
+
+interface SpanContext {
+  traceId: string;
+  spanId: string;
+  isRemote?: boolean;
+  traceFlags: number;
+  traceState?: TraceState;
+}
+
+enum SpanStatusCode {
+  UNSET = 0,
+  OK = 1,
+  ERROR = 2,
+}
+
+interface SpanStatus {
+  code: SpanStatusCode;
+  message?: string;
+}
+
+type AttributeValue =
+  | string
+  | number
+  | boolean
+  | Array<null | undefined | string>
+  | Array<null | undefined | number>
+  | Array<null | undefined | boolean>;
+
+interface Attributes {
+  [attributeKey: string]: AttributeValue | undefined;
+}
+
+type SpanAttributes = Attributes;
+
+interface Exception {
+  code?: string | number;
+  message?: string;
+  name?: string;
+  stack?: string;
+}
+
+type TimeInput = [number, number] | number | Date;
+
+interface SpanOptions {
+  kind?: SpanKind;
+  attributes?: Attributes;
+  links?: Link[];
+  startTime?: TimeInput;
+  root?: boolean;
+}
+
+interface Link {
+  context: SpanContext;
+  attributes?: SpanAttributes;
+  droppedAttributesCount?: number;
+}
+
+interface IArrayValue {
+  values: IAnyValue[];
+}
+
+interface IAnyValue {
+  stringValue?: string | null;
+  boolValue?: boolean | null;
+  intValue?: number | null;
+  doubleValue?: number | null;
+  arrayValue?: IArrayValue;
+  kvlistValue?: IKeyValueList;
+  bytesValue?: Uint8Array;
+}
+
+interface IKeyValueList {
+  values: IKeyValue[];
+}
+
+interface IKeyValue {
+  key: string;
+  value: IAnyValue;
+}
+
+function hrToMs(hr: [number, number]): number {
+  return (hr[0] * 1e3 + hr[1] / 1e6);
+}
+
+function isTimeInput(input: unknown): input is TimeInput {
+  return typeof input === "number" ||
+    (input && (ArrayIsArray(input) || isDate(input)));
+}
+
+function timeInputToMs(input?: TimeInput): number | undefined {
+  if (input === undefined) return;
+  if (ArrayIsArray(input)) {
+    return hrToMs(input);
+  } else if (isDate(input)) {
+    return DatePrototypeGetTime(input);
+  }
+  return input;
+}
+
+function countAttributes(attributes?: Attributes): number {
+  return attributes ? ObjectKeys(attributes).length : 0;
+}
+
+interface AsyncContextSnapshot {
+  __brand: "AsyncContextSnapshot";
+}
+
+function enterSpan(
+  span: Span,
+  context?: Context,
+): AsyncContextSnapshot | undefined {
+  if (!span.isRecording()) return undefined;
+  context = (context ?? CURRENT.get() ?? ROOT_CONTEXT)
+    .setValue(SPAN_KEY, span);
+  return CURRENT.enter(context);
+}
+
+const currentSnapshot = getAsyncContext;
+const restoreSnapshot = setAsyncContext;
+
+function isDate(value: unknown): value is Date {
+  return ObjectPrototypeIsPrototypeOf(DatePrototype, value);
+}
+
+interface OtelTracer {
+  __key: "tracer";
+
+  // deno-lint-ignore no-misused-new
+  new (name: string, version?: string, schemaUrl?: string): OtelTracer;
+
+  startSpan(
+    parent: OtelSpan | undefined,
+    name: string,
+    spanKind: SpanKind,
+    startTime: number | undefined,
+    attributeCount: number,
+  ): OtelSpan;
+
+  startSpanForeign(
+    parentTraceId: string,
+    parentSpanId: string,
+    parentTraceFlags: number,
+    name: string,
+    spanKind: SpanKind,
+    startTime: number | undefined,
+    attributeCount: number,
+  ): OtelSpan;
+}
+
+interface OtelSpan {
+  __key: "span";
+
+  spanContext(): SpanContext;
+  setStatus(status: SpanStatusCode, errorDescription: string): void;
+  addEvent(
+    name: string,
+    startTime: number,
+  ): number;
+  dropEvent(): void;
+  end(endTime: number): void;
+}
+
+enum SpanAttributesLocation {
+  SELF = 0,
+  EVENT = 1,
+  LINK = 2,
+}
+
+function spanAddAttributes(
+  span: OtelSpan,
+  attributesLocation: SpanAttributesLocation,
+  attributesTarget: number,
+  attributes: Attributes,
+) {
+  const attributeKvs = ObjectEntries(attributes);
+  let i = 0;
+  while (i < attributeKvs.length) {
+    if (i + 2 < attributeKvs.length) {
+      op_otel_span_attribute3(
+        span,
+        attributesLocation,
+        attributesTarget,
+        attributeKvs[i][0],
+        attributeKvs[i][1],
+        attributeKvs[i + 1][0],
+        attributeKvs[i + 1][1],
+        attributeKvs[i + 2][0],
+        attributeKvs[i + 2][1],
+      );
+      i += 3;
+    } else if (i + 1 < attributeKvs.length) {
+      op_otel_span_attribute2(
+        span,
+        attributesLocation,
+        attributesTarget,
+        attributeKvs[i][0],
+        attributeKvs[i][1],
+        attributeKvs[i + 1][0],
+        attributeKvs[i + 1][1],
+      );
+      i += 2;
+    } else {
+      op_otel_span_attribute1(
+        span,
+        attributesLocation,
+        attributesTarget,
+        attributeKvs[i][0],
+        attributeKvs[i][1],
+      );
+      i += 1;
+    }
+  }
+}
+
+interface TracerOptions {
+  schemaUrl?: string;
+}
+
+class TracerProvider {
+  constructor() {
+    throw new TypeError("TracerProvider can not be constructed");
+  }
+
+  static getTracer(
+    name: string,
+    version?: string,
+    options?: TracerOptions,
+  ): Tracer {
+    const tracer = new OtelTracer(name, version, options?.schemaUrl);
+    return new Tracer(tracer);
+  }
+}
+
+class Tracer {
+  #tracer: OtelTracer;
+
+  constructor(tracer: OtelTracer) {
+    this.#tracer = tracer;
+  }
+
+  startActiveSpan<F extends (span: Span) => unknown>(
+    name: string,
+    fn: F,
+  ): ReturnType<F>;
+  startActiveSpan<F extends (span: Span) => unknown>(
+    name: string,
+    options: SpanOptions,
+    fn: F,
+  ): ReturnType<F>;
+  startActiveSpan<F extends (span: Span) => unknown>(
+    name: string,
+    options: SpanOptions,
+    context: Context,
+    fn: F,
+  ): ReturnType<F>;
+  startActiveSpan<F extends (span: Span) => unknown>(
+    name: string,
+    optionsOrFn: SpanOptions | F,
+    fnOrContext?: F | Context,
+    maybeFn?: F,
+  ) {
+    let options;
+    let context;
+    let fn;
+    if (typeof optionsOrFn === "function") {
+      options = undefined;
+      fn = optionsOrFn;
+    } else if (typeof fnOrContext === "function") {
+      options = optionsOrFn;
+      fn = fnOrContext;
+    } else if (typeof maybeFn === "function") {
+      options = optionsOrFn;
+      context = fnOrContext;
+      fn = maybeFn;
+    } else {
+      throw new Error("startActiveSpan requires a function argument");
+    }
+    if (options?.root) {
+      context = ROOT_CONTEXT;
+    } else {
+      context = context ?? CURRENT.get() ?? ROOT_CONTEXT;
+    }
+    const span = this.startSpan(name, options, context);
+    const ctx = CURRENT.enter(context.setValue(SPAN_KEY, span));
+    try {
+      return ReflectApply(fn, undefined, [span]);
+    } finally {
+      setAsyncContext(ctx);
+    }
+  }
+
+  startSpan(name: string, options?: SpanOptions, context?: Context): Span {
+    if (options?.root) {
+      context = undefined;
+    } else {
+      context = context ?? CURRENT.get();
+    }
+
+    const startTime = timeInputToMs(options?.startTime);
+
+    const parentSpan = context?.getValue(SPAN_KEY) as
+      | Span
+      | { spanContext(): SpanContext }
+      | undefined;
+    const attributesCount = countAttributes(options?.attributes);
+    const parentOtelSpan: OtelSpan | null | undefined = parentSpan !== undefined
+      ? getOtelSpan(parentSpan) ?? undefined
+      : undefined;
+    let otelSpan: OtelSpan;
+    if (parentOtelSpan || !parentSpan) {
+      otelSpan = this.#tracer.startSpan(
+        parentOtelSpan,
+        name,
+        options?.kind ?? 0,
+        startTime,
+        attributesCount,
+      );
+    } else {
+      const spanContext = parentSpan.spanContext();
+      otelSpan = this.#tracer.startSpanForeign(
+        spanContext.traceId,
+        spanContext.spanId,
+        spanContext.traceFlags ?? 0,
+        name,
+        options?.kind ?? 0,
+        startTime,
+        attributesCount,
+      );
+    }
+    const span = new Span(otelSpan);
+    if (options?.links) span.addLinks(options?.links);
+    if (options?.attributes) span.setAttributes(options?.attributes);
+    return span;
+  }
+}
+
+const SPAN_KEY = SymbolFor("OpenTelemetry Context Key SPAN");
+
+let getOtelSpan: (span: object) => OtelSpan | null | undefined;
+
+class Span {
+  #otelSpan: OtelSpan | null;
+  #spanContext: SpanContext | undefined;
+
+  static {
+    getOtelSpan = (span) => (#otelSpan in span ? span.#otelSpan : undefined);
+  }
+
+  constructor(otelSpan: OtelSpan | null) {
+    this.#otelSpan = otelSpan;
+  }
+
+  spanContext() {
+    if (!this.#spanContext) {
+      if (this.#otelSpan) {
+        this.#spanContext = this.#otelSpan.spanContext();
+      } else {
+        this.#spanContext = {
+          traceId: "00000000000000000000000000000000",
+          spanId: "0000000000000000",
+          traceFlags: 0,
+        };
+      }
+    }
+    return this.#spanContext;
+  }
+
+  addEvent(
+    name: string,
+    attributesOrStartTime?: Attributes | TimeInput,
+    startTime?: TimeInput,
+  ): this {
+    if (!this.#otelSpan) return this;
+    let attributes: Attributes | undefined;
+    if (isTimeInput(attributesOrStartTime)) {
+      startTime = attributesOrStartTime;
+    } else {
+      attributes = attributesOrStartTime;
+    }
+    const startTimeMs = timeInputToMs(startTime);
+
+    const attributesTarget = this.#otelSpan.addEvent(
+      name,
+      startTimeMs ?? NaN,
+    );
+    if (attributes && attributesTarget !== 0) {
+      spanAddAttributes(
+        this.#otelSpan,
+        SpanAttributesLocation.EVENT,
+        attributesTarget,
+        attributes,
+      );
+    }
+    return this;
+  }
+
+  addLink(link: Link): this {
+    if (!this.#otelSpan) return this;
+    const attributesTarget = op_otel_span_add_link(
+      this.#otelSpan,
+      link.context.traceId,
+      link.context.spanId,
+      link.context.traceFlags,
+      link.context.isRemote ?? false,
+      link.droppedAttributesCount ?? 0,
+    );
+    if (link.attributes && attributesTarget !== 0) {
+      spanAddAttributes(
+        this.#otelSpan,
+        SpanAttributesLocation.LINK,
+        attributesTarget,
+        link.attributes,
+      );
+    }
+    return this;
+  }
+
+  addLinks(links: Link[]): this {
+    for (let i = 0; i < links.length; i++) {
+      this.addLink(links[i]);
+    }
+    return this;
+  }
+
+  end(endTime?: TimeInput): void {
+    this.#otelSpan?.end(timeInputToMs(endTime) || NaN);
+  }
+
+  isRecording(): boolean {
+    return this.#otelSpan !== undefined;
+  }
+
+  recordException(exception: string | Exception, time?: TimeInput): void {
+    if (typeof exception === "string") {
+      this.addEvent("exception", {
+        "exception.message": exception,
+      }, time);
+      return;
+    }
+    const attributes: Attributes = {};
+
+    if (exception.code) {
+      if (typeof exception.code === "number") {
+        attributes["exception.type"] = NumberPrototypeToString(exception.code);
+      } else {
+        attributes["exception.type"] = exception.code;
+      }
+    } else if (exception.name) {
+      attributes["exception.type"] = exception.name;
+    }
+
+    if (exception.message) {
+      attributes["exception.message"] = exception.message;
+    }
+    if (exception.stack) {
+      attributes["exception.stacktrace"] = exception.stack;
+    }
+
+    this.addEvent("exception", attributes, time);
+  }
+
+  setAttribute(key: string, value: AttributeValue): this {
+    if (!this.#otelSpan) return this;
+    op_otel_span_attribute1(
+      this.#otelSpan,
+      SpanAttributesLocation.SELF,
+      0,
+      key,
+      value,
+    );
+    return this;
+  }
+
+  setAttributes(attributes: Attributes): this {
+    if (!this.#otelSpan) return this;
+    spanAddAttributes(
+      this.#otelSpan,
+      SpanAttributesLocation.SELF,
+      0,
+      attributes,
+    );
+    return this;
+  }
+
+  setStatus(status: SpanStatus): this {
+    this.#otelSpan?.setStatus(status.code, status.message ?? "");
+    return this;
+  }
+
+  updateName(name: string): this {
+    if (!this.#otelSpan) return this;
+    op_otel_span_update_name(this.#otelSpan, name);
+    return this;
+  }
+}
+
+const CURRENT = new AsyncVariable();
+
+class Context {
+  // @ts-ignore __proto__ is not supported in TypeScript
+  #data: Record<symbol, unknown> = { __proto__: null };
+
+  constructor(data?: Record<symbol, unknown> | null | undefined) {
+    // @ts-ignore __proto__ is not supported in TypeScript
+    this.#data = { __proto__: null, ...data };
+  }
+
+  getValue(key: symbol): unknown {
+    return this.#data[key];
+  }
+
+  setValue(key: symbol, value: unknown): Context {
+    const c = new Context(this.#data);
+    c.#data[key] = value;
+    return c;
+  }
+
+  deleteValue(key: symbol): Context {
+    const c = new Context(this.#data);
+    delete c.#data[key];
+    return c;
+  }
+}
+
+// TODO(lucacasonato): @opentelemetry/api defines it's own ROOT_CONTEXT
+const ROOT_CONTEXT = new Context();
+
+// Context manager for opentelemetry js library
+class ContextManager {
+  constructor() {
+    throw new TypeError("ContextManager can not be constructed");
+  }
+
+  static active(): Context {
+    return CURRENT.get() ?? ROOT_CONTEXT;
+  }
+
+  static with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
+    context: Context,
+    fn: F,
+    thisArg?: ThisParameterType<F>,
+    ...args: A
+  ): ReturnType<F> {
+    const ctx = CURRENT.enter(context);
+    try {
+      return ReflectApply(fn, thisArg, args);
+    } finally {
+      setAsyncContext(ctx);
+    }
+  }
+
+  // deno-lint-ignore no-explicit-any
+  static bind<T extends (...args: any[]) => any>(
+    context: Context,
+    target: T,
+  ): T {
+    return ((...args) => {
+      const ctx = CURRENT.enter(context);
+      try {
+        return ReflectApply(target, this, args);
+      } finally {
+        setAsyncContext(ctx);
+      }
+    }) as T;
+  }
+
+  static enable() {
+    return this;
+  }
+
+  static disable() {
+    return this;
+  }
+}
+
+// metrics
+
+interface MeterOptions {
+  schemaUrl?: string;
+}
+
+interface MetricOptions {
+  description?: string;
+
+  unit?: string;
+
+  valueType?: ValueType;
+
+  advice?: MetricAdvice;
+}
+
+enum ValueType {
+  INT = 0,
+  DOUBLE = 1,
+}
+
+interface MetricAdvice {
+  /**
+   * Hint the explicit bucket boundaries for SDK if the metric is been
+   * aggregated with a HistogramAggregator.
+   */
+  explicitBucketBoundaries?: number[];
+}
+
+interface OtelMeter {
+  __key: "meter";
+  createCounter(name: string, description?: string, unit?: string): Instrument;
+  createUpDownCounter(
+    name: string,
+    description?: string,
+    unit?: string,
+  ): Instrument;
+  createGauge(name: string, description?: string, unit?: string): Instrument;
+  createHistogram(
+    name: string,
+    description?: string,
+    unit?: string,
+    explicitBucketBoundaries?: number[],
+  ): Instrument;
+  createObservableCounter(
+    name: string,
+    description?: string,
+    unit?: string,
+  ): Instrument;
+  createObservableUpDownCounter(
+    name: string,
+    description?: string,
+    unit?: string,
+  ): Instrument;
+  createObservableGauge(
+    name: string,
+    description?: string,
+    unit?: string,
+  ): Instrument;
+}
+
+class MeterProvider {
+  constructor() {
+    throw new TypeError("MeterProvider can not be constructed");
+  }
+
+  static getMeter(
+    name: string,
+    version?: string,
+    options?: MeterOptions,
+  ): Meter {
+    const meter = new OtelMeter(name, version, options?.schemaUrl);
+    return new Meter(meter);
+  }
+}
+
+type MetricAttributes = Attributes;
+
+type Instrument = { __key: "instrument" };
+
+let batchResultHasObservables: (
+  res: BatchObservableResult,
+  observables: Observable[],
+) => boolean;
+
+class BatchObservableResult {
+  #observables: WeakSet<Observable>;
+
+  constructor(observables: WeakSet<Observable>) {
+    this.#observables = observables;
+  }
+
+  static {
+    batchResultHasObservables = (cb, observables) => {
+      for (const observable of new SafeArrayIterator(observables)) {
+        if (!cb.#observables.has(observable)) return false;
+      }
+      return true;
+    };
+  }
+
+  observe(
+    metric: Observable,
+    value: number,
+    attributes?: MetricAttributes,
+  ): void {
+    if (!this.#observables.has(metric)) return;
+    getObservableResult(metric).observe(value, attributes);
+  }
+}
+
+const BATCH_CALLBACKS = new SafeMap<
+  BatchObservableCallback,
+  BatchObservableResult
+>();
+const INDIVIDUAL_CALLBACKS = new SafeMap<Observable, Set<ObservableCallback>>();
+
+class Meter {
+  #meter: OtelMeter;
+
+  constructor(meter: OtelMeter) {
+    this.#meter = meter;
+  }
+
+  createCounter(name: string, options?: MetricOptions): Counter {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) return new Counter(null, false);
+    const instrument = this.#meter.createCounter(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+    ) as Instrument;
+    return new Counter(instrument, false);
+  }
+
+  createUpDownCounter(name: string, options?: MetricOptions): Counter {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) return new Counter(null, true);
+    const instrument = this.#meter.createUpDownCounter(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+    ) as Instrument;
+    return new Counter(instrument, true);
+  }
+
+  createGauge(name: string, options?: MetricOptions): Gauge {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) return new Gauge(null);
+    const instrument = this.#meter.createGauge(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+    ) as Instrument;
+    return new Gauge(instrument);
+  }
+
+  createHistogram(name: string, options?: MetricOptions): Histogram {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) return new Histogram(null);
+    const instrument = this.#meter.createHistogram(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+      options?.advice?.explicitBucketBoundaries,
+    ) as Instrument;
+    return new Histogram(instrument);
+  }
+
+  createObservableCounter(name: string, options?: MetricOptions): Observable {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) new Observable(new ObservableResult(null, true));
+    const instrument = this.#meter.createObservableCounter(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+    ) as Instrument;
+    return new Observable(new ObservableResult(instrument, true));
+  }
+
+  createObservableUpDownCounter(
+    name: string,
+    options?: MetricOptions,
+  ): Observable {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) new Observable(new ObservableResult(null, false));
+    const instrument = this.#meter.createObservableUpDownCounter(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+    ) as Instrument;
+    return new Observable(new ObservableResult(instrument, false));
+  }
+
+  createObservableGauge(name: string, options?: MetricOptions): Observable {
+    if (options?.valueType !== undefined && options?.valueType !== 1) {
+      throw new Error("Only valueType: DOUBLE is supported");
+    }
+    if (!METRICS_ENABLED) new Observable(new ObservableResult(null, false));
+    const instrument = this.#meter.createObservableGauge(
+      name,
+      // deno-lint-ignore deno-internal/prefer-primordials
+      options?.description,
+      options?.unit,
+    ) as Instrument;
+    return new Observable(new ObservableResult(instrument, false));
+  }
+
+  addBatchObservableCallback(
+    callback: BatchObservableCallback,
+    observables: Observable[],
+  ): void {
+    if (!METRICS_ENABLED) return;
+    const result = new BatchObservableResult(new SafeWeakSet(observables));
+    startObserving();
+    BATCH_CALLBACKS.set(callback, result);
+  }
+
+  removeBatchObservableCallback(
+    callback: BatchObservableCallback,
+    observables: Observable[],
+  ): void {
+    if (!METRICS_ENABLED) return;
+    const result = BATCH_CALLBACKS.get(callback);
+    if (result && batchResultHasObservables(result, observables)) {
+      BATCH_CALLBACKS.delete(callback);
+    }
+  }
+}
+
+type BatchObservableCallback = (
+  observableResult: BatchObservableResult,
+) => void | Promise<void>;
+
+function record(
+  instrument: Instrument | null,
+  value: number,
+  attributes?: MetricAttributes,
+) {
+  if (instrument === null) return;
+  if (attributes === undefined) {
+    op_otel_metric_record0(instrument, value);
+  } else {
+    const attrs = ObjectEntries(attributes);
+    if (attrs.length === 0) {
+      op_otel_metric_record0(instrument, value);
+    }
+    let i = 0;
+    while (i < attrs.length) {
+      const remaining = attrs.length - i;
+      if (remaining > 3) {
+        op_otel_metric_attribute3(
+          attrs.length,
+          attrs[i][0],
+          attrs[i][1],
+          attrs[i + 1][0],
+          attrs[i + 1][1],
+          attrs[i + 2][0],
+          attrs[i + 2][1],
+        );
+        i += 3;
+      } else if (remaining === 3) {
+        op_otel_metric_record3(
+          instrument,
+          value,
+          attrs[i][0],
+          attrs[i][1],
+          attrs[i + 1][0],
+          attrs[i + 1][1],
+          attrs[i + 2][0],
+          attrs[i + 2][1],
+        );
+        i += 3;
+      } else if (remaining === 2) {
+        op_otel_metric_record2(
+          instrument,
+          value,
+          attrs[i][0],
+          attrs[i][1],
+          attrs[i + 1][0],
+          attrs[i + 1][1],
+        );
+        i += 2;
+      } else if (remaining === 1) {
+        op_otel_metric_record1(instrument, value, attrs[i][0], attrs[i][1]);
+        i += 1;
+      }
+    }
+  }
+}
+
+function recordObservable(
+  instrument: Instrument | null,
+  value: number,
+  attributes?: MetricAttributes,
+) {
+  if (instrument === null) return;
+  if (attributes === undefined) {
+    op_otel_metric_observable_record0(instrument, value);
+  } else {
+    const attrs = ObjectEntries(attributes);
+    if (attrs.length === 0) {
+      op_otel_metric_observable_record0(instrument, value);
+    }
+    let i = 0;
+    while (i < attrs.length) {
+      const remaining = attrs.length - i;
+      if (remaining > 3) {
+        op_otel_metric_attribute3(
+          attrs.length,
+          attrs[i][0],
+          attrs[i][1],
+          attrs[i + 1][0],
+          attrs[i + 1][1],
+          attrs[i + 2][0],
+          attrs[i + 2][1],
+        );
+        i += 3;
+      } else if (remaining === 3) {
+        op_otel_metric_observable_record3(
+          instrument,
+          value,
+          attrs[i][0],
+          attrs[i][1],
+          attrs[i + 1][0],
+          attrs[i + 1][1],
+          attrs[i + 2][0],
+          attrs[i + 2][1],
+        );
+        i += 3;
+      } else if (remaining === 2) {
+        op_otel_metric_observable_record2(
+          instrument,
+          value,
+          attrs[i][0],
+          attrs[i][1],
+          attrs[i + 1][0],
+          attrs[i + 1][1],
+        );
+        i += 2;
+      } else if (remaining === 1) {
+        op_otel_metric_observable_record1(
+          instrument,
+          value,
+          attrs[i][0],
+          attrs[i][1],
+        );
+        i += 1;
+      }
+    }
+  }
+}
+
+class Counter {
+  #instrument: Instrument | null;
+  #upDown: boolean;
+
+  constructor(instrument: Instrument | null, upDown: boolean) {
+    this.#instrument = instrument;
+    this.#upDown = upDown;
+  }
+
+  add(value: number, attributes?: MetricAttributes, _context?: Context): void {
+    if (value < 0 && !this.#upDown) {
+      throw new Error("Counter can only be incremented");
+    }
+    record(this.#instrument, value, attributes);
+  }
+}
+
+class Gauge {
+  #instrument: Instrument | null;
+
+  constructor(instrument: Instrument | null) {
+    this.#instrument = instrument;
+  }
+
+  record(
+    value: number,
+    attributes?: MetricAttributes,
+    _context?: Context,
+  ): void {
+    record(this.#instrument, value, attributes);
+  }
+}
+
+class Histogram {
+  #instrument: Instrument | null;
+
+  constructor(instrument: Instrument | null) {
+    this.#instrument = instrument;
+  }
+
+  record(
+    value: number,
+    attributes?: MetricAttributes,
+    _context?: Context,
+  ): void {
+    record(this.#instrument, value, attributes);
+  }
+}
+
+type ObservableCallback = (
+  observableResult: ObservableResult,
+) => void | Promise<void>;
+
+let getObservableResult: (observable: Observable) => ObservableResult;
+
+class Observable {
+  #result: ObservableResult;
+
+  constructor(result: ObservableResult) {
+    this.#result = result;
+  }
+
+  static {
+    getObservableResult = (observable) => observable.#result;
+  }
+
+  addCallback(callback: ObservableCallback): void {
+    const res = INDIVIDUAL_CALLBACKS.get(this);
+    if (res) res.add(callback);
+    else INDIVIDUAL_CALLBACKS.set(this, new SafeSet([callback]));
+    startObserving();
+  }
+
+  removeCallback(callback: ObservableCallback): void {
+    const res = INDIVIDUAL_CALLBACKS.get(this);
+    if (res) res.delete(callback);
+    if (res?.size === 0) INDIVIDUAL_CALLBACKS.delete(this);
+  }
+}
+
+class ObservableResult {
+  #instrument: Instrument | null;
+  #isRegularCounter: boolean;
+
+  constructor(instrument: Instrument | null, isRegularCounter: boolean) {
+    this.#instrument = instrument;
+    this.#isRegularCounter = isRegularCounter;
+  }
+
+  observe(
+    this: ObservableResult,
+    value: number,
+    attributes?: MetricAttributes,
+  ): void {
+    if (this.#isRegularCounter) {
+      if (value < 0) {
+        throw new Error("Observable counters can only be incremented");
+      }
+    }
+    recordObservable(this.#instrument, value, attributes);
+  }
+}
+
+async function observe(): Promise<void> {
+  if (ISOLATE_METRICS) {
+    op_otel_collect_isolate_metrics();
+  }
+
+  const promises: Promise<void>[] = [];
+  // Primordials are not needed, because this is a SafeMap.
+  // deno-lint-ignore deno-internal/prefer-primordials
+  for (const { 0: observable, 1: callbacks } of INDIVIDUAL_CALLBACKS) {
+    const result = getObservableResult(observable);
+    // Primordials are not needed, because this is a SafeSet.
+    // deno-lint-ignore deno-internal/prefer-primordials
+    for (const callback of callbacks) {
+      // PromiseTry is not in primordials?
+      // deno-lint-ignore deno-internal/prefer-primordials
+      ArrayPrototypePush(promises, Promise.try(callback, result));
+    }
+  }
+  // Primordials are not needed, because this is a SafeMap.
+  // deno-lint-ignore deno-internal/prefer-primordials
+  for (const { 0: callback, 1: result } of BATCH_CALLBACKS) {
+    // PromiseTry is not in primordials?
+    // deno-lint-ignore deno-internal/prefer-primordials
+    ArrayPrototypePush(promises, Promise.try(callback, result));
+  }
+  await SafePromiseAll(promises);
+}
+
+let isObserving = false;
+function startObserving() {
+  if (!isObserving) {
+    isObserving = true;
+    (async () => {
+      while (true) {
+        const promise = op_otel_metric_wait_to_observe();
+        core.unrefOpPromise(promise);
+        const ok = await promise;
+        if (!ok) break;
+        await observe();
+        op_otel_metric_observation_done();
+      }
+    })();
+  }
+}
+
+const otelConsoleConfig = {
+  ignore: 0,
+  capture: 1,
+  replace: 2,
+};
+
+let pendingException: Error | undefined;
+
+const OTEL_CONSOLE_METHODS = ["log", "debug", "info", "warn", "error"];
+
+function wrapOtelConsoleMethods(otelConsole: Console) {
+  for (let i = 0; i < OTEL_CONSOLE_METHODS.length; i++) {
+    const method = OTEL_CONSOLE_METHODS[i];
+    const orig = otelConsole[method];
+    otelConsole[method] = (...args: unknown[]) => {
+      if (args.length >= 1 && core.isNativeError(args[0])) {
+        pendingException = args[0] as Error;
+      }
+      return ReflectApply(orig, otelConsole, args);
+    };
+  }
+}
+
+function otelLog(message: string, level: number) {
+  const exception = pendingException;
+  pendingException = undefined;
+  const excType = exception?.name ?? "";
+  const excMessage = exception?.message ?? "";
+  const excStacktrace = exception?.stack ?? "";
+  const currentSpan = CURRENT.get()?.getValue(SPAN_KEY);
+  const otelSpan = currentSpan !== undefined
+    ? getOtelSpan(currentSpan)
+    : undefined;
+  if (otelSpan || currentSpan === undefined) {
+    op_otel_log(message, level, otelSpan, excType, excMessage, excStacktrace);
+  } else {
+    const spanContext = currentSpan.spanContext();
+    op_otel_log_foreign(
+      message,
+      level,
+      spanContext.traceId,
+      spanContext.spanId,
+      spanContext.traceFlags,
+      excType,
+      excMessage,
+      excStacktrace,
+    );
+  }
+}
+
+/*
+ * Copyright The OpenTelemetry Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const VERSION = "00";
+const VERSION_PART = "(?!ff)[\\da-f]{2}";
+const TRACE_ID_PART = "(?![0]{32})[\\da-f]{32}";
+const PARENT_ID_PART = "(?![0]{16})[\\da-f]{16}";
+const FLAGS_PART = "[\\da-f]{2}";
+const TRACE_PARENT_REGEX = new SafeRegExp(
+  `^\\s?(${VERSION_PART})-(${TRACE_ID_PART})-(${PARENT_ID_PART})-(${FLAGS_PART})(-.*)?\\s?$`,
+);
+const VALID_TRACEID_REGEX = new SafeRegExp("^([0-9a-f]{32})$", "i");
+const VALID_SPANID_REGEX = new SafeRegExp("^[0-9a-f]{16}$", "i");
+const MAX_TRACE_STATE_ITEMS = 32;
+const MAX_TRACE_STATE_LEN = 512;
+const LIST_MEMBERS_SEPARATOR = ",";
+const LIST_MEMBER_KEY_VALUE_SPLITTER = "=";
+const VALID_KEY_CHAR_RANGE = "[_0-9a-z-*/]";
+const VALID_KEY = `[a-z]${VALID_KEY_CHAR_RANGE}{0,255}`;
+const VALID_VENDOR_KEY =
+  `[a-z0-9]${VALID_KEY_CHAR_RANGE}{0,240}@[a-z]${VALID_KEY_CHAR_RANGE}{0,13}`;
+const VALID_KEY_REGEX = new SafeRegExp(
+  `^(?:${VALID_KEY}|${VALID_VENDOR_KEY})$`,
+);
+const VALID_VALUE_BASE_REGEX = new SafeRegExp("^[ -~]{0,255}[!-~]$");
+const INVALID_VALUE_COMMA_EQUAL_REGEX = new SafeRegExp(",|=");
+
+const TRACE_PARENT_HEADER = "traceparent";
+const TRACE_STATE_HEADER = "tracestate";
+const INVALID_TRACEID = "00000000000000000000000000000000";
+const INVALID_SPANID = "0000000000000000";
+const INVALID_SPAN_CONTEXT: SpanContext = {
+  traceId: INVALID_TRACEID,
+  spanId: INVALID_SPANID,
+  traceFlags: 0,
+};
+const BAGGAGE_KEY_PAIR_SEPARATOR = "=";
+const BAGGAGE_PROPERTIES_SEPARATOR = ";";
+const BAGGAGE_ITEMS_SEPARATOR = ",";
+const BAGGAGE_HEADER = "baggage";
+const BAGGAGE_MAX_NAME_VALUE_PAIRS = 180;
+const BAGGAGE_MAX_PER_NAME_VALUE_PAIRS = 4096;
+const BAGGAGE_MAX_TOTAL_LENGTH = 8192;
+
+class NonRecordingSpan implements Span {
+  constructor(
+    private readonly _spanContext: SpanContext = INVALID_SPAN_CONTEXT,
+  ) {}
+
+  spanContext(): SpanContext {
+    return this._spanContext;
+  }
+
+  setAttribute(_key: string, _value: unknown): this {
+    return this;
+  }
+
+  setAttributes(_attributes: SpanAttributes): this {
+    return this;
+  }
+
+  addEvent(_name: string, _attributes?: SpanAttributes): this {
+    return this;
+  }
+
+  addLink(_link: Link): this {
+    return this;
+  }
+
+  addLinks(_links: Link[]): this {
+    return this;
+  }
+
+  setStatus(_status: SpanStatus): this {
+    return this;
+  }
+
+  updateName(_name: string): this {
+    return this;
+  }
+
+  end(_endTime?: TimeInput): void {}
+
+  isRecording(): boolean {
+    return false;
+  }
+
+  // deno-lint-ignore no-explicit-any
+  recordException(_exception: any, _time?: TimeInput): void {}
+}
+
+const otelPropagators = {
+  traceContext: 0,
+  baggage: 1,
+  none: 2,
+};
+
+function parseTraceParent(traceParent: string): SpanContext | null {
+  const match = TRACE_PARENT_REGEX.exec(traceParent);
+  if (!match) return null;
+
+  // According to the specification the implementation should be compatible
+  // with future versions. If there are more parts, we only reject it if it's using version 00
+  // See https://www.w3.org/TR/trace-context/#versioning-of-traceparent
+  if (match[1] === "00" && match[5]) return null;
+
+  return {
+    traceId: match[2],
+    spanId: match[3],
+    traceFlags: NumberParseInt(match[4], 16),
+  };
+}
+
+// deno-lint-ignore no-explicit-any
+interface TextMapSetter<Carrier = any> {
+  set(carrier: Carrier, key: string, value: string): void;
+}
+
+// deno-lint-ignore no-explicit-any
+interface TextMapPropagator<Carrier = any> {
+  inject(
+    context: Context,
+    carrier: Carrier,
+    setter: TextMapSetter<Carrier>,
+  ): void;
+  extract(
+    context: Context,
+    carrier: Carrier,
+    getter: TextMapGetter<Carrier>,
+  ): Context;
+  fields(): string[];
+}
+
+// deno-lint-ignore no-explicit-any
+interface TextMapGetter<Carrier = any> {
+  keys(carrier: Carrier): string[];
+  get(carrier: Carrier, key: string): undefined | string | string[];
+}
+
+function isTracingSuppressed(context: Context): boolean {
+  return context.getValue(
+    SymbolFor("OpenTelemetry SDK Context Key SUPPRESS_TRACING"),
+  ) === true;
+}
+
+function isValidTraceId(traceId: string): boolean {
+  return VALID_TRACEID_REGEX.test(traceId) && traceId !== INVALID_TRACEID;
+}
+
+function isValidSpanId(spanId: string): boolean {
+  return VALID_SPANID_REGEX.test(spanId) && spanId !== INVALID_SPANID;
+}
+
+function isSpanContextValid(spanContext: SpanContext): boolean {
+  return (
+    isValidTraceId(spanContext.traceId) && isValidSpanId(spanContext.spanId)
+  );
+}
+
+function validateKey(key: string): boolean {
+  return VALID_KEY_REGEX.test(key);
+}
+
+function validateValue(value: string): boolean {
+  return (
+    VALID_VALUE_BASE_REGEX.test(value) &&
+    !INVALID_VALUE_COMMA_EQUAL_REGEX.test(value)
+  );
+}
+
+class TraceStateClass implements TraceState {
+  private _internalState: Map<string, string> = new SafeMap();
+
+  constructor(rawTraceState?: string) {
+    if (rawTraceState) this._parse(rawTraceState);
+  }
+
+  set(key: string, value: string): TraceStateClass {
+    const traceState = this._clone();
+    if (traceState._internalState.has(key)) {
+      traceState._internalState.delete(key);
+    }
+    traceState._internalState.set(key, value);
+    return traceState;
+  }
+
+  unset(key: string): TraceStateClass {
+    const traceState = this._clone();
+    traceState._internalState.delete(key);
+    return traceState;
+  }
+
+  get(key: string): string | undefined {
+    return this._internalState.get(key);
+  }
+
+  serialize(): string {
+    return ArrayPrototypeJoin(
+      ArrayPrototypeReduce(this._keys(), (agg: string[], key) => {
+        ArrayPrototypePush(
+          agg,
+          key + LIST_MEMBER_KEY_VALUE_SPLITTER + this.get(key),
+        );
+        return agg;
+      }, []),
+      LIST_MEMBERS_SEPARATOR,
+    );
+  }
+
+  private _parse(rawTraceState: string) {
+    if (rawTraceState.length > MAX_TRACE_STATE_LEN) return;
+    this._internalState = ArrayPrototypeReduce(
+      ArrayPrototypeReverse(
+        StringPrototypeSplit(rawTraceState, LIST_MEMBERS_SEPARATOR),
+      ),
+      (agg: Map<string, string>, part: string) => {
+        const listMember = StringPrototypeTrim(part); // Optional Whitespace (OWS) handling
+        const i = StringPrototypeIndexOf(
+          listMember,
+          LIST_MEMBER_KEY_VALUE_SPLITTER,
+        );
+        if (i !== -1) {
+          const key = StringPrototypeSlice(listMember, 0, i);
+          const value = StringPrototypeSlice(listMember, i + 1, part.length);
+          if (validateKey(key) && validateValue(value)) {
+            agg.set(key, value);
+          }
+        }
+        return agg;
+      },
+      new SafeMap(),
+    );
+
+    // Because of the reverse() requirement, trunc must be done after map is created
+    if (this._internalState.size > MAX_TRACE_STATE_ITEMS) {
+      this._internalState = new SafeMap(
+        ArrayPrototypeSlice(
+          ArrayPrototypeReverse(
+            ArrayFrom(MapPrototypeEntries(this._internalState)),
+          ),
+          0,
+          MAX_TRACE_STATE_ITEMS,
+        ),
+      );
+    }
+  }
+
+  private _keys(): string[] {
+    return ArrayPrototypeReverse(
+      ArrayFrom(MapPrototypeKeys(this._internalState)),
+    );
+  }
+
+  private _clone(): TraceStateClass {
+    const traceState = new TraceStateClass();
+    traceState._internalState = new SafeMap(this._internalState);
+    return traceState;
+  }
+}
+
+class W3CTraceContextPropagator implements TextMapPropagator {
+  inject(context: Context, carrier: unknown, setter: TextMapSetter): void {
+    const spanContext = (context.getValue(SPAN_KEY) as Span | undefined)
+      ?.spanContext();
+    if (
+      !spanContext ||
+      isTracingSuppressed(context) ||
+      !isSpanContextValid(spanContext)
+    ) {
+      return;
+    }
+
+    const traceParent =
+      `${VERSION}-${spanContext.traceId}-${spanContext.spanId}-0${
+        NumberPrototypeToString(Number(spanContext.traceFlags || 0), 16)
+      }`;
+
+    setter.set(carrier, TRACE_PARENT_HEADER, traceParent);
+    if (spanContext.traceState) {
+      setter.set(
+        carrier,
+        TRACE_STATE_HEADER,
+        spanContext.traceState.serialize(),
+      );
+    }
+  }
+
+  extract(context: Context, carrier: unknown, getter: TextMapGetter): Context {
+    const traceParentHeader = getter.get(carrier, TRACE_PARENT_HEADER);
+    if (!traceParentHeader) return context;
+    const traceParent = ArrayIsArray(traceParentHeader)
+      ? traceParentHeader[0]
+      : traceParentHeader;
+    if (typeof traceParent !== "string") return context;
+    const spanContext = parseTraceParent(traceParent);
+    if (!spanContext) return context;
+
+    spanContext.isRemote = true;
+
+    const traceStateHeader = getter.get(carrier, TRACE_STATE_HEADER);
+    if (traceStateHeader) {
+      // If more than one `tracestate` header is found, we merge them into a
+      // single header.
+      const state = ArrayIsArray(traceStateHeader)
+        ? ArrayPrototypeJoin(traceStateHeader, ",")
+        : traceStateHeader;
+      spanContext.traceState = new TraceStateClass(
+        typeof state === "string" ? state : undefined,
+      );
+    }
+    return context.setValue(SPAN_KEY, new NonRecordingSpan(spanContext));
+  }
+
+  fields(): string[] {
+    return [TRACE_PARENT_HEADER, TRACE_STATE_HEADER];
+  }
+}
+
+const baggageEntryMetadataSymbol = SymbolFor("BaggageEntryMetadata");
+
+type BaggageEntryMetadata = { toString(): string } & {
+  __TYPE__: typeof baggageEntryMetadataSymbol;
+};
+
+interface BaggageEntry {
+  value: string;
+  metadata?: BaggageEntryMetadata;
+}
+
+interface ParsedBaggageKeyValue {
+  key: string;
+  value: string;
+  metadata: BaggageEntryMetadata | undefined;
+}
+
+interface Baggage {
+  getEntry(key: string): BaggageEntry | undefined;
+  getAllEntries(): [string, BaggageEntry][];
+  setEntry(key: string, entry: BaggageEntry): Baggage;
+  removeEntry(key: string): Baggage;
+  removeEntries(...key: string[]): Baggage;
+  clear(): Baggage;
+}
+
+function baggageEntryMetadataFromString(
+  str: string,
+): BaggageEntryMetadata {
+  if (typeof str !== "string") {
+    str = "";
+  }
+
+  return {
+    __TYPE__: baggageEntryMetadataSymbol,
+    toString() {
+      return str;
+    },
+  };
+}
+
+function serializeKeyPairs(keyPairs: string[]): string {
+  return ArrayPrototypeReduce(keyPairs, (hValue: string, current: string) => {
+    const value = `${hValue}${
+      hValue !== "" ? BAGGAGE_ITEMS_SEPARATOR : ""
+    }${current}`;
+    return value.length > BAGGAGE_MAX_TOTAL_LENGTH ? hValue : value;
+  }, "");
+}
+
+function getKeyPairs(baggage: Baggage): string[] {
+  return ArrayPrototypeMap(baggage.getAllEntries(), (baggageEntry) => {
+    let entry = `${encodeURIComponent(baggageEntry[0])}=${
+      encodeURIComponent(baggageEntry[1].value)
+    }`;
+
+    // include opaque metadata if provided
+    // NOTE: we intentionally don't URI-encode the metadata - that responsibility falls on the metadata implementation
+    if (baggageEntry[1].metadata !== undefined) {
+      entry += BAGGAGE_PROPERTIES_SEPARATOR +
+        // deno-lint-ignore deno-internal/prefer-primordials
+        baggageEntry[1].metadata.toString();
+    }
+
+    return entry;
+  });
+}
+
+function parsePairKeyValue(
+  entry: string,
+): ParsedBaggageKeyValue | undefined {
+  const valueProps = StringPrototypeSplit(entry, BAGGAGE_PROPERTIES_SEPARATOR);
+  if (valueProps.length <= 0) return;
+  const keyPairPart = ArrayPrototypeShift(valueProps);
+  if (!keyPairPart) return;
+  const separatorIndex = StringPrototypeIndexOf(
+    keyPairPart,
+    BAGGAGE_KEY_PAIR_SEPARATOR,
+  );
+  if (separatorIndex <= 0) return;
+  const key = decodeURIComponent(
+    StringPrototypeTrim(
+      StringPrototypeSubstring(keyPairPart, 0, separatorIndex),
+    ),
+  );
+  const value = decodeURIComponent(
+    StringPrototypeTrim(
+      StringPrototypeSubstring(keyPairPart, separatorIndex + 1),
+    ),
+  );
+  let metadata;
+  if (valueProps.length > 0) {
+    metadata = baggageEntryMetadataFromString(
+      ArrayPrototypeJoin(valueProps, BAGGAGE_PROPERTIES_SEPARATOR),
+    );
+  }
+  return { key, value, metadata };
+}
+
+class BaggageImpl implements Baggage {
+  #entries: Map<string, BaggageEntry>;
+
+  constructor(entries?: Map<string, BaggageEntry>) {
+    this.#entries = new SafeMap();
+    // The `SafeMap` constructor that takes an iterable doesn't work for non Array iterables correctly.
+    if (entries) {
+      for (const { 0: key, 1: entry } of new SafeMapIterator(entries)) {
+        this.#entries.set(key, ObjectAssign({}, entry));
+      }
+    }
+  }
+
+  getEntry(key: string): BaggageEntry | undefined {
+    const entry = this.#entries.get(key);
+    if (!entry) {
+      return undefined;
+    }
+
+    return ObjectAssign({}, entry);
+  }
+
+  getAllEntries(): [string, BaggageEntry][] {
+    return ArrayPrototypeMap(
+      ArrayFrom(MapPrototypeEntries(this.#entries)),
+      (entry) => [entry[0], entry[1]],
+    );
+  }
+
+  setEntry(key: string, entry: BaggageEntry): BaggageImpl {
+    const newBaggage = new BaggageImpl(this.#entries);
+    newBaggage.#entries.set(key, entry);
+    return newBaggage;
+  }
+
+  removeEntry(key: string): BaggageImpl {
+    const newBaggage = new BaggageImpl(this.#entries);
+    newBaggage.#entries.delete(key);
+    return newBaggage;
+  }
+
+  removeEntries(...keys: string[]): BaggageImpl {
+    const newBaggage = new BaggageImpl(this.#entries);
+    for (const key of new SafeArrayIterator(keys)) {
+      newBaggage.#entries.delete(key);
+    }
+    return newBaggage;
+  }
+
+  clear(): BaggageImpl {
+    return new BaggageImpl();
+  }
+}
+
+const BAGGAGE_KEY = SymbolFor("OpenTelemetry Baggage Key");
+
+class W3CBaggagePropagator implements TextMapPropagator {
+  inject(context: Context, carrier: unknown, setter: TextMapSetter): void {
+    const baggage = context.getValue(BAGGAGE_KEY) as
+      | Baggage
+      | undefined;
+    if (!baggage || isTracingSuppressed(context)) return;
+    const keyPairs = ArrayPrototypeSlice(
+      ArrayPrototypeFilter(getKeyPairs(baggage), (pair: string) => {
+        return pair.length <= BAGGAGE_MAX_PER_NAME_VALUE_PAIRS;
+      }),
+      0,
+      BAGGAGE_MAX_NAME_VALUE_PAIRS,
+    );
+    const headerValue = serializeKeyPairs(keyPairs);
+    if (headerValue.length > 0) {
+      setter.set(carrier, BAGGAGE_HEADER, headerValue);
+    }
+  }
+
+  extract(context: Context, carrier: unknown, getter: TextMapGetter): Context {
+    const headerValue = getter.get(carrier, BAGGAGE_HEADER);
+    const baggageString = ArrayIsArray(headerValue)
+      ? ArrayPrototypeJoin(headerValue, BAGGAGE_ITEMS_SEPARATOR)
+      : headerValue;
+    if (!baggageString) return context;
+    const baggage: Record<string, BaggageEntry> = {};
+    const pairs = StringPrototypeSplit(baggageString, BAGGAGE_ITEMS_SEPARATOR);
+    ArrayPrototypeForEach(pairs, (entry) => {
+      const keyPair = parsePairKeyValue(entry);
+      if (keyPair) {
+        const baggageEntry: BaggageEntry = { value: keyPair.value };
+        if (keyPair.metadata) {
+          baggageEntry.metadata = keyPair.metadata;
+        }
+        baggage[keyPair.key] = baggageEntry;
+      }
+    });
+    if (ObjectEntries(baggage).length === 0) {
+      return context;
+    }
+
+    return context.setValue(
+      BAGGAGE_KEY,
+      new BaggageImpl(new SafeMap(ObjectEntries(baggage))),
+    );
+  }
+
+  fields(): string[] {
+    return [BAGGAGE_HEADER];
+  }
+}
+
+class CompositePropagator implements TextMapPropagator {
+  #propagators: TextMapPropagator[];
+  #fields: string[];
+
+  constructor(propagators: TextMapPropagator[]) {
+    this.#propagators = propagators;
+    this.#fields = ArrayFrom(
+      new SafeSet(
+        ArrayPrototypeReduce(
+          ArrayPrototypeMap(
+            this.#propagators,
+            (p) => p.fields(),
+          ),
+          (x, y) => ArrayPrototypeConcat(x, y),
+          [],
+        ),
+      ),
+    );
+  }
+
+  inject(context: Context, carrier: unknown, setter: TextMapSetter): void {
+    for (const propagator of new SafeArrayIterator(this.#propagators)) {
+      try {
+        propagator.inject(context, carrier, setter);
+      } catch (err) {
+        // deno-lint-ignore no-console
+        console.warn(
+          `Failed to inject with ${propagator.constructor.name}.`,
+          err,
+        );
+      }
+    }
+  }
+
+  extract(context: Context, carrier: unknown, getter: TextMapGetter): Context {
+    return ArrayPrototypeReduce(this.#propagators, (ctx, propagator) => {
+      try {
+        return propagator.extract(ctx, carrier, getter);
+      } catch (err) {
+        // deno-lint-ignore no-console
+        console.warn(
+          `Failed to extract with ${propagator.constructor.name}.`,
+          err,
+        );
+      }
+      return ctx;
+    }, context);
+  }
+
+  fields(): string[] {
+    return ArrayPrototypeSlice(this.#fields);
+  }
+}
+
+let builtinTracerCache: Tracer;
+
+function builtinTracer(): Tracer {
+  if (!builtinTracerCache) {
+    builtinTracerCache = new Tracer(OtelTracer.builtin());
+  }
+  return builtinTracerCache;
+}
+
+function enableIsolateMetrics() {
+  op_otel_enable_isolate_metrics();
+  ISOLATE_METRICS = true;
+  startObserving();
+}
+
+// We specify a very high version number, to allow any `@opentelemetry/api`
+// version to load this module. This does cause @opentelemetry/api to not be
+// able to register anything itself with the global registration methods.
+const OTEL_API_COMPAT_VERSION = "1.999.999";
+
+function bootstrap(
+  config: [
+    0 | 1,
+    0 | 1,
+    (typeof otelConsoleConfig)[keyof typeof otelConsoleConfig],
+    ...Array<(typeof otelPropagators)[keyof typeof otelPropagators]>,
+  ],
+): void {
+  const {
+    0: tracingEnabled,
+    1: metricsEnabled,
+    2: consoleConfig,
+    ...propagators
+  } = config;
+
+  TRACING_ENABLED = tracingEnabled === 1;
+  METRICS_ENABLED = metricsEnabled === 1;
+
+  PROPAGATORS = ArrayPrototypeMap(
+    ArrayPrototypeFilter(
+      ObjectValues(propagators),
+      (propagator) => propagator !== otelPropagators.none,
+    ),
+    (propagator) => {
+      switch (propagator) {
+        case otelPropagators.traceContext:
+          return new W3CTraceContextPropagator();
+        case otelPropagators.baggage:
+          return new W3CBaggagePropagator();
+      }
+    },
+  );
+
+  switch (consoleConfig) {
+    case otelConsoleConfig.capture: {
+      const otelConsole = new Console(otelLog);
+      wrapOtelConsoleMethods(otelConsole);
+      core.wrapConsole(globalThis.console, otelConsole);
+      break;
+    }
+    case otelConsoleConfig.replace: {
+      const otelConsole = new Console(otelLog);
+      wrapOtelConsoleMethods(otelConsole);
+      ObjectDefineProperty(
+        globalThis,
+        "console",
+        core.propNonEnumerable(otelConsole),
+      );
+      break;
+    }
+    default:
+      break;
+  }
+
+  if (TRACING_ENABLED || METRICS_ENABLED || PROPAGATORS.length > 0) {
+    const otel = globalThis[SymbolFor("opentelemetry.js.api.1")] ??= {
+      version: OTEL_API_COMPAT_VERSION,
+    };
+    if (TRACING_ENABLED) {
+      otel.trace = TracerProvider;
+      otel.context = ContextManager;
+    }
+    if (METRICS_ENABLED) {
+      otel.metrics = MeterProvider;
+      enableIsolateMetrics();
+    }
+    if (PROPAGATORS.length > 0) {
+      otel.propagation = new CompositePropagator(PROPAGATORS);
+    }
+  }
+}
+
+internals.__telemetry = {
+  builtinTracer,
+  ContextManager,
+  enterSpan,
+  get PROPAGATORS() {
+    return PROPAGATORS;
+  },
+  restoreSnapshot,
+  get TRACING_ENABLED() {
+    return TRACING_ENABLED;
+  },
+};
+
+const telemetry = {
+  tracerProvider: TracerProvider,
+  contextManager: ContextManager,
+  meterProvider: MeterProvider,
+};
+
+// Mutable state container: consumers destructure a reference to this
+// object, so property access at call-time always reflects the latest
+// values set by bootstrap().
+const otelState = {
+  TRACING_ENABLED: false,
+  METRICS_ENABLED: false,
+  PROPAGATORS: [] as TextMapPropagator[],
+  getOtelSpan: undefined as
+    | ((span: object) => OtelSpan | null | undefined)
+    | undefined,
+};
+
+// Keep module-level variables in sync with otelState for internal use
+// (existing code references the bare names).
+const _origBootstrap = bootstrap;
+function wrappedBootstrap(config: Parameters<typeof bootstrap>[0]) {
+  _origBootstrap(config);
+  otelState.TRACING_ENABLED = TRACING_ENABLED;
+  otelState.METRICS_ENABLED = METRICS_ENABLED;
+  otelState.PROPAGATORS = PROPAGATORS;
+  otelState.getOtelSpan = getOtelSpan;
+}
+
+return {
+  otelState,
+  enterSpan,
+  currentSnapshot,
+  restoreSnapshot,
+  SPAN_KEY,
+  Span,
+  ContextManager,
+  baggageEntryMetadataFromString,
+  W3CBaggagePropagator,
+  CompositePropagator,
+  builtinTracer,
+  bootstrap: wrappedBootstrap,
+  telemetry,
+};
+})();
diff --git a/crates/js-runtime/third-party/deno-telemetry/util.ts b/crates/js-runtime/third-party/deno-telemetry/util.ts
new file mode 100644
index 0000000000..f520f4ca90
--- /dev/null
+++ b/crates/js-runtime/third-party/deno-telemetry/util.ts
@@ -0,0 +1,88 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+
+(function () {
+const { internals, primordials } = __bootstrap;
+
+const { String, StringPrototypeSlice } = primordials;
+
+type Span = {
+  updateName(name: string): void;
+  setAttribute(key: string, value: string): void;
+  setStatus(status: { code: number; message: string }): void;
+};
+
+function updateSpanFromRequest(span: Span, request: Request) {
+  span.updateName(request.method);
+
+  span.setAttribute("http.request.method", request.method);
+  const url = new URL(request.url);
+  span.setAttribute("url.full", request.url);
+  span.setAttribute(
+    "url.scheme",
+    StringPrototypeSlice(url.protocol, 0, -1),
+  );
+  span.setAttribute("url.path", url.pathname);
+  span.setAttribute("url.query", StringPrototypeSlice(url.search, 1));
+}
+
+function setResponseAttributes(
+  span: Span,
+  response: Response,
+  errorThreshold: number,
+) {
+  span.setAttribute(
+    "http.response.status_code",
+    String(response.status),
+  );
+  if (response.status >= errorThreshold) {
+    span.setAttribute("error.type", String(response.status));
+    span.setStatus({ code: 2, message: response.statusText });
+  }
+}
+
+// Per OTel HTTP semantic conventions, client spans should have ERROR status
+// for all >= 400 responses.
+function updateSpanFromClientResponse(
+  span: Span,
+  response: Response,
+) {
+  setResponseAttributes(span, response, 400);
+}
+
+// Per OTel HTTP semantic conventions, server spans should only have ERROR
+// status for 5xx responses. 4xx responses are client errors, not server
+// errors.
+function updateSpanFromServerResponse(
+  span: Span,
+  response: Response,
+) {
+  setResponseAttributes(span, response, 500);
+}
+
+// deno-lint-ignore no-explicit-any
+function updateSpanFromError(span: Span, error: any) {
+  const errorType = error.name ?? "Error";
+  span.setAttribute("error.type", errorType);
+  span.setAttribute("exception.type", errorType);
+  if (error.message != null) {
+    span.setAttribute("exception.message", error.message);
+  }
+  if (error.stack != null) {
+    span.setAttribute("exception.stacktrace", error.stack);
+  }
+  span.setStatus({ code: 2, message: error.message ?? String(error) });
+}
+
+internals.__telemetryUtil = {
+  updateSpanFromClientResponse,
+  updateSpanFromError,
+  updateSpanFromRequest,
+};
+
+return {
+  updateSpanFromRequest,
+  updateSpanFromClientResponse,
+  updateSpanFromServerResponse,
+  updateSpanFromError,
+};
+})();
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 4f283189f1..cbbf61e1fa 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -54,6 +54,9 @@ socket on Linux/macOS or a private named pipe on Windows. An optional
 `--websocket 127.0.0.1:0` listener requires authentication; TLS is not implemented.
 Development builds retain line-number backtraces; `CARGO_PROFILE_DEV_DEBUG=full`
 enables full debugger information.
+Windows MSVC builds use the static CRT required by the official V8 archive.
+The two vendored Deno TypeScript files must match `deno_telemetry` exactly;
+update them together when upgrading that dependency. They avoid a build-time V8.
 
 `host install --root <directory>` pins the current executable and on-demand policy;
 `--mode supervised` selects persistent serving. Only the returned `executable`
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 54e677ee82..74b138f3ba 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -49,6 +49,9 @@ maka host retire --root /absolute/path/to/new-root
 二进制未加入 PATH 时使用 `target/debug/maka`。Linux/macOS 使用 Unix socket,
 Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要认证,尚不支持 TLS。
 开发构建保留文件与行号回溯;`CARGO_PROFILE_DEV_DEBUG=full` 可启用完整调试信息。
+Windows MSVC 构建使用与官方 V8 静态库一致的静态 CRT。
+两份随仓库保存的 Deno TypeScript 须与 `deno_telemetry` 完全一致,升级依赖时须同步;
+构建直接转译它们,不再加载构建期 V8。
 
 `host install --root <目录>` 固定当前可执行文件和按需策略;`--mode supervised` 选择持续运行。
 此后只有返回的 `executable` 可以启动该托管根。`host activate --root-id <rootId> --framed`
diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs
index fb82a14ea9..d1fa8b8071 100644
--- a/scripts/asf-license-headers.mjs
+++ b/scripts/asf-license-headers.mjs
@@ -226,6 +226,9 @@ export const exclusionRules = [
         'crates/apply-patch/src/seek_sequence.rs',
         'crates/apply-patch/src/text_file.rs',
         'crates/apply-patch/src/update.rs',
+        // Verbatim Deno MIT sources; exact revision and license in LICENSE.
+        'crates/js-runtime/third-party/deno-telemetry/telemetry.ts',
+        'crates/js-runtime/third-party/deno-telemetry/util.ts',
         // Adapted from Vercel AI SDK material; recorded by the #2907 origin audit.
         'packages/runtime/src/model-protocol.ts',
         'packages/eval/harbor/deepseek-harness-profile/cordis.patch.yml',
diff --git a/scripts/rust/bundle-providers.mjs b/scripts/rust/bundle-providers.mjs
index 2ace6f764d..bd44cb85d2 100644
--- a/scripts/rust/bundle-providers.mjs
+++ b/scripts/rust/bundle-providers.mjs
@@ -18,7 +18,7 @@
  */
 
 import { createRequire } from 'node:module';
-import { resolve, join, basename } from 'node:path';
+import { resolve, join } from 'node:path';
 import { writeFile } from 'node:fs/promises';
 import { readFileSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
@@ -61,15 +61,17 @@ await build({
   ],
   legalComments: 'eof',
 });
-// Transpile Deno's embedded TypeScript at build time; no compiler or npm
-// resolution is installed in the running host.
-let input = '';
-for await (const chunk of process.stdin) input += chunk;
-for (const [specifier, source] of Object.entries(JSON.parse(input))) {
+// Tests check these unmodified sources against the locked Deno extension.
+// Transpilation needs neither a build-time V8 nor a compiler in the running host.
+for (const name of ['telemetry', 'util']) {
+  const source = readFileSync(
+    join(root, 'crates/js-runtime/third-party/deno-telemetry', `${name}.ts`),
+    'utf8',
+  );
   const { code } = await transform(source, {
     loader: 'ts',
     target: 'es2024',
     legalComments: 'inline',
   });
-  await writeFile(join(output, basename(specifier).replace(/\.ts$/, '.js')), code);
+  await writeFile(join(output, `${name}.js`), code);
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 22/29: feat(host): expose read-only deployment status and bounded diagnostics

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit c9d1eff8a6c24544189c8203cb75b7145f0b9d19
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 22:12:31 2026 +0800

    feat(host): expose read-only deployment status and bounded diagnostics
    
    Generated-by: OpenAI Codex
---
 crates/cli/Cargo.toml                        |   2 +-
 crates/cli/src/args.rs                       |  42 ++----
 crates/cli/src/deployment.rs                 |  18 ++-
 crates/cli/src/deployment/entry.rs           |  48 ++++++
 crates/cli/src/deployment/logs.rs            | 218 +++++++++++++++++++++++++++
 crates/cli/src/deployment/query.rs           | 182 ++++++++++++++++++++++
 crates/cli/src/deployment/service.rs         |  18 ++-
 crates/cli/src/deployment/service/linux.rs   |  38 +++++
 crates/cli/src/deployment/service/macos.rs   |  45 +++++-
 crates/cli/src/deployment/service/status.rs  |  69 +++++++++
 crates/cli/src/deployment/service/windows.rs |  34 ++++-
 crates/cli/src/deployment/updates.rs         |   2 +-
 crates/cli/src/main.rs                       |   6 +-
 crates/cli/src/serve.rs                      |  11 +-
 crates/cli/src/windows.rs                    |  70 +++++++++
 crates/cli/tests/integration/deployment.rs   |  94 ++++++++++--
 crates/protocol/src/handshake.rs             |   2 +-
 docs/rust-runtime.md                         |   7 +
 docs/rust-runtime.zh-CN.md                   |   5 +
 19 files changed, 844 insertions(+), 67 deletions(-)

diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 420cadd88e..1899c46650 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -62,4 +62,4 @@ libc.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_TaskScheduler", "Win32_System_Variant", "Win32_System_Ole"] }
-windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
+windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading"] }
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 9b7f51445c..c3a46e826d 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -63,8 +63,10 @@ enum HostCommand {
     Restart(crate::deployment::Control),
     /// Revoke startup and unregister its service, retaining State Root data.
     Uninstall(crate::deployment::Control),
-    /// Query the existing Host without opening its State Root for writing.
-    Status(Root),
+    /// Observe a Host or managed deployment without starting or changing it.
+    Status(crate::deployment::Status),
+    /// Read a bounded tail of supervised Host diagnostics without starting it.
+    Logs(crate::deployment::Logs),
     /// Retire the exact current Host, preserving work at safe step boundaries.
     Retire {
         #[command(flatten)]
@@ -79,7 +81,7 @@ enum HostCommand {
     Candidate(candidate::Candidate),
     /// Run only the currently admitted supervised deployment.
     #[command(hide = true)]
-    ServiceRun(Root),
+    ServiceRun(crate::deployment::ServiceRun),
     /// Serve a native State Root over a private local endpoint.
     Serve {
         #[command(flatten)]
@@ -102,25 +104,6 @@ struct Log {
 }
 
 impl Cli {
-    pub(super) async fn run_service(self) -> Result<(), HostError> {
-        match self.command {
-            Command::Host(HostCommand::Serve {
-                root,
-                websocket: None,
-            }) => {
-                #[cfg(windows)]
-                crate::windows::own_process_tree()?;
-                serve::run(&root.root, None, true).await
-            }
-            Command::Host(HostCommand::Serve { .. }) => {
-                Err("service listener belongs to deployment configuration".into())
-            }
-            // The installed artifact also remains a usable captured-stdio
-            // operator for SSH/WSL; the interactive console command is maka.
-            command => Self { command }.run().await,
-        }
-    }
-
     pub(super) fn error_exit_code(&self) -> u8 {
         if matches!(self.command, Command::Host(HostCommand::Candidate(_))) {
             70
@@ -151,20 +134,13 @@ impl Cli {
                 Ok(())
             }
             Command::Host(HostCommand::Serve { root, websocket }) => {
-                serve::run(&root.root, websocket, false).await
-            }
-            Command::Host(HostCommand::ServiceRun(root)) => {
                 #[cfg(windows)]
                 crate::windows::own_process_tree()?;
-                serve::run(&root.root, None, true).await
-            }
-            Command::Host(HostCommand::Status(root)) => {
-                let mut client = crate::host_client::HostClient::connect(&root.root, None).await?;
-                let status = client.status().await?;
-                drop(client);
-                println!("{}", serde_json::to_string(&status)?);
-                Ok(())
+                serve::run(&root.root, websocket, None).await
             }
+            Command::Host(HostCommand::ServiceRun(args)) => args.run().await,
+            Command::Host(HostCommand::Status(args)) => args.run().await,
+            Command::Host(HostCommand::Logs(args)) => args.run().await,
             Command::Host(HostCommand::Retire {
                 root,
                 expected_host_epoch,
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index ac550c9fcb..7e4a9146e2 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -19,13 +19,19 @@
 
 mod activation;
 mod control;
+mod entry;
+mod logs;
 mod package;
+mod query;
 mod service;
 mod store;
 mod update;
 mod updates;
 pub(super) use activation::Activate;
 pub(super) use control::{Control, ControlAction};
+pub(super) use entry::ServiceRun;
+pub(super) use logs::Logs;
+pub(super) use query::Status;
 pub(super) use update::Update;
 
 use clap::{Args, ValueEnum};
@@ -120,8 +126,16 @@ impl Deployment {
     }
 
     fn validate(&self, root: &RootLocation, directory: &Path) -> Result<(), HostError> {
-        if self.root_id != root.root_id()
-            || self.root_path != root.canonical_path()
+        self.validate_record(directory)?;
+        if self.root_id != root.root_id() || self.root_path != root.canonical_path() {
+            return Err("deployment does not match its native root".into());
+        }
+        Ok(())
+    }
+
+    fn validate_record(&self, directory: &Path) -> Result<(), HostError> {
+        if self.root_id.parse::<RootId>().is_err()
+            || !self.root_path.is_absolute()
             || !(1..=9_007_199_254_740_991).contains(&self.config_revision)
             || self.sha256.len() != 64
             || !self
diff --git a/crates/cli/src/deployment/entry.rs b/crates/cli/src/deployment/entry.rs
new file mode 100644
index 0000000000..41f88bca37
--- /dev/null
+++ b/crates/cli/src/deployment/entry.rs
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{RootId, directory, store};
+use clap::Args;
+use maka_runtime_host::server::HostError;
+
+#[derive(Args)]
+pub(crate) struct ServiceRun {
+    #[arg(long)]
+    root_id: RootId,
+}
+
+impl ServiceRun {
+    pub async fn run(self) -> Result<(), HostError> {
+        let directory = directory(&self.root_id.0)?;
+        #[cfg(windows)]
+        {
+            crate::windows::service_stderr(&directory)?;
+            crate::windows::own_process_tree()?;
+        }
+        let store::Installation::Installed(deployment) = store::read(&directory).await? else {
+            return Err("service deployment is absent or incomplete".into());
+        };
+        if deployment.root_id != self.root_id.0 {
+            return Err("service deployment root identity differs".into());
+        }
+        // This read only locates Root. Admission is repeated after acquiring the
+        // actual writer lease, so a waiting process cannot reuse an old grant.
+        crate::serve::run(&deployment.root_path, None, Some(&self.root_id.0)).await
+    }
+}
diff --git a/crates/cli/src/deployment/logs.rs b/crates/cli/src/deployment/logs.rs
new file mode 100644
index 0000000000..80a7a88ae0
--- /dev/null
+++ b/crates/cli/src/deployment/logs.rs
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, Mode, RootId, directory, store};
+use clap::Args;
+use maka_runtime_host::server::HostError;
+use serde::Serialize;
+
+const LIMIT: usize = 48 * 1024;
+
+#[derive(Args)]
+pub(crate) struct Logs {
+    #[arg(long)]
+    root_id: RootId,
+}
+
+#[derive(Serialize)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase"
+)]
+enum Output {
+    NotCaptured,
+    Tail {
+        source: Source,
+        text: String,
+        byte_truncated: bool,
+    },
+}
+
+#[derive(Serialize)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase"
+)]
+enum Source {
+    #[cfg(target_os = "linux")]
+    Journal { entry_limit: usize },
+    #[cfg(any(target_os = "macos", windows))]
+    Stderr,
+}
+
+impl Logs {
+    pub async fn run(self) -> Result<(), HostError> {
+        let directory = directory(&self.root_id.0)?;
+        let store::Installation::Installed(deployment) = store::read(&directory).await? else {
+            return Err("State Root has no installed deployment".into());
+        };
+        if deployment.root_id != self.root_id.0 {
+            return Err("deployment root identity differs".into());
+        }
+        deployment.validate_record(&directory)?;
+        let output = if deployment.mode == Mode::OnDemand {
+            Output::NotCaptured
+        } else {
+            read(&deployment).await?
+        };
+        println!("{}", serde_json::to_string(&output)?);
+        Ok(())
+    }
+}
+
+#[cfg(any(target_os = "macos", windows))]
+async fn read(deployment: &Deployment) -> Result<Output, HostError> {
+    let directory = directory(&deployment.root_id)?;
+    tokio::task::spawn_blocking(move || {
+        use std::io::{Read, Seek, SeekFrom};
+        let path = directory.join("host.stderr.log");
+        #[cfg(target_os = "macos")]
+        let opened = {
+            use std::os::unix::fs::OpenOptionsExt;
+            std::fs::OpenOptions::new()
+                .read(true)
+                .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+                .open(&path)
+        };
+        #[cfg(windows)]
+        let opened = maka_event_log::root::windows::open_nofollow(&path, false);
+        let mut file = match opened {
+            Ok(file) => file,
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+                return Ok(Output::NotCaptured);
+            }
+            Err(error) => return Err(error.into()),
+        };
+        let metadata = file.metadata()?;
+        if !metadata.is_file() {
+            return Err("service log is not a regular file".into());
+        }
+        #[cfg(target_os = "macos")]
+        {
+            use std::os::unix::fs::MetadataExt;
+            if metadata.uid() != unsafe { libc::geteuid() }
+                || metadata.mode() & 0o077 != 0
+                || metadata.nlink() != 1
+            {
+                return Err("service log is not private and singly linked".into());
+            }
+        }
+        #[cfg(windows)]
+        {
+            use maka_event_log::root::windows::{file_identity, validate_private};
+            validate_private(&file)?;
+            if file_identity(&file)?.links != 1 {
+                return Err("service log is not singly linked".into());
+            }
+        }
+        // Freeze the end offset; an active writer must not make this a follow.
+        let start = metadata.len().saturating_sub(LIMIT as u64);
+        file.seek(SeekFrom::Start(start))?;
+        let mut bytes = Vec::with_capacity(LIMIT);
+        file.take(metadata.len() - start).read_to_end(&mut bytes)?;
+        Ok(Output::Tail {
+            source: Source::Stderr,
+            text: String::from_utf8_lossy(&bytes).into_owned(),
+            byte_truncated: start != 0,
+        })
+    })
+    .await?
+}
+
+#[cfg(target_os = "linux")]
+async fn read(deployment: &Deployment) -> Result<Output, HostError> {
+    use std::{process::Stdio, time::Duration};
+    const ENTRIES: usize = 200;
+    let mut child = tokio::process::Command::new("journalctl")
+        .args([
+            "--user",
+            "--no-pager",
+            "--quiet",
+            "--all",
+            "--output=short-iso-precise",
+        ])
+        .arg(format!(
+            "--user-unit=org.apache.maka.host.{}.service",
+            deployment.root_id
+        ))
+        .arg(format!("--lines={ENTRIES}"))
+        .stdin(Stdio::null())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .kill_on_drop(true)
+        .spawn()?;
+    let stdout = child.stdout.take().ok_or("journal stdout unavailable")?;
+    let stderr = child.stderr.take().ok_or("journal stderr unavailable")?;
+    let result = tokio::time::timeout(Duration::from_secs(10), async {
+        tokio::try_join!(tail(stdout, LIMIT), tail(stderr, 2048), child.wait())
+    })
+    .await;
+    let (stdout, stderr, status) = match result {
+        Ok(Ok(output)) => output,
+        result => {
+            // Reap the query even on timeout or pipe failure; no pager is spawned.
+            let _ = child.kill().await;
+            return Err(match result {
+                Err(error) => error.into(),
+                Ok(Err(error)) => error.into(),
+                Ok(Ok(_)) => unreachable!(),
+            });
+        }
+    };
+    if !status.success() {
+        return Err(format!(
+            "journal query failed ({status}): {}",
+            String::from_utf8_lossy(&stderr.0)
+        )
+        .into());
+    }
+    Ok(Output::Tail {
+        source: Source::Journal {
+            entry_limit: ENTRIES,
+        },
+        text: String::from_utf8_lossy(&stdout.0).into_owned(),
+        byte_truncated: stdout.1,
+    })
+}
+
+#[cfg(target_os = "linux")]
+async fn tail(
+    mut input: impl tokio::io::AsyncRead + Unpin,
+    limit: usize,
+) -> std::io::Result<(Vec<u8>, bool)> {
+    use tokio::io::AsyncReadExt;
+    let mut bytes = std::collections::VecDeque::with_capacity(limit);
+    let mut buffer = [0; 8192];
+    let mut truncated = false;
+    loop {
+        let read = input.read(&mut buffer).await?;
+        if read == 0 {
+            break;
+        }
+        let overflow = (bytes.len() + read).saturating_sub(limit);
+        if overflow != 0 {
+            truncated = true;
+            bytes.drain(..overflow.min(bytes.len()));
+        }
+        bytes.extend(&buffer[read.saturating_sub(limit)..read]);
+    }
+    Ok((bytes.into(), truncated))
+}
diff --git a/crates/cli/src/deployment/query.rs b/crates/cli/src/deployment/query.rs
new file mode 100644
index 0000000000..b82ef74c77
--- /dev/null
+++ b/crates/cli/src/deployment/query.rs
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Admission, Deployment, RootId, directory, service, store, updates};
+use crate::host_client::{HostClient, LiveHost};
+use clap::Args;
+use maka_protocol::handshake::Lifecycle;
+use maka_runtime_host::server::HostError;
+use serde::{Deserialize, Serialize};
+use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions};
+use std::path::{Path, PathBuf};
+
+#[derive(Args)]
+#[group(required = true, multiple = false)]
+pub(crate) struct Status {
+    #[arg(long, value_name = "DIRECTORY")]
+    root: Option<PathBuf>,
+    #[arg(long)]
+    root_id: Option<RootId>,
+}
+
+#[derive(Serialize)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase"
+)]
+enum Observation {
+    NotInstalled,
+    Incomplete,
+    Installed(Box<Installed>),
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Installed {
+    deployment: Deployment,
+    pending_update: Option<Deployment>,
+    supervisor: service::Observation,
+    host: HostObservation,
+}
+
+#[derive(Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum HostObservation {
+    Connected {
+        identity: LiveHost,
+        activity: Activity,
+    },
+    Unavailable {
+        message: String,
+    },
+    NotAdmitted,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Activity {
+    state: Lifecycle,
+    connections: usize,
+    active_operations: usize,
+    active_residencies: usize,
+}
+
+impl Status {
+    pub async fn run(self) -> Result<(), HostError> {
+        if let Some(root) = self.root {
+            let mut client = HostClient::connect(&root, None).await?;
+            println!("{}", serde_json::to_string(&client.status().await?)?);
+            return Ok(());
+        }
+        let id = self.root_id.ok_or("missing State Root target")?;
+        let observation = observe(&id).await?;
+        println!("{}", serde_json::to_string(&observation)?);
+        Ok(())
+    }
+}
+
+async fn observe(id: &RootId) -> Result<Observation, HostError> {
+    let directory = match directory(&id.0) {
+        Ok(directory) => directory,
+        Err(error)
+            if error
+                .downcast_ref::<std::io::Error>()
+                .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
+        {
+            return Ok(Observation::NotInstalled);
+        }
+        Err(error) => return Err(error),
+    };
+    let observation = match store::read(&directory).await? {
+        store::Installation::Missing => Observation::NotInstalled,
+        store::Installation::Incomplete => Observation::Incomplete,
+        store::Installation::Installed(deployment) => {
+            if deployment.root_id != id.0 {
+                return Err("deployment root identity differs".into());
+            }
+            deployment.validate_record(&directory)?;
+            let pending_update = pending(&directory, &deployment).await?;
+            let (supervisor, host) = tokio::join!(
+                service::observe(deployment.clone()),
+                observe_host(&deployment),
+            );
+            // Only configuration is bracketed. OS and Host observations can
+            // differ in time, including a restart that keeps this revision.
+            match store::read(&directory).await? {
+                store::Installation::Installed(after) if after == deployment => {}
+                _ => return Err("deployment changed during observation; query again".into()),
+            }
+            Observation::Installed(Box::new(Installed {
+                deployment,
+                pending_update,
+                supervisor,
+                host,
+            }))
+        }
+    };
+    Ok(observation)
+}
+
+async fn observe_host(deployment: &Deployment) -> HostObservation {
+    if deployment.admission == Admission::Revoked {
+        return HostObservation::NotAdmitted;
+    }
+    let result = async {
+        let mut client =
+            HostClient::connect(&deployment.root_path, Some(&deployment.generation())).await?;
+        let identity = client.live_host(deployment.websocket.port()).await?;
+        let activity = serde_json::from_value(client.status().await?)?;
+        Ok::<_, HostError>(HostObservation::Connected { identity, activity })
+    }
+    .await;
+    result.unwrap_or_else(|error| HostObservation::Unavailable {
+        message: error.to_string().chars().take(2048).collect(),
+    })
+}
+
+async fn pending(directory: &Path, current: &Deployment) -> Result<Option<Deployment>, HostError> {
+    // Earlier installed operators have only the Active table. Observation must
+    // not run migrations or create a pending table on their behalf.
+    let mut connection = SqliteConnection::connect_with(
+        &SqliteConnectOptions::new()
+            .filename(directory.join("deployment.sqlite"))
+            .read_only(true),
+    )
+    .await?;
+    let result = async {
+        let exists: bool = sqlx::query_scalar(
+            "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='deployment_update')",
+        ).fetch_one(&mut connection).await?;
+        if !exists { return Ok(None) }
+        let value: Option<String> = sqlx::query_scalar(
+            "SELECT CASE WHEN length(CAST(target AS BLOB)) <= 65536 THEN target ELSE '' END FROM deployment_update WHERE singleton = 1",
+        ).fetch_optional(&mut connection).await?;
+        let target = value.map(|value| serde_json::from_str::<Deployment>(&value)).transpose()?;
+        if let Some(target) = &target {
+            target.validate_record(directory)?;
+            updates::validate_target(current, target)?;
+        }
+        Ok::<_, HostError>(target)
+    }.await;
+    let closed = connection.close().await;
+    let target = result?;
+    closed?;
+    Ok(target)
+}
diff --git a/crates/cli/src/deployment/service.rs b/crates/cli/src/deployment/service.rs
index 1de2614b8c..8d1f194f84 100644
--- a/crates/cli/src/deployment/service.rs
+++ b/crates/cli/src/deployment/service.rs
@@ -22,6 +22,10 @@ use maka_event_log::root::{FileLease, RootOwner};
 use maka_runtime_host::server::HostError;
 use std::sync::Arc;
 
+mod status;
+use status::State;
+pub(super) use status::{Observation, observe};
+
 #[cfg(target_os = "linux")]
 mod linux;
 #[cfg(target_os = "linux")]
@@ -109,14 +113,16 @@ fn arguments(deployment: &Deployment) -> Result<[&str; 5], HostError> {
         .executable
         .to_str()
         .ok_or("service executable must be UTF-8")?;
-    let root = deployment
-        .root_path
-        .to_str()
-        .ok_or("service root must be UTF-8")?;
-    if executable.chars().chain(root.chars()).any(char::is_control) {
+    if executable.chars().any(char::is_control) {
         return Err("service paths cannot contain control characters".into());
     }
-    Ok([executable, "host", "service-run", "--root", root])
+    Ok([
+        executable,
+        "host",
+        "service-run",
+        "--root-id",
+        &deployment.root_id,
+    ])
 }
 
 fn label(deployment: &Deployment) -> String {
diff --git a/crates/cli/src/deployment/service/linux.rs b/crates/cli/src/deployment/service/linux.rs
index 314840bcff..1a3ff594b0 100644
--- a/crates/cli/src/deployment/service/linux.rs
+++ b/crates/cli/src/deployment/service/linux.rs
@@ -128,6 +128,44 @@ impl Service {
         checked(command("systemctl", &["--user", "start", &self.unit])?)?;
         Ok(())
     }
+
+    pub fn observe(&self) -> Result<super::Observation, HostError> {
+        use super::{Observation, State};
+        let output = checked(command(
+            "systemctl",
+            &[
+                "--user",
+                "show",
+                "--property=LoadState,ActiveState,UnitFileState,MainPID,ExecMainStatus",
+                &self.unit,
+            ],
+        )?)?;
+        let fields: std::collections::BTreeMap<_, _> = output
+            .lines()
+            .filter_map(|line| line.split_once('='))
+            .collect();
+        let field = |key| fields.get(key).copied().ok_or("incomplete systemd status");
+        if field("LoadState")? == "not-found" {
+            return Ok(Observation::Missing);
+        }
+        let state = match field("ActiveState")? {
+            "active" | "reloading" => State::Running,
+            "activating" => State::Starting,
+            "deactivating" => State::Stopping,
+            "inactive" => State::Stopped,
+            "failed" => State::Failed,
+            _ => return Err("unrecognized systemd activity state".into()),
+        };
+        Ok(Observation::Present {
+            state,
+            enabled: Some(matches!(
+                field("UnitFileState")?,
+                "enabled" | "enabled-runtime"
+            )),
+            pid: std::num::NonZeroU32::new(field("MainPID")?.parse()?),
+            last_result: Some(field("ExecMainStatus")?.parse()?),
+        })
+    }
 }
 
 fn quote(value: &str) -> String {
diff --git a/crates/cli/src/deployment/service/macos.rs b/crates/cli/src/deployment/service/macos.rs
index 0265e5fa19..60f564ffe7 100644
--- a/crates/cli/src/deployment/service/macos.rs
+++ b/crates/cli/src/deployment/service/macos.rs
@@ -33,6 +33,7 @@ pub(super) struct Service {
 
 struct Instance {
     pid: Option<libc::pid_t>,
+    last_result: i64,
 }
 
 impl Service {
@@ -81,12 +82,17 @@ impl Service {
         Ok(())
     }
 
-    pub fn stop(&self) -> Result<(), HostError> {
+    fn require_domain(&self) -> Result<(), HostError> {
         if self.in_user_domain(&["managername"])?.trim() != "Aqua"
             || self.in_user_domain(&["manageruid"])?.trim() != self.uid
         {
             return Err("the account GUI launchd domain is unavailable".into());
         }
+        Ok(())
+    }
+
+    pub fn stop(&self) -> Result<(), HostError> {
+        self.require_domain()?;
         // Unlike print's diagnostic format, list's three columns are documented.
         if let Some(instance) = self.instance()? {
             // Root is held, so even a racing automatic launch cannot execute work.
@@ -131,7 +137,10 @@ impl Service {
                             .ok_or("invalid launchd service PID")?,
                     ),
                 };
-                return Ok(Some(Instance { pid }));
+                return Ok(Some(Instance {
+                    pid,
+                    last_result: columns[1].parse()?,
+                }));
             }
         }
         Ok(None)
@@ -142,6 +151,38 @@ impl Service {
         checked(command("/bin/launchctl", &["kickstart", &self.target])?)?;
         Ok(())
     }
+
+    pub fn observe(&self) -> Result<super::Observation, HostError> {
+        use super::{Observation, State};
+        self.require_domain()?;
+        if let Some(instance) = self.instance()? {
+            return Ok(Observation::Present {
+                state: if instance.pid.is_some() {
+                    State::Running
+                } else if instance.last_result == 0 {
+                    State::Stopped
+                } else {
+                    State::Failed
+                },
+                enabled: None,
+                pid: instance
+                    .pid
+                    .and_then(|pid| std::num::NonZeroU32::new(pid as u32)),
+                last_result: Some(instance.last_result),
+            });
+        }
+        match self.path.symlink_metadata() {
+            Ok(metadata) if metadata.is_file() => Ok(Observation::Present {
+                state: State::Stopped,
+                enabled: None,
+                pid: None,
+                last_result: None,
+            }),
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Observation::Missing),
+            Err(error) => Err(error.into()),
+            Ok(_) => Err("service definition is not a regular file".into()),
+        }
+    }
 }
 
 fn process_exists(pid: libc::pid_t) -> bool {
diff --git a/crates/cli/src/deployment/service/status.rs b/crates/cli/src/deployment/service/status.rs
new file mode 100644
index 0000000000..870911b360
--- /dev/null
+++ b/crates/cli/src/deployment/service/status.rs
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, HostError, platform};
+use serde::Serialize;
+use std::num::NonZeroU32;
+
+#[derive(Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub(crate) enum Observation {
+    OnDemand,
+    Missing,
+    Present {
+        state: State,
+        enabled: Option<bool>,
+        pid: Option<NonZeroU32>,
+        #[serde(rename = "lastResult")]
+        last_result: Option<i64>,
+    },
+    Unavailable {
+        message: String,
+    },
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum State {
+    Stopped,
+    #[cfg(any(target_os = "linux", windows))]
+    Starting,
+    Running,
+    #[cfg(target_os = "linux")]
+    Stopping,
+    #[cfg(unix)]
+    Failed,
+}
+
+pub(crate) async fn observe(deployment: Deployment) -> Observation {
+    if deployment.mode == super::super::Mode::OnDemand {
+        return Observation::OnDemand;
+    }
+    let result =
+        tokio::task::spawn_blocking(move || platform::Service::new(&deployment)?.observe())
+            .await
+            .map_err(HostError::from)
+            .and_then(|result| result);
+    match result {
+        Ok(observed) => observed,
+        Err(error) => Observation::Unavailable {
+            message: error.to_string().chars().take(2048).collect(),
+        },
+    }
+}
diff --git a/crates/cli/src/deployment/service/windows.rs b/crates/cli/src/deployment/service/windows.rs
index 72d28fd62d..7075a4c235 100644
--- a/crates/cli/src/deployment/service/windows.rs
+++ b/crates/cli/src/deployment/service/windows.rs
@@ -29,7 +29,8 @@ use windows::{
             },
             TaskScheduler::{
                 IRegisteredTask, ITaskFolder, ITaskService, TASK_CREATE_OR_UPDATE,
-                TASK_LOGON_INTERACTIVE_TOKEN, TASK_STATE_DISABLED, TaskScheduler,
+                TASK_LOGON_INTERACTIVE_TOKEN, TASK_STATE_DISABLED, TASK_STATE_QUEUED,
+                TASK_STATE_READY, TASK_STATE_RUNNING, TaskScheduler,
             },
             Variant::VARIANT,
         },
@@ -170,6 +171,37 @@ impl Service {
         Ok(())
     }
 
+    pub fn observe(&self) -> Result<super::Observation, HostError> {
+        use super::{Observation, State};
+        let Some(task) = self.task()? else {
+            return Ok(Observation::Missing);
+        };
+        // SAFETY: observation stays on this object's COM apartment thread.
+        unsafe {
+            let last_result = if task.LastRunTime()? > 0.0 {
+                Some(i64::from(task.LastTaskResult()?))
+            } else {
+                None
+            };
+            let state = match task.State()? {
+                TASK_STATE_RUNNING => State::Running,
+                TASK_STATE_QUEUED => State::Starting,
+                // LastTaskResult also contains successful scheduler statuses
+                // such as user termination; it is not a process exit code.
+                TASK_STATE_READY | TASK_STATE_DISABLED => State::Stopped,
+                _ => return Err("unrecognized scheduled task state".into()),
+            };
+            Ok(Observation::Present {
+                state,
+                enabled: Some(task.Enabled()?.as_bool()),
+                // Task engine PID is not the Host PID; only the live handshake
+                // and diagnostics identify the actual execution owner.
+                pid: None,
+                last_result,
+            })
+        }
+    }
+
     fn task(&self) -> Result<Option<IRegisteredTask>, HostError> {
         // SAFETY: retrieval and ownership validation share this COM apartment.
         unsafe {
diff --git a/crates/cli/src/deployment/updates.rs b/crates/cli/src/deployment/updates.rs
index e5f767a852..9c93a1bf91 100644
--- a/crates/cli/src/deployment/updates.rs
+++ b/crates/cli/src/deployment/updates.rs
@@ -107,7 +107,7 @@ pub(super) async fn commit(
     Ok(())
 }
 
-fn validate_target(current: &Deployment, target: &Deployment) -> Result<(), StoreError> {
+pub(super) fn validate_target(current: &Deployment, target: &Deployment) -> Result<(), StoreError> {
     let mut expected = target.clone();
     expected.executable = current.executable.clone();
     expected.sha256 = current.sha256.clone();
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 080f6c3b13..d50ec1fc8b 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -33,11 +33,7 @@ mod windows;
 async fn main() -> std::process::ExitCode {
     let cli = args::Cli::parse();
     let error_exit = cli.error_exit_code();
-    let result = if env!("CARGO_BIN_NAME") == "maka-service" {
-        cli.run_service().await
-    } else {
-        cli.run().await
-    };
+    let result = cli.run().await;
     if let Err(error) = result {
         eprintln!("{error}");
         // Let Tokio wait for accepted blocking work to finish before the OS
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index 2c65284f8a..c6f543174b 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -24,7 +24,7 @@ use tokio_util::sync::CancellationToken;
 pub(super) async fn run(
     path: &Path,
     websocket: Option<std::net::SocketAddr>,
-    require_managed: bool,
+    expected_root: Option<&str>,
 ) -> Result<(), maka_runtime_host::server::HostError> {
     use maka_event_log::root::{ROOT_MARKER, RootNamespaces, RootOwner};
     use maka_runtime_host::server::{Host, websocket::WebSocketListener};
@@ -35,7 +35,7 @@ pub(super) async fn run(
             match RootOwner::open(path, &namespaces) {
                 Ok(owner) => break owner,
                 Err(error)
-                    if require_managed
+                    if expected_root.is_some()
                         && error.kind() == std::io::ErrorKind::WouldBlock
                         && tokio::time::Instant::now() < deadline =>
                 {
@@ -46,13 +46,16 @@ pub(super) async fn run(
                 Err(error) => return Err(error.into()),
             }
         }
-    } else if !require_managed {
+    } else if expected_root.is_none() {
         RootOwner::create(path, &namespaces)?
     } else {
         return Err("service State Root must already be installed".into());
     };
+    if expected_root.is_some_and(|expected| expected != owner.root_id()) {
+        return Err("service State Root identity changed".into());
+    }
     let deployment = crate::deployment::admit(&owner, crate::deployment::Mode::Supervised).await?;
-    if require_managed && deployment.is_none() {
+    if expected_root.is_some() && deployment.is_none() {
         return Err("service State Root must have a supervised deployment".into());
     }
     let websocket = match deployment
diff --git a/crates/cli/src/windows.rs b/crates/cli/src/windows.rs
index fd64ccb6d6..fc668ddbab 100644
--- a/crates/cli/src/windows.rs
+++ b/crates/cli/src/windows.rs
@@ -37,6 +37,76 @@ use windows_sys::Win32::System::{
 // the Host exits, terminating descendants without killing the Host during Drop.
 static OWNER: OnceLock<io::Result<OwnedHandle>> = OnceLock::new();
 
+// SetStdHandle borrows the file handle. Retain it until process exit, including
+// final error reporting and Tokio's shutdown of accepted blocking work.
+static SERVICE_STDERR: OnceLock<io::Result<std::fs::File>> = OnceLock::new();
+
+pub(super) fn service_stderr(directory: &std::path::Path) -> io::Result<()> {
+    SERVICE_STDERR
+        .get_or_init(|| open_service_stderr(directory))
+        .as_ref()
+        .map(|_| ())
+        .map_err(|error| io::Error::new(error.kind(), error.to_string()))
+}
+
+fn open_service_stderr(directory: &std::path::Path) -> io::Result<std::fs::File> {
+    use maka_event_log::root::windows::{
+        PrivateSecurity, file_identity, open_nofollow, validate_private,
+    };
+    use std::os::windows::{ffi::OsStrExt, fs::MetadataExt};
+    use windows_sys::Win32::{
+        Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE},
+        Storage::FileSystem::{
+            CreateFileW, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT,
+            FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
+            OPEN_ALWAYS,
+        },
+        System::Console::{STD_ERROR_HANDLE, SetStdHandle},
+    };
+
+    let parent = open_nofollow(directory, false)?;
+    validate_private(&parent)?;
+    if !parent.metadata()?.is_dir() {
+        return Err(io::Error::other("service deployment is not a directory"));
+    }
+    let path = directory.join("host.stderr.log");
+    let name: Vec<_> = path.as_os_str().encode_wide().chain(Some(0)).collect();
+    let security = PrivateSecurity::current_account()?;
+    // SAFETY: name and private descriptor live through the call. Append-only
+    // writes preserve concurrent startup diagnostics without following reparse points.
+    let handle = unsafe {
+        CreateFileW(
+            name.as_ptr(),
+            GENERIC_READ | FILE_APPEND_DATA,
+            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+            &security.attributes(),
+            OPEN_ALWAYS,
+            FILE_FLAG_OPEN_REPARSE_POINT,
+            ptr::null_mut(),
+        )
+    };
+    if handle == INVALID_HANDLE_VALUE {
+        return Err(io::Error::last_os_error());
+    }
+    // SAFETY: CreateFileW returned one owned file handle.
+    let file = unsafe { std::fs::File::from_raw_handle(handle) };
+    let metadata = file.metadata()?;
+    if !metadata.is_file()
+        || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
+        || file_identity(&file)?.links != 1
+    {
+        return Err(io::Error::other(
+            "service log is not a regular, singly linked file",
+        ));
+    }
+    validate_private(&file)?;
+    // SAFETY: SERVICE_STDERR owns the returned file until the process exits.
+    if unsafe { SetStdHandle(STD_ERROR_HANDLE, file.as_raw_handle()) } == 0 {
+        return Err(io::Error::last_os_error());
+    }
+    Ok(file)
+}
+
 pub(super) fn own_process_tree() -> io::Result<()> {
     OWNER
         .get_or_init(create)
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index 0dd0292660..49c2cee458 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -35,6 +35,16 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
     let namespaces = RootNamespaces::for_current_account().unwrap();
     for mode in ["on-demand", "supervised"] {
         let mut fixture = CandidateFixture::new(temporary.path().join(mode));
+        let query_id = fixture.root_id.clone();
+        let query = |action: &str| {
+            let output = Command::new(env!("CARGO_BIN_EXE_maka"))
+                .args(["host", action, "--root-id", &query_id])
+                .output()
+                .unwrap();
+            assert!(output.status.success(), "{output:?}");
+            serde_json::from_slice::<Value>(&output.stdout).unwrap()
+        };
+        assert_eq!(query("status")["kind"], "not_installed");
         let mut install = Command::new(env!("CARGO_BIN_EXE_maka"));
         install
             .args(["host", "install", "--root"])
@@ -63,23 +73,50 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         let executable = installed["executable"].as_str().unwrap();
         assert_eq!(installed["rootId"], fixture.root_id);
         assert_eq!(installed["configRevision"], 1);
+        let owner = RootOwner::open(&fixture.root, &namespaces).unwrap();
+        let observed = query("status");
+        assert_eq!(observed["deployment"], installed);
+        assert_eq!(observed["host"]["kind"], "unavailable");
+        assert!(observed["pendingUpdate"].is_null());
+        if mode == "on-demand" {
+            assert_eq!(observed["supervisor"]["kind"], "on_demand");
+            assert_eq!(query("logs")["kind"], "not_captured");
+        }
+        #[cfg(any(target_os = "macos", windows))]
+        if mode == "supervised" {
+            let path = directory.join("host.stderr.log");
+            #[cfg(windows)]
+            let mut file = maka_event_log::root::windows::create_private_file(&path).unwrap();
+            #[cfg(target_os = "macos")]
+            let mut file = {
+                use std::os::unix::fs::OpenOptionsExt;
+                std::fs::OpenOptions::new()
+                    .write(true)
+                    .create_new(true)
+                    .mode(0o600)
+                    .open(&path)
+                    .unwrap()
+            };
+            let contents = "old diagnostics\n".repeat(8192) + "latest failure\n";
+            file.write_all(contents.as_bytes()).unwrap();
+            drop(file);
+            let log = query("logs");
+            assert_eq!(log["source"]["kind"], "stderr");
+            assert_eq!(log["byteTruncated"], true);
+            assert_eq!(log["text"], contents[contents.len() - 48 * 1024..]);
+            assert_eq!(std::fs::read_to_string(path).unwrap(), contents);
+        }
+        assert!(!fixture.root.join("runtime-rust.sqlite").exists());
+        drop(owner);
 
         let launch_root = fixture.root.clone();
         let launch_root_id = fixture.root_id.clone();
         let command = |executable: &str, candidate: bool| {
             let mut command = Command::new(executable);
-            command
-                .args([
-                    "host",
-                    if candidate {
-                        "candidate"
-                    } else {
-                        "service-run"
-                    },
-                    "--root",
-                ])
-                .arg(&launch_root);
             if candidate {
+                command
+                    .args(["host", "candidate", "--root"])
+                    .arg(&launch_root);
                 command.args([
                     "--expected-root-id",
                     &launch_root_id,
@@ -89,6 +126,8 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
                     "--initial-connection-timeout-ms",
                     "300000",
                 ]);
+            } else {
+                command.args(["host", "service-run", "--root-id", &launch_root_id]);
             }
             command
         };
@@ -105,6 +144,28 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
             !wrong_mode.status.success(),
             "launch mode bypassed deployment policy"
         );
+        #[cfg(windows)]
+        if !candidate {
+            let log = directory.join("host.stderr.log");
+            let before = std::fs::read(&log).unwrap();
+            let marker = fixture.root.join(maka_event_log::root::ROOT_MARKER);
+            let backup = fixture.root.join("marker.backup");
+            std::fs::rename(&marker, &backup).unwrap();
+            let failed = command(executable, false).output();
+            std::fs::rename(&backup, &marker).unwrap();
+            let failed = failed.unwrap();
+            assert!(!failed.status.success());
+            let after = std::fs::read(log).unwrap();
+            assert!(
+                after.starts_with(&before),
+                "startup overwrote previous diagnostics"
+            );
+            assert!(
+                String::from_utf8_lossy(&after[before.len()..])
+                    .contains("service State Root must already be installed"),
+                "early failure was lost before main reported it"
+            );
+        }
         assert!(
             !fixture.root.join("runtime-rust.sqlite").exists(),
             "admission must precede migrations"
@@ -172,6 +233,14 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(connected["hostEpoch"], registration["hostEpoch"]);
         assert_eq!(connected["pid"], registration["pid"]);
         assert_eq!(connected["endpoint"]["port"], address.port());
+        let observed = query("status");
+        assert_eq!(observed["host"]["kind"], "connected");
+        assert_eq!(
+            observed["host"]["identity"]["hostEpoch"],
+            registration["hostEpoch"]
+        );
+        assert_eq!(observed["host"]["identity"]["pid"], registration["pid"]);
+        assert_eq!(observed["host"]["activity"]["state"], "ready");
         assert!(RootOwner::open(&fixture.root, &namespaces).is_err());
         let retired = Command::new(env!("CARGO_BIN_EXE_maka"))
             .args(["host", "retire", "--root"])
@@ -269,6 +338,9 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(uninstalled["deployment"]["admission"], "revoked");
         assert_eq!(uninstalled["deployment"]["configRevision"], 2);
         assert_eq!(uninstalled["cleanup"]["kind"], "complete");
+        let observed = query("status");
+        assert_eq!(observed["deployment"], uninstalled["deployment"]);
+        assert_eq!(observed["host"]["kind"], "not_admitted");
         let repeated = control("uninstall").output().unwrap();
         assert!(repeated.status.success(), "{repeated:?}");
         assert_eq!(
diff --git a/crates/protocol/src/handshake.rs b/crates/protocol/src/handshake.rs
index 09c99d07e2..918d998706 100644
--- a/crates/protocol/src/handshake.rs
+++ b/crates/protocol/src/handshake.rs
@@ -124,7 +124,7 @@ impl ClientHello {
     }
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum Lifecycle {
     Starting,
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index ef866ac4c8..4f283189f1 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -84,6 +84,13 @@ the service; it retains Root data, packages and a deployment tombstone. Retry th
 same uninstall if `cleanup.kind` is `pending`. Explicit installation grants a new
 deployment identity after the old service has been removed.
 
+`host status --root-id <rootId>` reads the deployment, pending update, OS service
+and live Host independently; it never starts or repairs them. An unavailable Host
+is not proof that its process stopped. `host logs --root-id <rootId>` returns up to
+48 KiB of supervised diagnostics, with `byteTruncated` marking omitted bytes.
+Linux selects the latest 200 journal entries; macOS/Windows read stderr. These are
+diagnostics, not execution history. On-demand stderr is not captured.
+
 The `maka` command also provides `host candidate` for Desktop-owned startup,
 `code --log <file>` for a JavaScript cell read from stdin, and
 `inspect --log <file>` for committed execution facts.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 2853a2cecd..54e677ee82 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -69,6 +69,11 @@ Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 
 停止和重启保留待更新目标。卸载先撤销启动资格,再注销服务;Root 数据、代码包及部署撤销记录均保留。
 若 `cleanup.kind` 为 `pending`,重试相同卸载命令;显式安装会在旧服务清理完成后授予新的部署身份。
 
+`host status --root-id <rootId>` 分别读取部署、待更新目标、OS 服务及活体 Host,不启动或修复它们;
+Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返回最多 48 KiB 的托管诊断尾部,
+`byteTruncated` 标明省略的字节。Linux 选择最近 200 条 journal 记录;macOS/Windows 读取 stderr。
+这些是诊断信息,不是执行历史;按需 Host 不捕获 stderr。
+
 `maka` 命令还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
 `code --log <file>`,以及查看已提交执行事实的 `inspect --log <file>`。
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 25/29: feat(host): publish code and deployment settings as one target

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d30c7e06f7f8a879ae8fe8ed6d5e7371fd144578
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 00:00:26 2026 +0800

    feat(host): publish code and deployment settings as one target
    
    Generated-by: OpenAI Codex
---
 crates/cli/src/args.rs                             |   8 +-
 crates/cli/src/candidate.rs                        |   4 +-
 crates/cli/src/deployment.rs                       |  18 +++-
 crates/cli/src/deployment/configuration.rs         |  66 +++++++++++++
 crates/cli/src/deployment/update.rs                |  70 +++++++++++---
 crates/cli/src/deployment/updates.rs               |  33 +++++--
 crates/cli/src/serve.rs                            |   3 +
 crates/cli/tests/integration/deployment.rs         | 104 +++++++++++++++++++++
 crates/runtime-host/src/server.rs                  |  16 +++-
 crates/runtime-host/src/server/connection.rs       |   3 +-
 .../src/server/projects/directories.rs             |  16 ++++
 .../runtime-host/tests/integration/retirement.rs   |  36 +++++++
 docs/rust-runtime.md                               |   5 +
 docs/rust-runtime.zh-CN.md                         |   4 +
 tests/fixtures/client-bridge-entry.mjs             |   7 ++
 15 files changed, 360 insertions(+), 33 deletions(-)

diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 7b74506679..c03d848ae5 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -55,10 +55,10 @@ enum HostCommand {
     Activate(crate::deployment::Activate),
     /// Connect an installed Host to stdin/stdout using the client wire protocol.
     Connect(crate::deployment::Connect),
-    /// Update a deployment to this executable at a safe boundary.
+    /// Update code and optional deployment configuration at a safe boundary.
     Update(crate::deployment::Update),
     /// Finish an interrupted deployment update without choosing another target.
-    Reconcile(crate::deployment::Update),
+    Reconcile(crate::deployment::Expected),
     /// Stop a deployment without changing its configuration or pending update.
     Stop(crate::deployment::Control),
     /// Restart the current deployment without applying a pending update.
@@ -120,8 +120,8 @@ impl Cli {
             Command::Host(HostCommand::Install(args)) => args.run().await,
             Command::Host(HostCommand::Activate(args)) => args.run().await,
             Command::Host(HostCommand::Connect(args)) => args.run().await,
-            Command::Host(HostCommand::Update(args)) => args.run(false).await,
-            Command::Host(HostCommand::Reconcile(args)) => args.run(true).await,
+            Command::Host(HostCommand::Update(args)) => args.run().await,
+            Command::Host(HostCommand::Reconcile(args)) => args.reconcile().await,
             Command::Host(HostCommand::Stop(args)) => {
                 args.run(crate::deployment::ControlAction::Stop).await
             }
diff --git a/crates/cli/src/candidate.rs b/crates/cli/src/candidate.rs
index 269612de27..33b99e8246 100644
--- a/crates/cli/src/candidate.rs
+++ b/crates/cli/src/candidate.rs
@@ -81,12 +81,14 @@ impl Candidate {
             super::serve::global_instructions()?,
             HostOptions {
                 skill_home: super::serve::home_directory()?,
+                project_directory_roots: deployment
+                    .as_ref()
+                    .and_then(|deployment| deployment.project_directory_roots.clone()),
                 generation: deployment
                     .as_ref()
                     .map(|deployment| deployment.generation())
                     .or(self.generation),
                 handshake_timeout: Duration::from_millis(self.handshake_timeout_ms),
-                ..HostOptions::default()
             },
         )
         .await?;
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index cf118f5570..2792b7962b 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -18,6 +18,7 @@
  */
 
 mod activation;
+mod configuration;
 mod connect;
 mod control;
 mod entry;
@@ -34,11 +35,11 @@ pub(super) use control::{Control, ControlAction};
 pub(super) use entry::ServiceRun;
 pub(super) use logs::Logs;
 pub(super) use query::Status;
-pub(super) use update::Update;
+pub(super) use update::{Expected, Update};
 
 use clap::{Args, ValueEnum};
 use maka_event_log::root::{self, FileLease, RootLocation, RootNamespaces, RootOwner};
-use maka_runtime_host::server::HostError;
+use maka_runtime_host::server::{DirectoryRootSpec, HostError};
 use serde::{Deserialize, Serialize};
 use std::{
     net::SocketAddr,
@@ -81,6 +82,8 @@ pub(super) struct Install {
     mode: Mode,
     #[arg(long, default_value = "127.0.0.1:0")]
     websocket: SocketAddr,
+    #[command(flatten)]
+    directories: configuration::Directories,
 }
 
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -109,6 +112,8 @@ pub(super) struct Deployment {
     sha256: String,
     pub mode: Mode,
     pub websocket: SocketAddr,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub project_directory_roots: Option<Vec<DirectoryRootSpec>>,
     // Active remains readable by previously installed executables. Older readers
     // reject the explicit tombstone through deny_unknown_fields.
     #[serde(default, skip_serializing_if = "Admission::is_active")]
@@ -146,6 +151,7 @@ impl Deployment {
                 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
             || self.executable != package::path(directory, &self.sha256)
             || self.websocket.ip() != std::net::Ipv4Addr::LOCALHOST
+            || serde_json::to_vec(self)?.len() > 65_536
         {
             return Err("deployment does not match its native root or package".into());
         }
@@ -169,6 +175,7 @@ impl Install {
         if self.websocket.ip() != std::net::Ipv4Addr::LOCALHOST {
             return Err("managed Host listener must use 127.0.0.1".into());
         }
+        let project_directory_roots = self.directories.resolve(None).await?;
         let root_path = self.root.root;
         let (root, directory, lease) = tokio::task::spawn_blocking(move || {
             root::initialize(&root_path, &RootNamespaces::for_current_account()?)?;
@@ -185,7 +192,10 @@ impl Install {
             && existing.admission.is_active()
         {
             existing.validate(&root, &directory)?;
-            if existing.mode != self.mode || existing.websocket != self.websocket {
+            if existing.mode != self.mode
+                || existing.websocket != self.websocket
+                || existing.project_directory_roots != project_directory_roots
+            {
                 return Err("deployment configuration changes require an update".into());
             }
             if existing.executable == std::env::current_exe()?.canonicalize()? {
@@ -212,8 +222,10 @@ impl Install {
             sha256,
             mode: self.mode,
             websocket: self.websocket,
+            project_directory_roots,
             admission: Admission::Active,
         };
+        requested.validate(&root, &directory)?;
         let deployment = if let store::Installation::Installed(existing) = existing {
             existing.validate(&root, &directory)?;
             if existing.admission == Admission::Revoked {
diff --git a/crates/cli/src/deployment/configuration.rs b/crates/cli/src/deployment/configuration.rs
new file mode 100644
index 0000000000..74d04bc896
--- /dev/null
+++ b/crates/cli/src/deployment/configuration.rs
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use clap::Args;
+use maka_runtime_host::server::{DirectoryRootSpec, HostError};
+
+#[derive(Args)]
+pub(super) struct Directories {
+    /// Publish an absolute project directory as {"label":"Projects","path":"/..."}.
+    #[arg(long = "project-root-json", value_parser = parse_root, conflicts_with_all = ["no_project_roots", "default_project_roots"])]
+    roots: Vec<DirectoryRootSpec>,
+    /// Publish no project directories.
+    #[arg(long, conflicts_with = "default_project_roots")]
+    no_project_roots: bool,
+    /// Restore the account's default project directory.
+    #[arg(long)]
+    default_project_roots: bool,
+}
+
+impl Directories {
+    pub async fn resolve(
+        self,
+        current: Option<Vec<DirectoryRootSpec>>,
+    ) -> Result<Option<Vec<DirectoryRootSpec>>, HostError> {
+        let selected = if self.default_project_roots {
+            None
+        } else if self.no_project_roots || !self.roots.is_empty() {
+            Some(self.roots)
+        } else {
+            current
+        };
+        // Reuse the Host's label, duplicate and containment rules. Existing
+        // deployment reads deliberately do not require old paths to exist:
+        // this command must be able to replace an unavailable directory.
+        tokio::task::spawn_blocking(move || {
+            selected
+                .map(DirectoryRootSpec::normalize)
+                .transpose()
+                .map_err(|error| error.message.into())
+        })
+        .await?
+    }
+}
+
+fn parse_root(value: &str) -> Result<DirectoryRootSpec, String> {
+    if value.len() > 8192 {
+        return Err("project directory declaration exceeds 8192 bytes".into());
+    }
+    serde_json::from_str(value).map_err(|error| error.to_string())
+}
diff --git a/crates/cli/src/deployment/update.rs b/crates/cli/src/deployment/update.rs
index 989fb26421..ac2cadd084 100644
--- a/crates/cli/src/deployment/update.rs
+++ b/crates/cli/src/deployment/update.rs
@@ -17,18 +17,28 @@
  * under the License.
  */
 
-use super::{Deployment, RootId, activation, directory, package, store, updates};
+use super::{
+    Deployment, Mode, RootId, activation, configuration, directory, package, store, updates,
+};
 use crate::host_client::{HostClient, LiveHost};
 use clap::Args;
 use maka_event_log::root::{self, FileLease, RootNamespaces, RootOwner};
 use maka_protocol::host::RetirementResult;
 use maka_runtime_host::server::HostError;
 use serde::Serialize;
-use std::{sync::Arc, time::Duration};
+use std::{net::SocketAddr, sync::Arc, time::Duration};
 use uuid::Uuid;
 
 #[derive(Args)]
 pub(crate) struct Update {
+    #[command(flatten)]
+    expected: Expected,
+    #[command(flatten)]
+    settings: Settings,
+}
+
+#[derive(Args)]
+pub(crate) struct Expected {
     #[arg(long)]
     root_id: RootId,
     #[arg(long)]
@@ -37,6 +47,18 @@ pub(crate) struct Update {
     expected_revision: u64,
 }
 
+#[derive(Args)]
+struct Settings {
+    /// Change the launch policy together with this executable.
+    #[arg(long, value_enum)]
+    mode: Option<Mode>,
+    /// Replace the loopback listener address.
+    #[arg(long)]
+    websocket: Option<SocketAddr>,
+    #[command(flatten)]
+    directories: configuration::Directories,
+}
+
 #[derive(Serialize)]
 #[serde(tag = "kind", rename_all = "snake_case")]
 enum Outcome {
@@ -51,7 +73,17 @@ enum Outcome {
 }
 
 impl Update {
-    pub async fn run(self, reconcile: bool) -> Result<(), HostError> {
+    pub async fn run(self) -> Result<(), HostError> {
+        self.expected.run(Some(self.settings)).await
+    }
+}
+
+impl Expected {
+    pub async fn reconcile(self) -> Result<(), HostError> {
+        self.run(None).await
+    }
+
+    async fn run(self, settings: Option<Settings>) -> Result<(), HostError> {
         let directory = directory(&self.root_id.0)?;
         if !directory.is_dir() {
             return Err("Host deployment is not installed".into());
@@ -75,10 +107,18 @@ impl Update {
         {
             return Err("deployment revision changed".into());
         }
-        let target = if reconcile {
-            pending
-        } else {
-            let source = package::source(current.mode)?;
+        let target = if let Some(settings) = settings {
+            let mut target = current.clone();
+            target.mode = settings.mode.unwrap_or(current.mode);
+            target.websocket = settings.websocket.unwrap_or(current.websocket);
+            target.project_directory_roots = settings
+                .directories
+                .resolve(current.project_directory_roots.clone())
+                .await?;
+            target.validate(&root, &directory)?;
+            // Code and configuration are one target. The calling distribution
+            // understands every field it writes, unlike an arbitrary older pin.
+            let source = package::source(target.mode)?;
             let (executable, sha256) = if source == current.executable {
                 (source, current.sha256.clone())
             } else {
@@ -92,7 +132,9 @@ impl Update {
                 })
                 .await??
             };
-            if sha256 == current.sha256 {
+            target.executable = executable;
+            target.sha256 = sha256;
+            if target == current {
                 if pending.is_some() {
                     return Err("another update is pending; reconcile it first".into());
                 }
@@ -101,15 +143,14 @@ impl Update {
                 if current.config_revision != self.expected_revision {
                     return Err("deployment changed before package staging".into());
                 }
-                let mut target = current.clone();
                 target.config_revision += 1;
-                target.executable = executable;
-                target.sha256 = sha256;
                 target.validate(&root, &directory)?;
                 updates::prepare(&directory, lease.clone(), current.clone(), target.clone())
                     .await?;
                 Some(target)
             }
+        } else {
+            pending
         };
         let deployment = if let Some(target) = target {
             target.validate(&root, &directory)?;
@@ -124,6 +165,13 @@ impl Update {
                 );
                 return Ok(());
             };
+            let owner = Arc::new(owner);
+            if current.mode == Mode::Supervised && target.mode == Mode::OnDemand {
+                // Remove the old login/restart trigger while Root is held.
+                // If removal or COMMIT fails, pending remains and Active can
+                // still be reactivated; there is no second cleanup authority.
+                super::service::remove(current.clone(), lease.clone(), owner.clone()).await?;
+            }
             updates::commit(&directory, lease.clone(), owner, current, target.clone()).await?;
             target
         } else {
diff --git a/crates/cli/src/deployment/updates.rs b/crates/cli/src/deployment/updates.rs
index 9c93a1bf91..9aa9a88816 100644
--- a/crates/cli/src/deployment/updates.rs
+++ b/crates/cli/src/deployment/updates.rs
@@ -70,7 +70,7 @@ pub(super) async fn prepare(
 pub(super) async fn commit(
     directory: &Path,
     lease: Arc<FileLease>,
-    owner: RootOwner,
+    owner: Arc<RootOwner>,
     expected: Deployment,
     target: Deployment,
 ) -> Result<(), HostError> {
@@ -80,7 +80,7 @@ pub(super) async fn commit(
     }
     // Keep both authorities in the worker through actual database close, including
     // a dropped caller, a failed COMMIT or a queued transaction rollback.
-    let connection = store::open_writer(directory, lease, Some(Arc::new(owner))).await?;
+    let connection = store::open_writer(directory, lease, Some(owner)).await?;
     let result = connection
         .run(move |connection| {
             Box::pin(async move {
@@ -111,11 +111,18 @@ pub(super) fn validate_target(current: &Deployment, target: &Deployment) -> Resu
     let mut expected = target.clone();
     expected.executable = current.executable.clone();
     expected.sha256 = current.sha256.clone();
+    expected.mode = current.mode;
+    expected.websocket = current.websocket;
+    expected.project_directory_roots = current.project_directory_roots.clone();
     expected.config_revision = current.config_revision;
     if &expected != current
         || current.config_revision >= 9_007_199_254_740_991
         || target.config_revision != current.config_revision + 1
-        || target.sha256 == current.sha256
+        || (target.sha256 == current.sha256
+            && target.mode == current.mode
+            && target.websocket == current.websocket
+            && target.project_directory_roots == current.project_directory_roots)
+        || serde_json::to_vec(target)?.len() > 65_536
     {
         return Err(conflict());
     }
@@ -171,6 +178,7 @@ mod tests {
             sha256: "a".repeat(64),
             mode: Mode::OnDemand,
             websocket: "127.0.0.1:0".parse().unwrap(),
+            project_directory_roots: None,
             admission: super::super::Admission::Active,
         };
         store::install(&directory, lease.clone(), owner, current.clone())
@@ -178,8 +186,9 @@ mod tests {
             .unwrap();
         let mut target = current.clone();
         target.config_revision += 1;
-        target.sha256 = "b".repeat(64);
-        target.executable = package::path(&directory, &target.sha256);
+        // A configuration-only target has the same executable, but still
+        // requires the exact pending receipt and writer-held atomic cut.
+        target.project_directory_roots = Some(Vec::new());
         prepare(&directory, lease.clone(), current.clone(), target.clone())
             .await
             .unwrap();
@@ -216,7 +225,7 @@ mod tests {
             commit(
                 &directory,
                 lease.clone(),
-                owner,
+                Arc::new(owner),
                 current.clone(),
                 target.clone()
             )
@@ -244,9 +253,15 @@ mod tests {
             .await
             .unwrap();
         writer.close().await.unwrap();
-        commit(&directory, lease.clone(), owner, current, target.clone())
-            .await
-            .unwrap();
+        commit(
+            &directory,
+            lease.clone(),
+            Arc::new(owner),
+            current,
+            target.clone(),
+        )
+        .await
+        .unwrap();
         // Uninstall cancels pending intent atomically, but retains a tombstone.
         // Only explicit reinstallation can grant a fresh deployment identity.
         let mut pending = target.clone();
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index c6f543174b..380d84dde8 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -71,6 +71,9 @@ pub(super) async fn run(
         global_instructions()?,
         maka_runtime_host::server::HostOptions {
             skill_home: home_directory()?,
+            project_directory_roots: deployment
+                .as_ref()
+                .and_then(|deployment| deployment.project_directory_roots.clone()),
             generation: deployment
                 .as_ref()
                 .map(|deployment| deployment.generation()),
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index bcd8baa928..36ea4e57a3 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -371,6 +371,110 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(ready["deploymentId"], reinstalled["deploymentId"]);
         assert!(!control("stop").output().unwrap().status.success());
         assert_eq!(decode_activation(&activate.output().unwrap().stdout), ready);
+
+        let update = |revision: &str| {
+            // These are same-code configuration changes. Use the installed
+            // operator without repeatedly copying and hashing the debug binary.
+            let mut command = Command::new(reinstalled["executable"].as_str().unwrap());
+            command.args([
+                "host",
+                "update",
+                "--root-id",
+                &fixture.root_id,
+                "--expected-deployment-id",
+                reinstalled["deploymentId"].as_str().unwrap(),
+                "--expected-revision",
+                revision,
+            ]);
+            command
+        };
+        let projects = temporary.path().join("published projects");
+        std::fs::create_dir(&projects).unwrap();
+        let mut configure = update("1");
+        configure.args([
+            "--project-root-json",
+            &serde_json::json!({
+                "label": " Projects ", "path": projects,
+            })
+            .to_string(),
+        ]);
+        let changed = configure.output().unwrap();
+        assert!(changed.status.success(), "{changed:?}");
+        let changed: Value = serde_json::from_slice(&changed.stdout).unwrap();
+        assert_eq!(changed["kind"], "ready");
+        assert_eq!(changed["deployment"]["configRevision"], 2);
+        assert_eq!(changed["deployment"]["sha256"], reinstalled["sha256"]);
+        assert_eq!(
+            changed["deployment"]["projectDirectoryRoots"],
+            serde_json::json!([
+                {"label": "Projects", "path": projects.canonicalize().unwrap()}
+            ])
+        );
+        let repeated = configure.output().unwrap();
+        assert!(repeated.status.success(), "{repeated:?}");
+        assert_eq!(
+            serde_json::from_slice::<Value>(&repeated.stdout).unwrap()["deployment"],
+            changed["deployment"]
+        );
+        assert!(
+            !update("1")
+                .arg("--no-project-roots")
+                .output()
+                .unwrap()
+                .status
+                .success()
+        );
+        assert_eq!(query("status")["deployment"], changed["deployment"]);
+
+        let bridge = |host: &Value, roots: &str| {
+            let output = Command::new("node")
+                .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
+                .args([
+                    "--bridge",
+                    env!("CARGO_BIN_EXE_maka"),
+                    "--root-id",
+                    &fixture.root_id,
+                    "--host-epoch",
+                    host["hostEpoch"].as_str().unwrap(),
+                    "--project-roots",
+                    roots,
+                ])
+                .output()
+                .unwrap();
+            assert!(
+                output.status.success(),
+                "{}",
+                String::from_utf8_lossy(&output.stderr)
+            );
+        };
+        // Debug-build hashing can exceed the on-demand idle grace. Rebind to
+        // the current activation instead of treating an old epoch as permanent.
+        let current = activate.output().unwrap();
+        assert!(current.status.success(), "{current:?}");
+        bridge(
+            &decode_activation(&current.stdout),
+            r#"[{"id":"root-1","label":"Projects"}]"#,
+        );
+        // A missing old directory must not prevent replacing its configuration.
+        std::fs::remove_dir(&projects).unwrap();
+        let cleared = update("2").arg("--no-project-roots").output().unwrap();
+        assert!(cleared.status.success(), "{cleared:?}");
+        let cleared: Value = serde_json::from_slice(&cleared.stdout).unwrap();
+        assert_eq!(cleared["deployment"]["configRevision"], 3);
+        assert_eq!(
+            cleared["deployment"]["projectDirectoryRoots"],
+            serde_json::json!([])
+        );
+        bridge(&cleared["host"], "[]");
+        let defaults = update("3").arg("--default-project-roots").output().unwrap();
+        assert!(defaults.status.success(), "{defaults:?}");
+        let defaults: Value = serde_json::from_slice(&defaults.stdout).unwrap();
+        assert_eq!(defaults["deployment"]["configRevision"], 4);
+        assert!(
+            defaults["deployment"]
+                .get("projectDirectoryRoots")
+                .is_none()
+        );
         fixture.retire_registered();
     }
 }
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index 04ffde47cf..b87db56d20 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -52,7 +52,7 @@ pub mod websocket;
 mod workhub;
 
 use std::error::Error;
-use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
 use std::sync::{Arc, Mutex};
 use std::time::Duration;
 
@@ -95,7 +95,7 @@ pub struct Host {
     retirement: Arc<Mutex<retirement::Phase>>,
     accepted_connections: AtomicUsize,
     started: std::time::Instant,
-    accepted_connection: AtomicBool,
+    accepted_connection_revision: AtomicU64,
     executions: Arc<crate::execution::Executions>,
     shells: Arc<crate::shell::ShellResources>,
     controllers: crate::controllers::Controllers,
@@ -218,7 +218,7 @@ impl Host {
             retirement: interactions.retirement.clone(),
             accepted_connections: AtomicUsize::new(0),
             started: std::time::Instant::now(),
-            accepted_connection: AtomicBool::new(false),
+            accepted_connection_revision: AtomicU64::new(0),
             shells: executions.shells.clone(),
             controllers: executions.controllers.clone(),
             executions,
@@ -275,9 +275,17 @@ impl Host {
     pub async fn wait_until_idle(&self, initial_timeout: Duration, idle_grace: Duration) {
         let initial = tokio::time::Instant::now() + initial_timeout;
         let mut idle_since = None;
+        let mut observed = 0;
         loop {
             let now = tokio::time::Instant::now();
-            if !self.accepted_connection.load(Ordering::SeqCst) {
+            let revision = self.accepted_connection_revision.load(Ordering::SeqCst);
+            if revision != observed {
+                // A complete activation/probe can occur between two polls.
+                // Its absence from the live count must not erase that activity.
+                idle_since = None;
+                observed = revision;
+            }
+            if revision == 0 {
                 if now >= initial {
                     return;
                 }
diff --git a/crates/runtime-host/src/server/connection.rs b/crates/runtime-host/src/server/connection.rs
index b646aa22b2..2df4284dc1 100644
--- a/crates/runtime-host/src/server/connection.rs
+++ b/crates/runtime-host/src/server/connection.rs
@@ -94,7 +94,8 @@ impl Host {
             timeout(Duration::from_secs(2), writer.close_after_flush()).await??;
             return Ok(());
         }
-        self.accepted_connection.store(true, Ordering::SeqCst);
+        self.accepted_connection_revision
+            .fetch_add(1, Ordering::SeqCst);
         let outbound = Outbound::default();
         let delivery = outbound.run(writer, &closed);
         let connection = async {
diff --git a/crates/runtime-host/src/server/projects/directories.rs b/crates/runtime-host/src/server/projects/directories.rs
index 3c0a506564..26ad6c18d5 100644
--- a/crates/runtime-host/src/server/projects/directories.rs
+++ b/crates/runtime-host/src/server/projects/directories.rs
@@ -25,11 +25,27 @@ use std::{
     sync::Arc,
 };
 
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
 pub struct DirectoryRootSpec {
     pub label: String,
     pub path: PathBuf,
 }
 
+impl DirectoryRootSpec {
+    /// Validate the published directory set and capture canonical paths once.
+    pub fn normalize(specs: Vec<Self>) -> Result<Vec<Self>> {
+        Ok(Directories::open(Some(specs))?
+            .0
+            .into_iter()
+            .map(|root| Self {
+                label: root.label,
+                path: root.directory.path().to_owned(),
+            })
+            .collect())
+    }
+}
+
 pub(in crate::server) struct Directories(Vec<Root>);
 
 struct Root {
diff --git a/crates/runtime-host/tests/integration/retirement.rs b/crates/runtime-host/tests/integration/retirement.rs
index a171788cac..39d46c3f59 100644
--- a/crates/runtime-host/tests/integration/retirement.rs
+++ b/crates/runtime-host/tests/integration/retirement.rs
@@ -70,6 +70,39 @@ async fn receive(frames: &mut mpsc::UnboundedReceiver<Value>) -> Value {
         .unwrap()
 }
 
+async fn short_connections_reset_idle_expiry(host: &std::sync::Arc<Host>) {
+    let connect = || async {
+        let (requests, reader) = mpsc::unbounded_channel();
+        let (frames, mut responses) = mpsc::unbounded_channel();
+        requests.send(hello()).unwrap();
+        drop(requests);
+        host.clone()
+            .local_owner_connection(
+                Reader(reader),
+                Writer {
+                    frames,
+                    gate: None,
+                    fail: false,
+                },
+            )
+            .await
+            .unwrap();
+        assert_eq!(receive(&mut responses).await["kind"], "accepted");
+    };
+    connect().await;
+    let expiry = host.wait_until_idle(Duration::from_secs(10), Duration::from_millis(50));
+    tokio::pin!(expiry);
+    assert!(futures_util::poll!(&mut expiry).is_pending());
+    // This future is deliberately not spawned: the complete second connection
+    // occurs between its polls, irrespective of scheduler speed. Sleeping only
+    // ages the old deadline and its 100ms timer; it does not guess connection state.
+    connect().await;
+    tokio::time::sleep(Duration::from_millis(110)).await;
+    assert!(futures_util::poll!(&mut expiry).is_pending());
+    tokio::time::sleep(Duration::from_millis(110)).await;
+    assert!(futures_util::poll!(&mut expiry).is_ready());
+}
+
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_abandoned() {
     for fail in [false, true] {
@@ -103,6 +136,9 @@ async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_aba
         .await
         .expect("unused Host retained the root");
         let host = Host::open(owner).await.unwrap();
+        if !fail {
+            short_connections_reset_idle_expiry(&host).await;
+        }
         #[cfg(unix)]
         let endpoint = directory.path().join("h.sock");
         #[cfg(windows)]
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index db9af85680..2a5ab47620 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -82,6 +82,11 @@ run the built executable directly, not through `cargo run`.
 
 For a code update, run the new binary with
 `host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`.
+The same update can set `--mode`, `--websocket`, and repeatable
+`--project-root-json '{"label":"Projects","path":"/absolute/path"}'` declarations.
+`--no-project-roots` publishes none; `--default-project-roots` restores the account
+default. Omitted settings are preserved. Code and configuration share one target
+and revision; `reconcile` never selects different settings.
 Active clients or non-cooperative work defer the switch; `host reconcile` with the
 same identity arguments finishes the recorded update. A committed target is never
 automatically rolled back, even if startup fails. Supervised activation replaces
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 1e87f813a6..5726d90d96 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -69,6 +69,10 @@ Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 
 
 更新部署时,用新二进制运行
 `host update --root-id <rootId> --expected-deployment-id <deploymentId> --expected-revision <revision>`。
+同一次更新可设置 `--mode`、`--websocket`,以及可重复的
+`--project-root-json '{"label":"Projects","path":"/absolute/path"}'`。
+`--no-project-roots` 不发布目录;`--default-project-roots` 恢复账户默认目录。省略的配置保留不变。
+代码和配置共用一个目标及 revision;`reconcile` 不重新选择配置。
 活跃客户端或不可交接任务会推迟切换;随后用相同身份参数运行 `host reconcile` 完成已记录的更新。
 目标一旦提交,即使启动失败也不自动回退。Supervised 激活仅在持有 Root 时替换服务定义。
 无人值守版本选择和不中断监听的交接尚未接通。
diff --git a/tests/fixtures/client-bridge-entry.mjs b/tests/fixtures/client-bridge-entry.mjs
index d578320190..5128c5491d 100644
--- a/tests/fixtures/client-bridge-entry.mjs
+++ b/tests/fixtures/client-bridge-entry.mjs
@@ -29,6 +29,7 @@ const { values } = parseArgs({
     bridge: { type: 'string' },
     'root-id': { type: 'string' },
     'host-epoch': { type: 'string' },
+    'project-roots': { type: 'string' },
   },
 });
 const children = [];
@@ -85,6 +86,12 @@ try {
       (await connection.request('host.diagnostics.query', {})).hostEpoch,
       values['host-epoch'],
     );
+    if (values['project-roots'] !== undefined) {
+      assert.deepEqual(
+        await connection.request('project.catalog.query', { kind: 'directory_roots' }),
+        { kind: 'directory_roots', roots: JSON.parse(values['project-roots']) },
+      );
+    }
   } finally {
     await connection.close();
   }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 14/29: feat(host): pin native deployments before runtime admission

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 760a0500d2ee546b2c427ff499c9e9c9069b4d91
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 17:09:17 2026 +0800

    feat(host): pin native deployments before runtime admission
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                         |   2 +
 crates/cli/Cargo.toml                              |   2 +
 .../deployment/0001_deployment.sql}                |   7 +-
 crates/cli/src/args.rs                             |   3 +
 crates/cli/src/candidate.rs                        |  34 +++-
 crates/cli/src/deployment.rs                       | 196 +++++++++++++++++++++
 crates/cli/src/deployment/package.rs               | 101 +++++++++++
 crates/cli/src/deployment/store.rs                 | 195 ++++++++++++++++++++
 crates/cli/src/main.rs                             |   1 +
 crates/cli/src/serve.rs                            |  24 ++-
 crates/cli/tests/integration/candidate.rs          |  52 ++++--
 crates/cli/tests/integration/deployment.rs         | 167 ++++++++++++++++++
 crates/cli/tests/integration/main.rs               |   1 +
 crates/event-log/src/root/windows.rs               |   2 +-
 crates/event-log/src/root/windows/filesystem.rs    |   2 +-
 crates/runtime-host/src/server/registration.rs     |   4 +
 docs/rust-runtime.md                               |   6 +
 docs/rust-runtime.zh-CN.md                         |   4 +
 18 files changed, 774 insertions(+), 29 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index ffb64913fb..43d049aabe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3947,6 +3947,8 @@ dependencies = [
  "maka-transport",
  "serde",
  "serde_json",
+ "sha2 0.10.9",
+ "sqlx",
  "tempfile",
  "tokio",
  "tokio-util",
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index fab1d36fb5..cffb7b1a30 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -38,6 +38,8 @@ maka-transport.workspace = true
 maka-js-runtime.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+sha2.workspace = true
+sqlx.workspace = true
 tokio.workspace = true
 tokio-util.workspace = true
 uuid.workspace = true
diff --git a/crates/cli/tests/integration/main.rs b/crates/cli/migrations/deployment/0001_deployment.sql
similarity index 77%
copy from crates/cli/tests/integration/main.rs
copy to crates/cli/migrations/deployment/0001_deployment.sql
index 828d2d4316..0444da03cd 100644
--- a/crates/cli/tests/integration/main.rs
+++ b/crates/cli/migrations/deployment/0001_deployment.sql
@@ -17,5 +17,8 @@
  * under the License.
  */
 
-mod candidate;
-mod code_command;
+CREATE TABLE deployment (
+    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+    configuration TEXT NOT NULL CHECK (json_valid(configuration) AND length(CAST(configuration AS BLOB)) <= 65536)
+) STRICT;
+PRAGMA user_version = 1;
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index c5ff57a466..78e06ee030 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -49,6 +49,8 @@ enum Command {
 enum HostCommand {
     /// Initialize an empty native State Root, or verify its existing identity.
     Init(Root),
+    /// Install this native executable as the root's managed Host.
+    Install(crate::deployment::Install),
     /// Query the existing Host without opening its State Root for writing.
     Status(Root),
     /// Retire the exact current Host, preserving work at safe step boundaries.
@@ -96,6 +98,7 @@ impl Cli {
     pub(super) async fn run(self) -> Result<(), HostError> {
         match self.command {
             Command::Host(HostCommand::Candidate(args)) => args.run().await,
+            Command::Host(HostCommand::Install(args)) => args.run().await,
             Command::Host(HostCommand::Init(args)) => {
                 let root_id = initialize(&args.root, &RootNamespaces::for_current_account()?)?;
                 println!("{}", serde_json::json!({"rootId": root_id}));
diff --git a/crates/cli/src/candidate.rs b/crates/cli/src/candidate.rs
index fdf9c7d86c..5ec8942685 100644
--- a/crates/cli/src/candidate.rs
+++ b/crates/cli/src/candidate.rs
@@ -59,6 +59,17 @@ impl Candidate {
         if root.root_id() != self.expected_root_id {
             return Err("candidate State Root identity mismatch".into());
         }
+        let deployment = crate::deployment::admit(&root, crate::deployment::Mode::OnDemand).await?;
+        let websocket = match &deployment {
+            Some(deployment) => Some(
+                maka_runtime_host::server::websocket::WebSocketListener::bind(
+                    deployment.websocket,
+                    Vec::new(),
+                )
+                .await?,
+            ),
+            None => None,
+        };
         let cancel = CancellationToken::new();
         let _cancel_on_exit = cancel.clone().drop_guard();
         if self.owner_stdin {
@@ -70,14 +81,23 @@ impl Candidate {
             super::serve::global_instructions()?,
             HostOptions {
                 skill_home: super::serve::home_directory()?,
-                generation: self.generation,
+                generation: deployment
+                    .as_ref()
+                    .map(|deployment| deployment.generation())
+                    .or(self.generation),
                 handshake_timeout: Duration::from_millis(self.handshake_timeout_ms),
                 ..HostOptions::default()
             },
         )
         .await?;
         let endpoint = LocalEndpoint::bind()?;
-        let registration = host.publish_registration(&endpoint.path)?;
+        let registration = host.publish_registration(
+            &endpoint.path,
+            websocket
+                .as_ref()
+                .map(|listener| listener.local_addr())
+                .transpose()?,
+        )?;
         let idle_host = host.clone();
         let idle_cancel = cancel.clone();
         let expiry = tokio::spawn(async move {
@@ -89,7 +109,15 @@ impl Candidate {
                 .await;
             idle_cancel.cancel();
         });
-        let result = endpoint.listener.serve(host, cancel).await;
+        let result = match websocket {
+            Some(websocket) => {
+                endpoint
+                    .listener
+                    .serve_with_websocket(websocket, host, cancel)
+                    .await
+            }
+            None => endpoint.listener.serve(host, cancel).await,
+        };
         expiry.abort();
         let _ = expiry.await;
         drop(signals);
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
new file mode 100644
index 0000000000..0108c3d9c2
--- /dev/null
+++ b/crates/cli/src/deployment.rs
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod package;
+mod store;
+
+use clap::{Args, ValueEnum};
+use maka_event_log::root::{self, FileLease, RootLocation, RootNamespaces, RootOwner};
+use maka_runtime_host::server::HostError;
+use serde::{Deserialize, Serialize};
+use std::{
+    net::SocketAddr,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+use uuid::Uuid;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub(super) enum Mode {
+    OnDemand,
+    Supervised,
+}
+
+#[derive(Args)]
+pub(super) struct Install {
+    #[command(flatten)]
+    root: crate::args::Root,
+    #[arg(long, value_enum, default_value = "on-demand")]
+    mode: Mode,
+    #[arg(long, default_value = "127.0.0.1:0")]
+    websocket: SocketAddr,
+}
+
+/// Active configuration authorizes startup; it does not assert process health.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub(super) struct Deployment {
+    deployment_id: Uuid,
+    config_revision: u64,
+    root_id: String,
+    root_path: PathBuf,
+    pub executable: PathBuf,
+    sha256: String,
+    pub mode: Mode,
+    pub websocket: SocketAddr,
+}
+
+impl Deployment {
+    pub fn generation(&self) -> String {
+        format!("{}:{}", self.deployment_id, self.config_revision)
+    }
+
+    fn validate(&self, root: &RootLocation, directory: &Path) -> Result<(), HostError> {
+        if self.root_id != root.root_id()
+            || self.root_path != root.canonical_path()
+            || !(1..=9_007_199_254_740_991).contains(&self.config_revision)
+            || self.sha256.len() != 64
+            || !self
+                .sha256
+                .bytes()
+                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+            || self.executable != package::path(directory, &self.sha256)
+            || !self.websocket.ip().is_loopback()
+        {
+            return Err("deployment does not match its native root or package".into());
+        }
+        Ok(())
+    }
+}
+
+pub(super) fn directory(root_id: &str) -> Result<PathBuf, HostError> {
+    let namespaces = RootNamespaces::for_current_account()?;
+    Ok(namespaces
+        .ownership
+        .parent()
+        .ok_or("missing account data directory")?
+        .canonicalize()?
+        .join("deployments")
+        .join(root_id))
+}
+
+impl Install {
+    pub async fn run(self) -> Result<(), HostError> {
+        if !self.websocket.ip().is_loopback() {
+            return Err("managed Host listener must be loopback".into());
+        }
+        let root_path = self.root.root;
+        let (root, directory, lease) = tokio::task::spawn_blocking(move || {
+            root::initialize(&root_path, &RootNamespaces::for_current_account()?)?;
+            let root = root::resolve(&root_path)?;
+            let directory = directory(root.root_id())?;
+            root::private_directory(&directory)?;
+            let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+            lease.validate()?;
+            Ok::<_, HostError>((root, directory, lease))
+        })
+        .await??;
+        let existing = store::read(&directory).await?;
+        if let store::Installation::Installed(existing) = &existing {
+            existing.validate(&root, &directory)?;
+            if existing.mode != self.mode || existing.websocket != self.websocket {
+                return Err("deployment configuration changes require an update".into());
+            }
+            if existing.executable == std::env::current_exe()?.canonicalize()? {
+                lease.validate()?;
+                println!("{}", serde_json::to_string(existing)?);
+                return Ok(());
+            }
+        }
+        let stage_directory = directory.clone();
+        let stage_lease = lease.clone();
+        let (executable, sha256) = tokio::task::spawn_blocking(move || {
+            stage_lease.validate()?;
+            let package = package::stage(&stage_directory, &std::env::current_exe()?)?;
+            stage_lease.validate()?;
+            Ok::<_, HostError>(package)
+        })
+        .await??;
+        let requested = Deployment {
+            deployment_id: Uuid::new_v4(),
+            config_revision: 1,
+            root_id: root.root_id().into(),
+            root_path: root.canonical_path().into(),
+            executable,
+            sha256,
+            mode: self.mode,
+            websocket: self.websocket,
+        };
+        let deployment = if let store::Installation::Installed(existing) = existing {
+            existing.validate(&root, &directory)?;
+            if existing.executable != requested.executable
+                || existing.mode != requested.mode
+                || existing.websocket != requested.websocket
+            {
+                return Err(
+                    "deployment is already installed; changing it requires an update".into(),
+                );
+            }
+            existing
+        } else {
+            // No deployment database is created before obtaining the Root. A busy
+            // unmanaged Host remains unmanaged; staging alone cannot fence startup.
+            let owner = RootOwner::open(
+                root.canonical_path(),
+                &RootNamespaces::for_current_account()?,
+            )?;
+            if owner.root_id() != root.root_id() {
+                return Err("State Root changed during installation".into());
+            }
+            store::install(&directory, lease.clone(), owner, requested).await?
+        };
+        lease.validate()?;
+        println!("{}", serde_json::to_string(&deployment)?);
+        Ok(())
+    }
+}
+
+/// Called with the actual writer lease, before any Host database can migrate.
+pub(super) async fn admit(owner: &RootOwner, mode: Mode) -> Result<Option<Deployment>, HostError> {
+    let root = root::resolve(owner.canonical_path())?;
+    let directory = directory(root.root_id())?;
+    let deployment = match store::read(&directory).await? {
+        store::Installation::Missing => return Ok(None),
+        store::Installation::Incomplete => {
+            return Err("deployment installation must finish before Host startup".into());
+        }
+        store::Installation::Installed(deployment) => deployment,
+    };
+    deployment.validate(&root, &directory)?;
+    if !deployment.executable.symlink_metadata()?.is_file()
+        || deployment.executable.canonicalize()? != deployment.executable
+        || deployment.mode != mode
+        || std::env::current_exe()?.canonicalize()? != deployment.executable
+    {
+        return Err("this executable or launch mode is not authorized by the deployment".into());
+    }
+    owner.validate_current()?;
+    Ok(Some(deployment))
+}
diff --git a/crates/cli/src/deployment/package.rs b/crates/cli/src/deployment/package.rs
new file mode 100644
index 0000000000..330eb2bb93
--- /dev/null
+++ b/crates/cli/src/deployment/package.rs
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_runtime_host::server::HostError;
+use sha2::{Digest, Sha256};
+use std::{
+    fs::File,
+    io::{Read, Write},
+    path::{Path, PathBuf},
+};
+
+pub(super) fn path(directory: &Path, digest: &str) -> PathBuf {
+    let suffix = if cfg!(windows) { ".exe" } else { "" };
+    directory
+        .join("packages")
+        .join(format!("maka-{digest}{suffix}"))
+}
+
+pub(super) fn stage(directory: &Path, source: &Path) -> Result<(PathBuf, String), HostError> {
+    let packages = directory.join("packages");
+    maka_event_log::root::private_directory(&packages)?;
+    let mut source = File::open(source)?;
+    if !source.metadata()?.is_file() {
+        return Err("Host package must be a regular executable".into());
+    }
+    let mut temporary = tempfile::NamedTempFile::new_in(&packages)?;
+    let mut hasher = Sha256::new();
+    let mut buffer = [0; 64 * 1024];
+    let mut size = 0_u64;
+    loop {
+        let read = source.read(&mut buffer)?;
+        if read == 0 {
+            break;
+        }
+        size += read as u64;
+        if size > 512 * 1024 * 1024 {
+            return Err("Host executable exceeds the package size limit".into());
+        }
+        temporary.write_all(&buffer[..read])?;
+        hasher.update(&buffer[..read]);
+    }
+    let digest = format!("{:x}", hasher.finalize());
+    let target = path(directory, &digest);
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        temporary
+            .as_file()
+            .set_permissions(std::fs::Permissions::from_mode(0o500))?;
+    }
+    temporary.as_file().sync_all()?;
+    #[cfg(unix)]
+    let published = temporary
+        .persist_noclobber(&target)
+        .map(drop)
+        .map_err(|error| error.error);
+    #[cfg(windows)]
+    let published =
+        maka_event_log::root::windows::publish_file(&temporary.into_temp_path(), &target);
+    match published {
+        Ok(()) => {}
+        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+            let metadata = target.symlink_metadata()?;
+            if !metadata.is_file() || metadata.len() != size {
+                return Err("installed Host package was replaced".into());
+            }
+            let mut file = File::open(&target)?.take(size + 1);
+            let mut hasher = Sha256::new();
+            loop {
+                let read = file.read(&mut buffer)?;
+                if read == 0 {
+                    break;
+                }
+                hasher.update(&buffer[..read]);
+            }
+            if format!("{:x}", hasher.finalize()) != digest {
+                return Err("installed Host package digest changed".into());
+            }
+        }
+        Err(error) => return Err(error.into()),
+    }
+    #[cfg(unix)]
+    File::open(&packages)?.sync_all()?;
+    Ok((target, digest))
+}
diff --git a/crates/cli/src/deployment/store.rs b/crates/cli/src/deployment/store.rs
new file mode 100644
index 0000000000..a685ebacb6
--- /dev/null
+++ b/crates/cli/src/deployment/store.rs
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::Deployment;
+use maka_event_log::{
+    StoreError,
+    connection::{ConnectionAuthority, OwnedConnection},
+    root::{FileLease, RootOwner},
+};
+use maka_runtime_host::server::HostError;
+use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions};
+use std::{fs::File, path::Path, sync::Arc};
+
+const DATABASE: &str = "deployment.sqlite";
+const APPLICATION_ID: i64 = 0x4d414b44;
+static MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/deployment");
+
+pub(super) enum Installation {
+    Missing,
+    Incomplete,
+    Installed(Deployment),
+}
+
+/// No executor lock and no schema initialization: launchers can read while an
+/// operator waits for their Ready acknowledgement.
+pub(super) async fn read(directory: &Path) -> Result<Installation, HostError> {
+    let path = directory.join(DATABASE);
+    match path.symlink_metadata() {
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+            return Ok(Installation::Missing);
+        }
+        Err(error) => return Err(error.into()),
+        Ok(metadata) if !metadata.is_file() => return Err("invalid deployment database".into()),
+        Ok(_) => {}
+    }
+    let mut connection =
+        SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(path).read_only(true))
+            .await?;
+    let result = async {
+        let application: i64 = sqlx::query_scalar("PRAGMA application_id").fetch_one(&mut connection).await?;
+        let version: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(&mut connection).await?;
+        if version == 0 && application == APPLICATION_ID {
+            return Ok(Installation::Incomplete);
+        }
+        if version == 0 && application == 0 {
+            let tables: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'")
+                .fetch_one(&mut connection).await?;
+            if tables == 0 { return Ok(Installation::Incomplete) }
+        }
+        if application != APPLICATION_ID || version != 1 {
+            return Err("unsupported or incomplete deployment database".into());
+        }
+        let value: Option<String> = sqlx::query_scalar("SELECT CASE WHEN length(CAST(configuration AS BLOB)) <= 65536 THEN configuration END FROM deployment WHERE singleton = 1")
+            .fetch_optional(&mut connection).await?;
+        let Some(value) = value else { return Ok(Installation::Incomplete) };
+        Ok::<_, HostError>(Installation::Installed(serde_json::from_str(&value)?))
+    }.await;
+    let closed = connection.close().await;
+    let deployment = result?;
+    closed?;
+    Ok(deployment)
+}
+
+pub(super) async fn install(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    owner: RootOwner,
+    deployment: Deployment,
+) -> Result<Deployment, HostError> {
+    let path = directory.join(DATABASE);
+    let file = private_file(&path)?;
+    file.sync_all()?;
+    #[cfg(unix)]
+    File::open(directory)?.sync_all()?;
+    let connection = OwnedConnection::open(
+        SqliteConnectOptions::new()
+            .filename(&path)
+            .create_if_missing(false),
+        ConnectionAuthority::Writer { lease, root: None },
+        |connection| Box::pin(initialize(connection)),
+    )
+    .await?;
+    let result = connection
+        .run(move |connection| {
+            Box::pin(async move {
+                // The accepted database job owns Root until its actual commit, even if
+                // the install caller disappears while awaiting the acknowledgement.
+                owner.validate_current()?;
+                let body = serde_json::to_string(&deployment)?;
+                sqlx::query("INSERT INTO deployment(singleton, configuration) VALUES (1, ?)")
+                    .bind(body)
+                    .execute(connection)
+                    .await?;
+                drop(owner);
+                Ok(deployment)
+            })
+        })
+        .await;
+    let closed = connection.close().await;
+    let deployment = result?;
+    closed?;
+    drop(file);
+    Ok(deployment)
+}
+
+fn private_file(path: &Path) -> Result<File, HostError> {
+    #[cfg(unix)]
+    let file = {
+        use std::os::unix::fs::OpenOptionsExt;
+        std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .create(true)
+            .truncate(false)
+            .mode(0o600)
+            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+            .open(path)?
+    };
+    #[cfg(windows)]
+    let file = {
+        use maka_event_log::root::windows::{create_private_file, open_nofollow, validate_private};
+        let file = match create_private_file(path) {
+            Ok(file) => file,
+            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+                open_nofollow(path, true)?
+            }
+            Err(error) => return Err(error.into()),
+        };
+        validate_private(&file)?;
+        file
+    };
+    if !file.metadata()?.is_file() {
+        return Err("deployment database is not a regular file".into());
+    }
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::MetadataExt;
+        let metadata = file.metadata()?;
+        if metadata.nlink() != 1
+            || metadata.mode() & 0o077 != 0
+            || metadata.uid() != unsafe { libc::geteuid() }
+        {
+            return Err("deployment database is not private and singly linked".into());
+        }
+    }
+    #[cfg(windows)]
+    if maka_event_log::root::windows::file_identity(&file)?.links != 1 {
+        return Err("deployment database is hard-linked".into());
+    }
+    Ok(file)
+}
+
+async fn initialize(connection: &mut SqliteConnection) -> Result<(), StoreError> {
+    let application: i64 = sqlx::query_scalar("PRAGMA application_id")
+        .fetch_one(&mut *connection)
+        .await?;
+    let version: i64 = sqlx::query_scalar("PRAGMA user_version")
+        .fetch_one(&mut *connection)
+        .await?;
+    let tables: i64 = sqlx::query_scalar(
+        "SELECT COUNT(*) FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'",
+    )
+    .fetch_one(&mut *connection)
+    .await?;
+    if !((application == 0 && version == 0 && tables == 0)
+        || (application == APPLICATION_ID && (0..=1).contains(&version)))
+    {
+        return Err(StoreError::InvalidTransition(
+            "unsupported deployment database".into(),
+        ));
+    }
+    sqlx::raw_sql(
+        "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA application_id=1296124740;",
+    )
+    .execute(&mut *connection)
+    .await?;
+    MIGRATIONS.run_direct(None, connection, false).await?;
+    Ok(())
+}
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 0a2e72651a..733f408bb5 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -21,6 +21,7 @@ use clap::Parser;
 mod args;
 mod candidate;
 mod code;
+mod deployment;
 mod endpoint;
 mod host_client;
 mod serve;
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index f39df116f7..4a0dbca7be 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -27,21 +27,29 @@ pub(super) async fn run(
 ) -> Result<(), maka_runtime_host::server::HostError> {
     use maka_event_log::root::{ROOT_MARKER, RootNamespaces, RootOwner};
     use maka_runtime_host::server::{Host, websocket::WebSocketListener};
-    let websocket = match websocket {
-        Some(address) => Some(WebSocketListener::bind(address, Vec::new()).await?),
-        None => None,
-    };
     let namespaces = RootNamespaces::for_current_account()?;
     let owner = if path.join(ROOT_MARKER).exists() {
         RootOwner::open(path, &namespaces)?
     } else {
         RootOwner::create(path, &namespaces)?
     };
+    let deployment = crate::deployment::admit(&owner, crate::deployment::Mode::Supervised).await?;
+    let websocket = match deployment
+        .as_ref()
+        .map(|deployment| deployment.websocket)
+        .or(websocket)
+    {
+        Some(address) => Some(WebSocketListener::bind(address, Vec::new()).await?),
+        None => None,
+    };
     let host = Host::open_with_options(
         owner,
         global_instructions()?,
         maka_runtime_host::server::HostOptions {
             skill_home: home_directory()?,
+            generation: deployment
+                .as_ref()
+                .map(|deployment| deployment.generation()),
             ..Default::default()
         },
     )
@@ -49,7 +57,13 @@ pub(super) async fn run(
     let endpoint = super::endpoint::LocalEndpoint::bind()?;
     let cancellation = CancellationToken::new();
     let _signals = super::signals::watch(cancellation.clone())?;
-    let registration = host.publish_registration(&endpoint.path)?;
+    let registration = host.publish_registration(
+        &endpoint.path,
+        websocket
+            .as_ref()
+            .map(|listener| listener.local_addr())
+            .transpose()?,
+    )?;
     let mut ready = json!({"kind":"ready","rootId":host.root_id(),"socketPath":endpoint.path});
     if let Some(listener) = &websocket {
         ready["websocketUrl"] = format!("ws://{}/runtime-host", listener.local_addr()?).into();
diff --git a/crates/cli/tests/integration/candidate.rs b/crates/cli/tests/integration/candidate.rs
index c18de6cb6e..2b5c504a78 100644
--- a/crates/cli/tests/integration/candidate.rs
+++ b/crates/cli/tests/integration/candidate.rs
@@ -31,16 +31,7 @@ use std::{
 fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle() {
     let directory = tempfile::tempdir().unwrap();
     let namespaces = RootNamespaces::for_current_account().unwrap();
-    let root = directory.path().join("root");
-    let owner = RootOwner::create(&root, &namespaces).unwrap();
-    let mut fixture = CandidateFixture {
-        child: None,
-        root,
-        root_id: owner.root_id().to_owned(),
-        registration: owner.control_directory().join("registration.json"),
-        lock: owner.lock_path().to_owned(),
-    };
-    drop(owner);
+    let mut fixture = CandidateFixture::new(directory.path().join("root"));
 
     enum End {
         OwnerLoss,
@@ -213,16 +204,28 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
 }
 
 /// Reap the exact test child before removing its fresh root's account-level lease files.
-struct CandidateFixture {
-    child: Option<Child>,
-    root: PathBuf,
-    root_id: String,
-    registration: PathBuf,
+pub(super) struct CandidateFixture {
+    pub child: Option<Child>,
+    pub root: PathBuf,
+    pub root_id: String,
+    pub registration: PathBuf,
     lock: PathBuf,
 }
 
 impl CandidateFixture {
-    fn wait_for_registration(&mut self) -> Value {
+    pub fn new(root: PathBuf) -> Self {
+        let owner =
+            RootOwner::create(&root, &RootNamespaces::for_current_account().unwrap()).unwrap();
+        Self {
+            child: None,
+            root,
+            root_id: owner.root_id().to_owned(),
+            registration: owner.control_directory().join("registration.json"),
+            lock: owner.lock_path().to_owned(),
+        }
+    }
+
+    pub fn wait_for_registration(&mut self) -> Value {
         let deadline = Instant::now() + Duration::from_secs(15);
         loop {
             if let Ok(bytes) = fs::read(&self.registration) {
@@ -240,7 +243,7 @@ impl CandidateFixture {
         }
     }
 
-    fn wait_for_exit(&mut self) -> std::process::ExitStatus {
+    pub fn wait_for_exit(&mut self) -> std::process::ExitStatus {
         let deadline = Instant::now() + Duration::from_secs(10);
         loop {
             if let Some(status) = self.child.as_mut().unwrap().try_wait().unwrap() {
@@ -280,6 +283,21 @@ impl Drop for CandidateFixture {
             std::thread::sleep(Duration::from_millis(20));
         }
         let _ = fs::remove_file(&self.registration);
+        let deployment = namespaces
+            .ownership
+            .parent()
+            .unwrap()
+            .join("deployments")
+            .join(&self.root_id);
+        if deployment.is_dir() {
+            // Only this fixture's freshly generated root; all owned children are reaped.
+            if let Ok(lease) =
+                maka_event_log::root::FileLease::acquire(&deployment.join("executor.lock"))
+            {
+                drop(lease);
+                let _ = fs::remove_dir_all(deployment);
+            }
+        }
         let _ = fs::remove_file(control.join("owner.lock"));
         let _ = fs::remove_dir(control);
         let _ = fs::remove_file(&self.lock);
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
new file mode 100644
index 0000000000..ed5d6b93e8
--- /dev/null
+++ b/crates/cli/tests/integration/deployment.rs
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::candidate::CandidateFixture;
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use serde_json::Value;
+use std::{
+    io::{Read, Write},
+    net::{SocketAddr, TcpStream},
+    process::{Command, Stdio},
+    time::Duration,
+};
+
+#[test]
+fn managed_installation_pins_code_before_migration_and_preserves_live_authority() {
+    let temporary = tempfile::tempdir().unwrap();
+    let namespaces = RootNamespaces::for_current_account().unwrap();
+    for mode in ["on-demand", "supervised"] {
+        let mut fixture = CandidateFixture::new(temporary.path().join(mode));
+        let mut install = Command::new(env!("CARGO_BIN_EXE_maka"));
+        install
+            .args(["host", "install", "--root"])
+            .arg(&fixture.root)
+            .args(["--mode", mode]);
+        let owner = RootOwner::open(&fixture.root, &namespaces).unwrap();
+        assert!(!install.output().unwrap().status.success());
+        let directory = namespaces
+            .ownership
+            .parent()
+            .unwrap()
+            .join("deployments")
+            .join(&fixture.root_id);
+        assert!(
+            !directory.join("deployment.sqlite").exists(),
+            "failed staging must not fence unmanaged startup"
+        );
+        drop(owner);
+        let output = install.output().unwrap();
+        assert!(
+            output.status.success(),
+            "{}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        let installed: Value = serde_json::from_slice(&output.stdout).unwrap();
+        let executable = installed["executable"].as_str().unwrap();
+        assert_eq!(installed["rootId"], fixture.root_id);
+        assert_eq!(installed["configRevision"], 1);
+
+        let command = |executable: &str, candidate: bool| {
+            let mut command = Command::new(executable);
+            command
+                .args([
+                    "host",
+                    if candidate { "candidate" } else { "serve" },
+                    "--root",
+                ])
+                .arg(&fixture.root);
+            if candidate {
+                command.args([
+                    "--expected-root-id",
+                    &fixture.root_id,
+                    "--startup-attempt-id",
+                    &uuid::Uuid::new_v4().to_string(),
+                    "--owner-stdin",
+                    "--initial-connection-timeout-ms",
+                    "300000",
+                ]);
+            }
+            command
+        };
+        let candidate = mode == "on-demand";
+        let refused = command(env!("CARGO_BIN_EXE_maka"), candidate)
+            .output()
+            .unwrap();
+        assert!(
+            !refused.status.success(),
+            "uninstalled executable bypassed the managed root"
+        );
+        let wrong_mode = command(executable, !candidate).output().unwrap();
+        assert!(
+            !wrong_mode.status.success(),
+            "launch mode bypassed deployment policy"
+        );
+        assert!(
+            !fixture.root.join("runtime-rust.sqlite").exists(),
+            "admission must precede migrations"
+        );
+
+        fixture.child = Some(
+            command(executable, candidate)
+                .stdin(Stdio::piped())
+                .stdout(Stdio::null())
+                .stderr(Stdio::inherit())
+                .spawn()
+                .unwrap(),
+        );
+        let registration = fixture.wait_for_registration();
+        assert_eq!(
+            registration["generation"],
+            format!("{}:1", installed["deploymentId"].as_str().unwrap())
+        );
+        let endpoint = registration["websocketEndpoints"][0].as_str().unwrap();
+        let address: SocketAddr = endpoint
+            .strip_prefix("ws://")
+            .unwrap()
+            .strip_suffix("/runtime-host")
+            .unwrap()
+            .parse()
+            .unwrap();
+        assert_ne!(address.port(), 0);
+        let mut health = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
+        health
+            .set_read_timeout(Some(Duration::from_secs(2)))
+            .unwrap();
+        health
+            .write_all(b"GET /readyz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+            .unwrap();
+        let mut response = String::new();
+        health.read_to_string(&mut response).unwrap();
+        assert!(response.starts_with("HTTP/1.1 200"), "{response}");
+
+        let repeated = Command::new(executable)
+            .args(["host", "install", "--root"])
+            .arg(&fixture.root)
+            .args(["--mode", mode])
+            .output()
+            .unwrap();
+        assert!(
+            repeated.status.success(),
+            "{}",
+            String::from_utf8_lossy(&repeated.stderr)
+        );
+        assert_eq!(
+            serde_json::from_slice::<Value>(&repeated.stdout).unwrap(),
+            installed
+        );
+        assert!(RootOwner::open(&fixture.root, &namespaces).is_err());
+        let retired = Command::new(env!("CARGO_BIN_EXE_maka"))
+            .args(["host", "retire", "--root"])
+            .arg(&fixture.root)
+            .output()
+            .unwrap();
+        assert!(
+            retired.status.success(),
+            "{}",
+            String::from_utf8_lossy(&retired.stderr)
+        );
+        assert!(fixture.wait_for_exit().success());
+        drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
+    }
+}
diff --git a/crates/cli/tests/integration/main.rs b/crates/cli/tests/integration/main.rs
index 828d2d4316..3ca0c01d65 100644
--- a/crates/cli/tests/integration/main.rs
+++ b/crates/cli/tests/integration/main.rs
@@ -19,3 +19,4 @@
 
 mod candidate;
 mod code_command;
+mod deployment;
diff --git a/crates/event-log/src/root/windows.rs b/crates/event-log/src/root/windows.rs
index 3659a5c584..4fe1b9b89b 100644
--- a/crates/event-log/src/root/windows.rs
+++ b/crates/event-log/src/root/windows.rs
@@ -24,7 +24,7 @@ mod filesystem;
 mod security;
 
 pub use account::account_home;
-pub(crate) use filesystem::publish_file;
+pub use filesystem::publish_file;
 pub use filesystem::{
     FileIdentity, create_private_file, file_identity, open_nofollow, private_directory,
 };
diff --git a/crates/event-log/src/root/windows/filesystem.rs b/crates/event-log/src/root/windows/filesystem.rs
index 0914ce7e74..0eb70bbea9 100644
--- a/crates/event-log/src/root/windows/filesystem.rs
+++ b/crates/event-log/src/root/windows/filesystem.rs
@@ -71,7 +71,7 @@ pub struct FileIdentity {
 
 /// Windows has no portable directory fsync. Publish a previously flushed marker
 /// with a write-through rename; omitting REPLACE_EXISTING preserves no-clobber.
-pub(crate) fn publish_file(source: &Path, target: &Path) -> io::Result<()> {
+pub fn publish_file(source: &Path, target: &Path) -> io::Result<()> {
     let source = wide(source.as_os_str())?;
     let target = wide(target.as_os_str())?;
     // SAFETY: both paths are NUL-terminated and live for the synchronous call.
diff --git a/crates/runtime-host/src/server/registration.rs b/crates/runtime-host/src/server/registration.rs
index 360856e32d..4446b73c5d 100644
--- a/crates/runtime-host/src/server/registration.rs
+++ b/crates/runtime-host/src/server/registration.rs
@@ -37,6 +37,7 @@ impl Host {
     pub fn publish_registration(
         self: &Arc<Self>,
         endpoint: &Path,
+        websocket: Option<std::net::SocketAddr>,
     ) -> Result<Registration, HostError> {
         self.root.validate_current()?;
         let endpoint = endpoint.to_str().ok_or("endpoint is not UTF-8")?;
@@ -54,6 +55,9 @@ impl Host {
         if let Some(generation) = &self.options.generation {
             value["generation"] = generation.clone().into();
         }
+        if let Some(address) = websocket {
+            value["websocketEndpoints"] = json!([format!("ws://{address}/runtime-host")]);
+        }
         let path = self.control_directory().join("registration.json");
         let mut temporary = tempfile::NamedTempFile::new_in(self.control_directory())?;
         serde_json::to_writer(&mut temporary, &value)?;
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 27f60242e2..9e9c603d25 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -53,6 +53,12 @@ Use `target/debug/maka` if the binary is not on PATH. Local transport is a Unix
 socket on Linux/macOS or a private named pipe on Windows. An optional
 `--websocket 127.0.0.1:0` listener requires authentication; TLS is not implemented.
 
+`host install --root <directory>` pins the current executable and on-demand policy;
+`--mode supervised` selects persistent serving. Only the returned `executable`
+may start that managed root. Installation does not yet register an OS service or
+provide automatic activation/updates. Deployment authority lives in account-level
+SQLite, outside the State Root; startup checks it before database migrations.
+
 The single binary also provides `host candidate` for Desktop-owned startup,
 `code --log <file>` for a JavaScript cell read from stdin, and
 `inspect --log <file>` for committed execution facts.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index fb250d6682..f5cb0895ff 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -49,6 +49,10 @@ maka host retire --root /absolute/path/to/new-root
 二进制未加入 PATH 时使用 `target/debug/maka`。Linux/macOS 使用 Unix socket,
 Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要认证,尚不支持 TLS。
 
+`host install --root <目录>` 固定当前可执行文件和按需策略;`--mode supervised` 选择持续运行。
+此后只有返回的 `executable` 可以启动该托管根。安装尚不注册 OS 服务,也不提供自动激活或更新。
+部署权威保存在 State Root 之外的账户级 SQLite 中,启动时先校验再执行数据库迁移。
+
 唯一二进制还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
 `code --log <file>`,以及查看已提交执行事实的 `inspect --log <file>`。
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 29/29: feat(desktop): activate and reuse native managed Hosts

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 787c21f5f54fd2b4052e3c9c537a4f6e6f81acce
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 00:56:43 2026 +0800

    feat(desktop): activate and reuse native managed Hosts
    
    Generated-by: OpenAI Codex
---
 .../src/main/native-runtime-host-deployment.ts     | 177 +++++++++++++++++++++
 apps/desktop/src/main/native-runtime-host.ts       |  47 +++++-
 apps/desktop/src/main/runtime-host-boot.ts         |  20 ++-
 .../src/main/runtime-host-desktop-candidate.ts     |   6 +-
 .../src/main/runtime-host-desktop-manager.ts       |   6 +-
 .../src/shared/native-runtime-host-deployment.ts   | 110 +++++++++++++
 crates/cli/tests/integration/deployment.rs         |  64 ++++++--
 docs/rust-runtime.md                               |   3 +
 docs/rust-runtime.zh-CN.md                         |   2 +
 .../runtime-host/src/client/connect-or-spawn.ts    |  11 +-
 tests/fixtures/client-activation-entry.mjs         |  24 +++
 tests/fixtures/client-native-managed-entry.mjs     | 140 ++++++++++++++++
 tests/fixtures/client.mjs                          |   1 +
 13 files changed, 580 insertions(+), 31 deletions(-)

diff --git a/apps/desktop/src/main/native-runtime-host-deployment.ts b/apps/desktop/src/main/native-runtime-host-deployment.ts
new file mode 100644
index 0000000000..c3f62667ac
--- /dev/null
+++ b/apps/desktop/src/main/native-runtime-host-deployment.ts
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { resolveStorageRoot } from '@maka/storage/root-authority';
+import {
+  connectExistingRuntimeHost,
+  type ConnectOrSpawnRuntimeHostInput,
+  type ConnectOrSpawnRuntimeHostResult,
+} from '@maka/runtime-host/client';
+import { decodeRuntimeHostActivationFrame } from '@maka/runtime-host/operator';
+import {
+  decodeNativeRuntimeHostDeploymentStatus,
+  type NativeRuntimeHostDeploymentStatus,
+} from '../shared/native-runtime-host-deployment.js';
+
+const run = promisify(execFile);
+type Route = { rootId: string; status: NativeRuntimeHostDeploymentStatus };
+type Input = ConnectOrSpawnRuntimeHostInput;
+type Result = ConnectOrSpawnRuntimeHostResult;
+
+export async function readNativeRuntimeHostDeployment(
+  executable: string,
+  rootId: string,
+): Promise<NativeRuntimeHostDeploymentStatus> {
+  const { stdout } = await run(executable, ['host', 'status', '--root-id', rootId], {
+    windowsHide: true,
+    maxBuffer: 256 * 1024,
+    timeout: 15_000,
+  });
+  return decodeNativeRuntimeHostDeploymentStatus(JSON.parse(stdout), rootId);
+}
+
+/** Routes are hints only. Every connection verifies the live Root and generation. */
+export function createNativeRuntimeHostDeploymentConnector(input: {
+  readonly executable: string;
+  readonly activate: (rootId: string) => Promise<string>;
+  readonly connectUnmanaged: (input: Input) => Promise<Result>;
+}): (request: Input) => Promise<Result> {
+  const routes = new Map<string, Promise<Route>>();
+  const route = (rootPath: string, refresh = false): Promise<Route> => {
+    if (refresh) routes.delete(rootPath);
+    let pending = routes.get(rootPath);
+    if (!pending) {
+      pending = (async () => {
+        const root = await resolveStorageRoot({ path: rootPath, kind: 'interactive' });
+        const status = await readNativeRuntimeHostDeployment(input.executable, root.rootId);
+        return { rootId: root.rootId, status };
+      })();
+      routes.set(rootPath, pending);
+      const selected = pending;
+      const forget = () => {
+        if (routes.get(rootPath) === selected) routes.delete(rootPath);
+      };
+      void pending.then(({ status }) => {
+        if (status.kind === 'incomplete' ||
+          (status.kind === 'installed' && status.deployment.admission)) forget();
+      }, forget);
+    }
+    return pending;
+  };
+
+  const connectManaged = async (request: Input, selected: Route): Promise<Result> => {
+    const connect = () => connectExistingRuntimeHost({
+      ...request,
+      generation: undefined,
+      takeoverHostEpoch: undefined,
+    });
+    for (let attempt = 0; attempt < 2; attempt++) {
+      request.signal?.throwIfAborted();
+      if (selected.status.kind !== 'installed' || selected.status.deployment.admission) {
+        throw new Error('Native Host deployment is absent, incomplete or uninstalled');
+      }
+      const deployment = selected.status.deployment;
+      const expected = `${deployment.deploymentId}:${deployment.configRevision}`;
+      const existing = await connect();
+      if (existing.kind === 'connected' || existing.kind === 'incompatible' ||
+        existing.kind === 'upgrade_required') {
+        if (existing.registration.rootId !== selected.rootId ||
+          existing.registration.generation !== expected ||
+          existing.registration.lifecycleMode !==
+            (deployment.mode === 'on_demand' ? 'ephemeral' : 'service')) {
+          if (existing.kind === 'connected') await existing.connection.close();
+          selected = await route(request.rootPath, true);
+          continue;
+        }
+        if (request.signal?.aborted) {
+          if (existing.kind === 'connected') await existing.connection.close();
+          request.signal.throwIfAborted();
+        }
+        return {
+          ...existing,
+          managedDeployment: {
+            deploymentId: deployment.deploymentId,
+            configRevision: deployment.configRevision,
+          },
+        };
+      }
+
+      request.signal?.throwIfAborted();
+      // Activation owns its own admission/election. A cancelled Desktop must
+      // collect the receipt, not kill a process halfway through OS activation.
+      const receipt = decodeRuntimeHostActivationFrame(
+        (await input.activate(selected.rootId)).trim(),
+      );
+      request.signal?.throwIfAborted();
+      if (!receipt || receipt.kind !== 'result' || receipt.rootId !== selected.rootId) {
+        throw new Error('Native Host activation did not return this Root');
+      }
+      const activated = await connect();
+      if (activated.kind !== 'connected') {
+        throw new Error('Activated native Host is not connectable');
+      }
+      const registration = activated.registration;
+      if (request.signal?.aborted ||
+        activated.connection.rootId !== receipt.rootId ||
+        activated.connection.hostEpoch !== receipt.hostEpoch ||
+        registration.pid !== receipt.pid ||
+        registration.generation !== `${receipt.deploymentId}:${receipt.configRevision}`) {
+        await activated.connection.close();
+        request.signal?.throwIfAborted();
+        throw new Error('Native Host changed after activation');
+      }
+      // A newer authority may have won while activation acquired the executor.
+      // Do not manufacture its full configuration from this live receipt.
+      if (receipt.deploymentId !== deployment.deploymentId ||
+        receipt.configRevision !== deployment.configRevision) routes.delete(request.rootPath);
+      return {
+        ...activated,
+        managedDeployment: {
+          deploymentId: receipt.deploymentId,
+          configRevision: receipt.configRevision,
+        },
+      };
+    }
+    throw new Error('Native Host deployment changed during connection; reconnect');
+  };
+
+  return async (request) => {
+    request.signal?.throwIfAborted();
+    const selected = await route(request.rootPath);
+    request.signal?.throwIfAborted();
+    if (selected.status.kind !== 'not_installed') return connectManaged(request, selected);
+    const result = await input.connectUnmanaged(request);
+    if (result.kind === 'connected' && result.registration.lifecycleMode === 'ephemeral' &&
+      result.registration.generation === request.generation) return result;
+    // Installation can happen after this Desktop starts. Failure is never a
+    // reason to bypass native admission with another bundled candidate.
+    let refreshed: Route;
+    try {
+      refreshed = await route(request.rootPath, true);
+    } catch (error) {
+      if (result.kind === 'connected') await result.connection.close();
+      throw error;
+    }
+    if (refreshed.status.kind === 'not_installed') return result;
+    if (result.kind === 'connected') await result.connection.close();
+    return connectManaged(request, refreshed);
+  };
+}
diff --git a/apps/desktop/src/main/native-runtime-host.ts b/apps/desktop/src/main/native-runtime-host.ts
index b4a81e6637..5f2dc5cb2a 100644
--- a/apps/desktop/src/main/native-runtime-host.ts
+++ b/apps/desktop/src/main/native-runtime-host.ts
@@ -26,6 +26,7 @@ import {
   type RuntimeHostCandidateLaunchBarrier,
   type RuntimeHostCandidateLaunchBarrierDependencies,
 } from '@maka/runtime-host/client';
+import { createNativeRuntimeHostDeploymentConnector } from './native-runtime-host-deployment.js';
 
 const run = promisify(execFile);
 type Launch = RuntimeHostCandidateLaunchBarrierDependencies['launchCandidate'];
@@ -53,7 +54,7 @@ export async function initializeNativeRuntimeHost(
 export function createNativeRuntimeHostCandidateLaunchBarrier(
   executable: string,
 ): RuntimeHostCandidateLaunchBarrier {
-  return createRuntimeHostCandidateLaunchBarrierWithDependencies({
+  const candidates = createRuntimeHostCandidateLaunchBarrierWithDependencies({
     retireTimeoutMs: 1000,
     launchCandidate: (input) => launchCandidate(executable, input),
     connect(input, launchCandidate) {
@@ -65,6 +66,50 @@ export function createNativeRuntimeHostCandidateLaunchBarrier(
       });
     },
   });
+  let launchesAllowed = true;
+  let released = false;
+  const activations = new Map<string, Promise<string>>();
+  const connect = createNativeRuntimeHostDeploymentConnector({
+    executable,
+    connectUnmanaged: (input) => candidates.connect(input),
+    activate(rootId) {
+      if (!launchesAllowed) throw new Error('Native Host launches are paused');
+      const existing = activations.get(rootId);
+      if (existing) return existing;
+      const activation = run(executable, ['host', 'activate', '--root-id', rootId, '--framed'], {
+        maxBuffer: 32 * 1024,
+        windowsHide: true,
+      }).then(({ stdout }) => stdout);
+      activations.set(rootId, activation);
+      void activation.finally(() => activations.delete(rootId)).catch(() => undefined);
+      return activation;
+    },
+  });
+  return {
+    connect(input) {
+      if (!launchesAllowed) return Promise.reject(new Error('Native Host launches are paused'));
+      return connect(input);
+    },
+    pause() {
+      candidates.pause();
+      launchesAllowed = false;
+    },
+    async retireExcept(protectedPid) {
+      if (launchesAllowed) throw new Error('Native Host launches must be paused before retirement');
+      await Promise.allSettled(activations.values());
+      await candidates.retireExcept(protectedPid);
+    },
+    resume() {
+      if (released) return;
+      candidates.resume();
+      launchesAllowed = true;
+    },
+    release() {
+      released = true;
+      launchesAllowed = false;
+      candidates.release();
+    },
+  };
 }
 
 function launchCandidate(executable: string, input: Parameters<Launch>[0]): ReturnType<Launch> {
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 45f134a6e2..e00956f21e 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1393,15 +1393,21 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager(
         isDefault: true,
       });
     },
-    recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal),
-    resolveStartupRepair: (error, signal) => localRuntimeHostRemoteAccess.resolveStartupRepair(error, signal),
+    recoverLocalHost: (signal) => isE2e
+      ? localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal)
+      : Promise.resolve(false),
+    resolveStartupRepair: (error, signal) => isE2e
+      ? localRuntimeHostRemoteAccess.resolveStartupRepair(error, signal)
+      : Promise.resolve(undefined),
     resolveWslHostHandoff: async (profile, error, signal) => resolveDesktopWslHostHandoff(profile, error, signal, {
       locale: await desktopLocale.resolve(),
       resolveBinding: (profileId) => runtimeHostProfileService.resolveManagedService(profileId),
       resolvePackage: (packageSignal) => runtimeHostSetupPackage.resolve('none', packageSignal),
     }),
     resolveLocalHostReplacement: (registration, signal) =>
-      localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal),
+      isE2e
+        ? localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal)
+        : Promise.resolve(undefined),
     onFatalError: (error, target) => {
       // Initial failure is handled after manager.start() has closed its own
       // observations. Do not quit before startup-owned resources are drained.
@@ -1482,9 +1488,11 @@ windowsAppTray.start();
 await guestSessionMountService.start().catch((error: unknown) => {
   console.error('[runtime-host] shared Sessions could not be restored:', error);
 });
-await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => {
-  console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error);
-});
+if (isE2e) {
+  await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => {
+    console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error);
+  });
+}
 void runtimeHostProfileService.startEnabledProfiles();
 const unavailableDefault = runtimeHostStartup.unavailable.get(
   runtimeHostStartup.preferences.defaultProfileId,
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index e71316bb18..c768fbcc13 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -209,7 +209,7 @@ export interface DesktopRuntimeHostCandidateControls {
   refreshClientCapabilities(): Promise<void>;
 }
 
-export type DesktopRuntimeHostOwnership = 'owned_ephemeral' | 'supervised' | 'external';
+export type DesktopRuntimeHostOwnership = 'owned_ephemeral' | 'managed' | 'supervised' | 'external';
 
 export interface DesktopRuntimeHostCandidateStartInput
   extends Omit<DesktopRuntimeHostCandidateDeps, "ipcMain"> {
@@ -396,7 +396,9 @@ export async function startDesktopRuntimeHostCandidate(
         connection.connection,
         { ...input, ipcMain },
         observationRegistry,
-        connection.registration.lifecycleMode === 'ephemeral'
+        connection.managedDeployment
+          ? 'managed'
+          : connection.registration.lifecycleMode === 'ephemeral'
           ? 'owned_ephemeral'
           : 'supervised',
         "local",
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index 8bc05d2f90..2ad6adb31a 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -1215,12 +1215,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
       }
       if (result.kind === 'incompatible' || result.kind === 'upgrade_required') {
         const conflict = result;
-        const idleTakeover = result.kind === 'upgrade_required' && result.restartable &&
+        const idleTakeover = !result.managedDeployment && result.kind === 'upgrade_required' && result.restartable &&
           !target.input.profileTarget && target.input.generation !== undefined;
-        const cooperativeRetirement = !target.input.profileTarget &&
+        const cooperativeRetirement = !result.managedDeployment && !target.input.profileTarget &&
           result.registration.lifecycleMode === 'ephemeral' &&
           result.handshake?.activity?.cooperativeHandoff === true;
-        const replacement = target.input.profileTarget
+        const replacement = target.input.profileTarget || result.managedDeployment
           ? undefined
           : this.#registeredEphemeralHostReplacement(target, result, signal) ??
             (await this.resolveLocalHostReplacement?.(result.registration, signal));
diff --git a/apps/desktop/src/shared/native-runtime-host-deployment.ts b/apps/desktop/src/shared/native-runtime-host-deployment.ts
new file mode 100644
index 0000000000..a65fbf184f
--- /dev/null
+++ b/apps/desktop/src/shared/native-runtime-host-deployment.ts
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { z } from 'zod';
+
+const count = z.number().int().nonnegative().safe();
+const identity = z.string().min(1).max(128);
+const digest = z.string().regex(/^[a-f0-9]{64}$/u);
+const path = z.string().min(1).max(32_768);
+const message = z.string().max(8192);
+const deployment = z.object({
+  deploymentId: z.string().uuid(),
+  configRevision: count.positive(),
+  rootId: digest,
+  rootPath: path,
+  executable: path,
+  sha256: digest,
+  mode: z.enum(['on_demand', 'supervised']),
+  websocket: z.string().regex(/^127\.0\.0\.1:(?:0|[1-9][0-9]{0,4})$/u)
+    .refine((value) => Number(value.split(':')[1]) <= 65_535),
+  projectDirectoryRoots: z.array(z.object({
+    label: z.string().min(1).max(256),
+    path,
+  }).strict()).max(8).optional(),
+  admission: z.literal('revoked').optional(),
+}).strict();
+
+const unavailable = z.object({ kind: z.literal('unavailable'), message }).strict();
+const status = z.discriminatedUnion('kind', [
+  z.object({ kind: z.literal('not_installed') }).strict(),
+  z.object({ kind: z.literal('incomplete') }).strict(),
+  z.object({
+    kind: z.literal('installed'),
+    deployment,
+    pendingUpdate: deployment.nullable(),
+    supervisor: z.discriminatedUnion('kind', [
+      z.object({ kind: z.literal('on_demand') }).strict(),
+      z.object({ kind: z.literal('missing') }).strict(),
+      unavailable,
+      z.object({
+        kind: z.literal('present'),
+        state: z.enum(['stopped', 'starting', 'running', 'stopping', 'failed']),
+        enabled: z.boolean().nullable(),
+        pid: count.positive().nullable(),
+        lastResult: z.number().int().safe().nullable(),
+      }).strict(),
+    ]),
+    host: z.discriminatedUnion('kind', [
+      z.object({ kind: z.literal('not_admitted') }).strict(),
+      unavailable,
+      z.object({
+        kind: z.literal('connected'),
+        identity: z.object({
+          hostEpoch: identity,
+          pid: count.positive(),
+          port: count.min(1).max(65_535),
+        }).strict(),
+        activity: z.object({
+          state: z.enum(['starting', 'containing', 'recovering', 'ready', 'draining']),
+          connections: count,
+          activeOperations: count,
+          activeResidencies: count,
+        }).strict(),
+      }).strict(),
+    ]),
+  }).strict(),
+]);
+
+export type NativeRuntimeHostDeployment = z.infer<typeof deployment>;
+export type NativeRuntimeHostDeploymentStatus = z.infer<typeof status>;
+
+export function decodeNativeRuntimeHostDeploymentStatus(
+  value: unknown,
+  rootId: string,
+): NativeRuntimeHostDeploymentStatus {
+  const result = status.parse(value);
+  if (result.kind === 'installed') {
+    if (result.deployment.rootId !== rootId) {
+      throw new Error('Native Host deployment belongs to another Root');
+    }
+    const pending = result.pendingUpdate;
+    if (pending && (
+      pending.rootId !== rootId ||
+      pending.rootPath !== result.deployment.rootPath ||
+      pending.deploymentId !== result.deployment.deploymentId ||
+      pending.configRevision !== result.deployment.configRevision + 1 ||
+      pending.admission !== undefined ||
+      result.deployment.admission !== undefined
+    )) {
+      throw new Error('Native Host pending update belongs to another deployment revision');
+    }
+  }
+  return result;
+}
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index e0833e87c1..14a2c6d1c5 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -246,6 +246,27 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(observed["host"]["identity"]["pid"], registration["pid"]);
         assert_eq!(observed["host"]["activity"]["state"], "ready");
         assert!(RootOwner::open(&fixture.root, &namespaces).is_err());
+        // Both lifecycle modes use the original Desktop connector. In
+        // particular, a service cannot be replaced by Desktop generation.
+        let client = Command::new("node")
+            .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
+            .arg("--activation-frame")
+            .arg(String::from_utf8(activate.output().unwrap().stdout).unwrap())
+            .args([
+                "--native-host",
+                env!("CARGO_BIN_EXE_maka"),
+                "--lifecycle-mode",
+                if candidate { "ephemeral" } else { "service" },
+            ])
+            .arg("--root")
+            .arg(&fixture.root)
+            .output()
+            .unwrap();
+        assert!(
+            client.status.success(),
+            "{}",
+            String::from_utf8_lossy(&client.stderr)
+        );
         let retired = Command::new("node")
             .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
             .args([
@@ -299,23 +320,6 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(frame["deploymentId"], installed["deploymentId"]);
         // The operator has exited; a relay still has time to establish its client.
         std::thread::sleep(Duration::from_millis(1100));
-        let client = Command::new("node")
-            .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
-            .arg("--activation-frame")
-            .arg(String::from_utf8(activated.stdout).unwrap())
-            .args([
-                "--lifecycle-mode",
-                if candidate { "ephemeral" } else { "service" },
-            ])
-            .arg("--root")
-            .arg(&fixture.root)
-            .output()
-            .unwrap();
-        assert!(
-            client.status.success(),
-            "{}",
-            String::from_utf8_lossy(&client.stderr)
-        );
         let reused = activate.output().unwrap();
         assert!(reused.status.success(), "{reused:?}");
         assert_eq!(decode_activation(&reused.stdout), frame);
@@ -346,6 +350,30 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
             "stopped"
         );
         drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
+        let native_desktop = |revoked: bool| {
+            let mut client = Command::new("node");
+            client
+                .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
+                .args([
+                    "--native-managed",
+                    env!("CARGO_BIN_EXE_maka"),
+                    "--root-id",
+                    &fixture.root_id,
+                ])
+                .arg("--root")
+                .arg(&fixture.root);
+            if revoked {
+                client.arg("--revoked");
+            }
+            let output = client.output().unwrap();
+            assert!(
+                output.status.success(),
+                "{}",
+                String::from_utf8_lossy(&output.stderr)
+            );
+        };
+        native_desktop(false);
+        drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
         let uninstalled = control("uninstall").output().unwrap();
         assert!(uninstalled.status.success(), "{uninstalled:?}");
         let uninstalled: Value = serde_json::from_slice(&uninstalled.stdout).unwrap();
@@ -356,6 +384,8 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         let observed = query("status");
         assert_eq!(observed["deployment"], uninstalled["deployment"]);
         assert_eq!(observed["host"]["kind"], "not_admitted");
+        native_desktop(true);
+        drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
         let repeated = control("uninstall").output().unwrap();
         assert!(repeated.status.success(), "{repeated:?}");
         assert_eq!(
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 3b5a055639..ff35d5753c 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -79,6 +79,9 @@ starts an account-level systemd service, LaunchAgent or Windows scheduled task.
 Linux requires an active user manager with lingering enabled; macOS requires an
 Aqua login and Windows an interactive user session. Installation alone does not
 start a service or change account policy.
+Desktop reuses managed native Hosts and activates pinned code when needed;
+its own generation and exit do not govern their lifetime. Pausing local launches
+waits for outstanding activations before handing off the Root.
 Deployment authority lives in account-level
 SQLite, outside the State Root; startup checks it before database migrations.
 On Windows, supervised installations use the sibling `maka-service.exe`, a
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 541f19bbad..d50a28d87a 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -68,6 +68,8 @@ Linux inode 未变并保留 Root ID。不要用它接管复制的根或旧版数
 复用就绪 Host,或启动固定版本;supervised 模式会注册并启动账户级 systemd 服务、LaunchAgent
 或 Windows 计划任务。Linux 要求用户服务管理器已运行且启用 linger;macOS 要求 Aqua 登录,
 Windows 要求交互式用户会话。单独安装不会启动服务或修改账户策略。
+Desktop 复用原生托管 Host,需要时激活固定版本;自身版本变化和退出不控制托管 Host 的寿命。
+暂停本地启动后,会等待已发出的激活收尾,再交接 Root。
 部署权威保存在 State Root 之外的账户级 SQLite 中,启动时先校验再执行数据库迁移。
 Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 的无窗口入口,须与 `maka.exe` 一起分发。
 按需激活要求启动环境允许脱离 Windows Job;应直接运行已构建的程序,不要通过 `cargo run` 激活。
diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts
index 3d99965454..0aafbd278c 100644
--- a/packages/runtime-host/src/client/connect-or-spawn.ts
+++ b/packages/runtime-host/src/client/connect-or-spawn.ts
@@ -132,7 +132,7 @@ export function electionDeadlineMsFromEnvironment(
   return parsed;
 }
 
-export type ConnectOrSpawnRuntimeHostResult =
+export type ConnectOrSpawnRuntimeHostResult = (
   | {
       kind: 'connected';
       connection: RuntimeHostConnection;
@@ -151,7 +151,14 @@ export type ConnectOrSpawnRuntimeHostResult =
       kind: 'failed';
       reason: CandidateStartupFailure['reason'] | 'startup_timeout' | 'host_unresponsive';
       diagnostic?: RuntimeHostElectionDiagnostic;
-    };
+    }
+) & {
+  /** The deployment operator owns this Host, including an on-demand process. */
+  readonly managedDeployment?: {
+    readonly deploymentId: string;
+    readonly configRevision: number;
+  };
+};
 
 export interface RuntimeHostElectionDiagnostic {
   readonly deadlineMs: number;
diff --git a/tests/fixtures/client-activation-entry.mjs b/tests/fixtures/client-activation-entry.mjs
index 7e4e075934..707d8eef0c 100644
--- a/tests/fixtures/client-activation-entry.mjs
+++ b/tests/fixtures/client-activation-entry.mjs
@@ -21,11 +21,13 @@ import assert from 'node:assert/strict';
 import { parseArgs } from 'node:util';
 import { connectExistingRuntimeHost } from '../../packages/runtime-host/src/client/connection.js';
 import { decodeRuntimeHostActivationFrame } from '../../packages/runtime-host/src/operator/activation-frame.js';
+import { createNativeRuntimeHostCandidateLaunchBarrier } from '../../apps/desktop/src/main/native-runtime-host.js';
 
 const { values } = parseArgs({
   options: {
     'activation-frame': { type: 'string' },
     'lifecycle-mode': { type: 'string' },
+    'native-host': { type: 'string' },
     root: { type: 'string' },
   },
 });
@@ -41,6 +43,28 @@ const connection = await connectExistingRuntimeHost(input);
 assert.equal(connection.kind, 'connected', JSON.stringify(connection));
 assert.equal(connection.registration.lifecycleMode, values['lifecycle-mode']);
 try {
+  const barrier = createNativeRuntimeHostCandidateLaunchBarrier(values['native-host']);
+  try {
+    const managed = await barrier.connect({
+      ...input,
+      generation: 'desktop-generation',
+      takeoverHostEpoch: frame.hostEpoch,
+      candidateEntrypoint: 'must-not-spawn-this',
+    });
+    assert.equal(managed.kind, 'connected');
+    try {
+      assert.equal(managed.spawnedProcess, undefined);
+      assert.equal(managed.connection.hostEpoch, frame.hostEpoch);
+      assert.deepEqual(managed.managedDeployment, {
+        deploymentId: frame.deploymentId,
+        configRevision: frame.configRevision,
+      });
+    } finally {
+      await managed.connection.close();
+    }
+  } finally {
+    barrier.release();
+  }
   assert.equal(connection.connection.rootId, frame.rootId);
   assert.equal(connection.connection.hostEpoch, frame.hostEpoch);
   const diagnostics = await connection.connection.request('host.diagnostics.query', {});
diff --git a/tests/fixtures/client-native-managed-entry.mjs b/tests/fixtures/client-native-managed-entry.mjs
new file mode 100644
index 0000000000..9f143d7e6f
--- /dev/null
+++ b/tests/fixtures/client-native-managed-entry.mjs
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { execFile } from 'node:child_process';
+import { parseArgs, promisify } from 'node:util';
+import { createNativeRuntimeHostCandidateLaunchBarrier } from '../../apps/desktop/src/main/native-runtime-host.js';
+import { readNativeRuntimeHostDeployment } from '../../apps/desktop/src/main/native-runtime-host-deployment.js';
+import { decodeNativeRuntimeHostDeploymentStatus } from '../../apps/desktop/src/shared/native-runtime-host-deployment.js';
+import { connectExistingRuntimeHost } from '../../packages/runtime-host/src/client/connection.js';
+
+const run = promisify(execFile);
+const { values } = parseArgs({
+  options: {
+    'native-managed': { type: 'string' },
+    root: { type: 'string' },
+    'root-id': { type: 'string' },
+    revoked: { type: 'boolean', default: false },
+  },
+});
+const executable = values['native-managed'];
+assert(executable && values.root && values['root-id']);
+const observed = await readNativeRuntimeHostDeployment(executable, values['root-id']);
+assert.equal(observed.kind, 'installed');
+assert.throws(() => decodeNativeRuntimeHostDeploymentStatus(observed, 'f'.repeat(64)));
+assert.throws(() =>
+  decodeNativeRuntimeHostDeploymentStatus(
+    {
+      ...observed,
+      pendingUpdate: { ...observed.deployment, configRevision: 0 },
+    },
+    values['root-id'],
+  ),
+);
+const expected = {
+  deploymentId: observed.deployment.deploymentId,
+  configRevision: observed.deployment.configRevision,
+};
+const barrier = createNativeRuntimeHostCandidateLaunchBarrier(executable);
+const request = {
+  rootPath: values.root,
+  protocol: { min: 0, max: 0 },
+  compositionId: 'maka.interactive',
+  generation: 'desktop-version-does-not-own-managed-host',
+  candidateEntrypoint: 'must-not-spawn-this',
+  closeOnLauncherExit: true,
+};
+const connections = [];
+async function stop() {
+  const deadline = Date.now() + 5000;
+  for (;;) {
+    const { stdout } = await run(
+      executable,
+      [
+        'host',
+        'stop',
+        '--root-id',
+        values['root-id'],
+        '--expected-deployment-id',
+        expected.deploymentId,
+        '--expected-revision',
+        String(expected.configRevision),
+      ],
+      { windowsHide: true },
+    );
+    const outcome = JSON.parse(stdout);
+    if (outcome.kind === 'stopped') return;
+    assert.equal(outcome.kind, 'active_tasks');
+    assert(Date.now() < deadline, 'fixture connections did not finish closing');
+  }
+}
+try {
+  if (values.revoked) {
+    assert.equal(observed.deployment.admission, 'revoked');
+    await assert.rejects(barrier.connect(request), /uninstalled/u);
+  } else {
+    assert.equal(observed.host.kind, 'unavailable', 'exercise cold managed activation');
+    const pair = await Promise.all([barrier.connect(request), barrier.connect(request)]);
+    for (const result of pair) {
+      assert.equal(result.kind, 'connected', JSON.stringify(result));
+      connections.push(result.connection);
+      assert.equal(result.spawnedProcess, undefined, 'operator PID is not Host ownership');
+      assert.deepEqual(result.managedDeployment, expected);
+    }
+    assert.equal(pair[0].connection.hostEpoch, pair[1].connection.hostEpoch);
+    const firstEpoch = pair[0].connection.hostEpoch;
+    const observer = await barrier.connect({
+      ...request,
+      generation: 'another-desktop-version',
+      takeoverHostEpoch: firstEpoch,
+    });
+    assert.equal(observer.kind, 'connected');
+    connections.push(observer.connection);
+    assert.equal(observer.connection.hostEpoch, firstEpoch);
+    assert.deepEqual(observer.managedDeployment, expected);
+    barrier.pause();
+    await assert.rejects(barrier.connect(request), /paused/u);
+    await barrier.retireExcept(pair[0].registration.pid);
+    assert.equal((await observer.connection.status()).state, 'ready');
+    for (const connection of connections.splice(0)) await connection.close();
+    await stop();
+    barrier.resume();
+    const replacement = await barrier.connect(request);
+    assert.equal(replacement.kind, 'connected');
+    connections.push(replacement.connection);
+    assert.notEqual(replacement.connection.hostEpoch, firstEpoch);
+    assert.deepEqual(replacement.managedDeployment, expected);
+    barrier.release();
+    barrier.resume();
+    await assert.rejects(barrier.connect(request), /paused/u);
+    const independent = await connectExistingRuntimeHost({
+      rootPath: values.root,
+      protocol: request.protocol,
+      compositionId: request.compositionId,
+    });
+    assert.equal(independent.kind, 'connected');
+    connections.push(independent.connection);
+    assert.equal(independent.connection.hostEpoch, replacement.connection.hostEpoch);
+  }
+} finally {
+  barrier.release();
+  for (const connection of connections) await connection.close();
+  if (!values.revoked) await stop();
+}
diff --git a/tests/fixtures/client.mjs b/tests/fixtures/client.mjs
index 59e703572f..243ad88bec 100644
--- a/tests/fixtures/client.mjs
+++ b/tests/fixtures/client.mjs
@@ -36,6 +36,7 @@ const entries = [
   ['--project-workspace', 'client-project-entry.mjs'],
   ['--native-candidate', 'client-candidate-entry.mjs'],
   ['--native-access', 'client-access-entry.mjs'],
+  ['--native-managed', 'client-native-managed-entry.mjs'],
   ['--activation-frame', 'client-activation-entry.mjs'],
   ['--bridge', 'client-bridge-entry.mjs'],
   ['--system-prompt-workspace', 'client-system-prompt-entry.mjs'],


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 01/29: feat(runtime-host): support diagnostics and safe retirement

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d4bc6dec5cb5bff02895c84e7079cc9abcfc54c5
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 09:58:36 2026 +0800

    feat(runtime-host): support diagnostics and safe retirement
---
 crates/process/Cargo.toml                          |   2 +-
 crates/process/src/lib.rs                          |   1 +
 crates/process/src/system.rs                       |  60 +++++++
 crates/protocol/src/host.rs                        |  82 +++++++++
 crates/protocol/src/host/status.rs                 | 191 +++++++++++++++++++++
 crates/protocol/src/lib.rs                         |   1 +
 crates/runtime-host/src/server.rs                  |   7 +
 crates/runtime-host/src/server/bootstrap.rs        |  70 ++++----
 crates/runtime-host/src/server/connection.rs       |  25 ++-
 crates/runtime-host/src/server/diagnostics.rs      | 174 +++++++++++++++++++
 crates/runtime-host/src/server/dispatch.rs         |  24 ++-
 crates/runtime-host/src/server/handshake.rs        |  14 +-
 crates/runtime-host/src/server/listeners.rs        |  12 +-
 crates/runtime-host/src/server/oauth.rs            |  12 ++
 crates/runtime-host/src/server/outbound.rs         |   9 +-
 crates/runtime-host/src/server/retirement.rs       |  58 +++++++
 crates/runtime-host/tests/integration/main.rs      |   1 +
 crates/runtime-host/tests/integration/oauth.rs     |  35 +++-
 .../runtime-host/tests/integration/retirement.rs   | 164 ++++++++++++++++++
 docs/rust-runtime.md                               |   4 +
 docs/rust-runtime.zh-CN.md                         |   3 +
 tests/fixtures/client-candidate-entry.mjs          |  52 +++++-
 22 files changed, 921 insertions(+), 80 deletions(-)

diff --git a/crates/process/Cargo.toml b/crates/process/Cargo.toml
index 12e0ce93b9..c48089c11a 100644
--- a/crates/process/Cargo.toml
+++ b/crates/process/Cargo.toml
@@ -37,7 +37,7 @@ rustix = { version = "1.1.4", features = ["pty", "termios", "fs", "process"] }
 [target.'cfg(windows)'.dependencies]
 dunce = "1.0.5"
 uuid.workspace = true
-windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] }
+windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemInformation", "Wdk_System_SystemServices"] }
 
 [dev-dependencies]
 tempfile.workspace = true
diff --git a/crates/process/src/lib.rs b/crates/process/src/lib.rs
index 75081fe3fc..d2263dabbf 100644
--- a/crates/process/src/lib.rs
+++ b/crates/process/src/lib.rs
@@ -21,6 +21,7 @@
 //! Durable background/PTY resource ownership belongs to the runtime.
 
 mod output;
+pub mod system;
 pub use output::{Captured as PipeResult, Outcome as ProcessOutcome, Termination};
 #[cfg(unix)]
 mod process;
diff --git a/crates/process/src/system.rs b/crates/process/src/system.rs
new file mode 100644
index 0000000000..6f16d55f31
--- /dev/null
+++ b/crates/process/src/system.rs
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Native observations; no dependency on an installed shell or utility.
+
+#[cfg(unix)]
+pub fn os_release() -> std::io::Result<String> {
+    let mut info = std::mem::MaybeUninit::<libc::utsname>::uninit();
+    // SAFETY: uname initializes the complete structure on success, including
+    // the NUL-terminated release array. Nothing is read on failure.
+    unsafe {
+        if libc::uname(info.as_mut_ptr()) != 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        let info = info.assume_init();
+        Ok(std::ffi::CStr::from_ptr(info.release.as_ptr())
+            .to_string_lossy()
+            .into_owned())
+    }
+}
+
+#[cfg(windows)]
+pub fn os_release() -> std::io::Result<String> {
+    use windows_sys::{
+        Wdk::System::SystemServices::RtlGetVersion,
+        Win32::System::SystemInformation::OSVERSIONINFOW,
+    };
+    let mut info = OSVERSIONINFOW {
+        dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32,
+        ..Default::default()
+    };
+    // SAFETY: a writable, correctly sized OSVERSIONINFOW lives through the call.
+    // Unlike GetVersionEx, this observation is not altered by the app manifest.
+    let status = unsafe { RtlGetVersion(&mut info) };
+    if status < 0 {
+        return Err(std::io::Error::other(format!(
+            "RtlGetVersion failed: {status:#x}"
+        )));
+    }
+    Ok(format!(
+        "{}.{}.{}",
+        info.dwMajorVersion, info.dwMinorVersion, info.dwBuildNumber
+    ))
+}
diff --git a/crates/protocol/src/host.rs b/crates/protocol/src/host.rs
new file mode 100644
index 0000000000..4563c9834f
--- /dev/null
+++ b/crates/protocol/src/host.rs
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{ProtocolError, Result, codec};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+mod status;
+pub use status::{Diagnostics, Platform, Residency, Status, decode_diagnostics, decode_status};
+
+/// Maintenance targets a process lifetime, not merely a State Root or a PID.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RetirementInput {
+    pub expected_host_epoch: String,
+    pub allow_interrupt_active_tasks: bool,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub allow_cooperative_handoff: Option<bool>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum RetirementResult {
+    ActiveTasks,
+    Prepared { pid: std::num::NonZeroU32 },
+}
+
+pub fn decode_retirement_input(value: &Value) -> Result<RetirementInput> {
+    codec::shaped(
+        codec::record(value, "Host retirement")?,
+        &["expectedHostEpoch", "allowInterruptActiveTasks"],
+        &["allowCooperativeHandoff"],
+    )?;
+    let boolean = |value: &Value| {
+        value
+            .as_bool()
+            .ok_or_else(|| ProtocolError::invalid("Invalid retirement policy"))
+    };
+    Ok(RetirementInput {
+        expected_host_epoch: codec::string(&value["expectedHostEpoch"], "Host epoch", 128)?,
+        allow_interrupt_active_tasks: boolean(&value["allowInterruptActiveTasks"])?,
+        allow_cooperative_handoff: value
+            .get("allowCooperativeHandoff")
+            .map(boolean)
+            .transpose()?,
+    })
+}
+
+pub fn decode_retirement_result(value: &Value) -> Result<RetirementResult> {
+    let fields = codec::record(value, "Host retirement result")?;
+    match value["kind"].as_str() {
+        Some("active_tasks") => {
+            codec::exact(fields, &["kind"])?;
+            Ok(RetirementResult::ActiveTasks)
+        }
+        Some("prepared") => {
+            codec::exact(fields, &["kind", "pid"])?;
+            let pid = u32::try_from(codec::count(&value["pid"], "Host PID")?)
+                .ok()
+                .and_then(std::num::NonZeroU32::new)
+                .ok_or_else(|| ProtocolError::invalid("Invalid Host PID"))?;
+            Ok(RetirementResult::Prepared { pid })
+        }
+        _ => Err(ProtocolError::invalid("Invalid retirement result")),
+    }
+}
diff --git a/crates/protocol/src/host/status.rs b/crates/protocol/src/host/status.rs
new file mode 100644
index 0000000000..ce60d14488
--- /dev/null
+++ b/crates/protocol/src/host/status.rs
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{ProtocolError, Result, codec, handshake::Lifecycle};
+use serde::Serialize;
+use serde_json::Value;
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Status<'a> {
+    pub host_epoch: &'a str,
+    pub composition_id: &'a str,
+    pub composition_revision: &'a str,
+    pub state: Lifecycle,
+    pub connections: usize,
+    pub active_operations: usize,
+    pub active_residencies: usize,
+}
+
+#[derive(Debug, Serialize)]
+pub struct Residency<'a> {
+    pub label: &'a str,
+    pub count: usize,
+}
+
+#[derive(Debug, Clone, Copy, Serialize)]
+pub enum Platform {
+    #[serde(rename = "linux")]
+    Linux,
+    #[serde(rename = "darwin")]
+    MacOs,
+    #[serde(rename = "win32")]
+    Windows,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Diagnostics<'a> {
+    #[serde(flatten)]
+    pub status: Status<'a>,
+    pub composition_modules: &'a [&'a str],
+    pub residencies: Vec<Residency<'a>>,
+    pub upgrade_blocking_activity: bool,
+    pub protocol_version: u64,
+    pub compatibility_epoch: u64,
+    pub pid: u32,
+    pub process_uptime_seconds: u64,
+    /// Legacy wire name. A native Host explicitly reports that Node is absent.
+    pub node_version: &'a str,
+    pub platform: Platform,
+    pub arch: &'a str,
+    pub os_release: String,
+    pub logs: Vec<String>,
+}
+
+const STATUS_FIELDS: &[&str] = &[
+    "hostEpoch",
+    "compositionId",
+    "compositionRevision",
+    "state",
+    "connections",
+    "activeOperations",
+    "activeResidencies",
+];
+const DIAGNOSTIC_FIELDS: &[&str] = &[
+    "compositionModules",
+    "residencies",
+    "upgradeBlockingActivity",
+    "protocolVersion",
+    "compatibilityEpoch",
+    "pid",
+    "processUptimeSeconds",
+    "nodeVersion",
+    "platform",
+    "arch",
+    "osRelease",
+    "logs",
+];
+
+pub fn decode_status(value: &Value) -> Result<()> {
+    codec::exact(codec::record(value, "Host status")?, STATUS_FIELDS)?;
+    status_fields(value)
+}
+
+pub fn decode_diagnostics(value: &Value) -> Result<()> {
+    if serde_json::to_vec(value)
+        .map_err(|e| ProtocolError::invalid(e.to_string()))?
+        .len()
+        > 72 * 1024
+    {
+        return Err(ProtocolError::invalid("Host diagnostics exceed byte limit"));
+    }
+    let fields = codec::record(value, "Host diagnostics")?;
+    codec::shaped(fields, STATUS_FIELDS, DIAGNOSTIC_FIELDS)?;
+    if DIAGNOSTIC_FIELDS
+        .iter()
+        .any(|key| !fields.contains_key(*key))
+    {
+        return Err(ProtocolError::invalid("Missing Host diagnostics field"));
+    }
+    status_fields(value)?;
+    for entry in entries(&value["compositionModules"], 64)? {
+        codec::string(entry, "composition module", 64)?;
+    }
+    for entry in entries(&value["residencies"], 128)? {
+        codec::exact(codec::record(entry, "residency")?, &["label", "count"])?;
+        codec::string(&entry["label"], "residency label", 128)?;
+        codec::count(&entry["count"], "residency count")?;
+    }
+    if !value["upgradeBlockingActivity"].is_boolean() {
+        return Err(ProtocolError::invalid("Invalid upgrade blocking activity"));
+    }
+    for key in [
+        "protocolVersion",
+        "compatibilityEpoch",
+        "pid",
+        "processUptimeSeconds",
+    ] {
+        codec::count(&value[key], key)?;
+    }
+    for (key, limit) in [("nodeVersion", 64), ("arch", 64), ("osRelease", 256)] {
+        codec::string(&value[key], key, limit)?;
+    }
+    if !matches!(
+        value["platform"].as_str(),
+        Some(
+            "aix"
+                | "android"
+                | "darwin"
+                | "freebsd"
+                | "haiku"
+                | "linux"
+                | "openbsd"
+                | "sunos"
+                | "win32"
+                | "cygwin"
+                | "netbsd"
+        )
+    ) {
+        return Err(ProtocolError::invalid("Invalid Host platform"));
+    }
+    for entry in entries(&value["logs"], 256)? {
+        if !entry
+            .as_str()
+            .is_some_and(|s| !s.is_empty() && s.len() <= 10 * 1024)
+        {
+            return Err(ProtocolError::invalid("Invalid Host diagnostic log entry"));
+        }
+    }
+    Ok(())
+}
+
+fn status_fields(value: &Value) -> Result<()> {
+    for key in ["hostEpoch", "compositionId", "compositionRevision"] {
+        codec::string(&value[key], key, 128)?;
+    }
+    for key in ["connections", "activeOperations", "activeResidencies"] {
+        codec::count(&value[key], key)?;
+    }
+    if !matches!(
+        value["state"].as_str(),
+        Some("starting" | "containing" | "recovering" | "ready" | "draining")
+    ) {
+        return Err(ProtocolError::invalid("Invalid Host lifecycle"));
+    }
+    Ok(())
+}
+
+fn entries(value: &Value, limit: usize) -> Result<&[Value]> {
+    value
+        .as_array()
+        .filter(|a| a.len() <= limit)
+        .map(Vec::as_slice)
+        .ok_or_else(|| ProtocolError::invalid("Invalid Host diagnostic entries"))
+}
diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs
index a90ff25451..82b75f4eca 100644
--- a/crates/protocol/src/lib.rs
+++ b/crates/protocol/src/lib.rs
@@ -31,6 +31,7 @@ pub mod context;
 mod display;
 pub mod execution_boundary;
 pub mod handshake;
+pub mod host;
 pub mod interaction;
 pub mod message;
 pub mod navigation;
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index 396060d789..37a1f7e103 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -27,6 +27,7 @@ pub(crate) mod configuration;
 mod connection;
 mod connection_effects;
 mod context;
+mod diagnostics;
 mod dispatch;
 mod execution_boundary;
 mod handshake;
@@ -42,6 +43,7 @@ mod outbound;
 mod projects;
 mod registration;
 mod resources;
+mod retirement;
 mod sessions;
 mod skills;
 mod subscriptions;
@@ -117,6 +119,8 @@ pub struct Host {
     connections: AtomicUsize,
     draining: CancellationToken,
     requests: TaskTracker,
+    commands: TaskTracker,
+    diagnostic_log: Mutex<diagnostics::Log>,
 }
 
 impl Host {
@@ -228,6 +232,8 @@ impl Host {
             connections: AtomicUsize::new(0),
             draining,
             requests: TaskTracker::new(),
+            commands: TaskTracker::new(),
+            diagnostic_log: Mutex::default(),
         });
         let recovery = async {
             workhub::recover(&host).await?;
@@ -243,6 +249,7 @@ impl Host {
             return Err(error.message.into());
         }
         startup_guard.disarm();
+        host.record_diagnostic(format_args!("Host ready: epoch {}", host.epoch));
         Ok(host)
     }
 
diff --git a/crates/runtime-host/src/server/bootstrap.rs b/crates/runtime-host/src/server/bootstrap.rs
index 49a33be127..cb19d7a11f 100644
--- a/crates/runtime-host/src/server/bootstrap.rs
+++ b/crates/runtime-host/src/server/bootstrap.rs
@@ -18,52 +18,52 @@
  */
 
 use maka_protocol::OperationErrorCode;
-use maka_protocol::codec::{count, exact, record, string};
-use maka_protocol::{COMPOSITION_ID, Operation, OperationRegistry, ProtocolError, Result};
+use maka_protocol::codec::{exact, record};
+use maka_protocol::{Operation, OperationRegistry, ProtocolError, Result, host};
 use serde_json::{Value, json};
 
 pub(super) struct Operations;
 
 impl OperationRegistry for Operations {
     fn decode_input(&self, operation: Operation, value: &Value) -> Result<Value> {
-        if operation != Operation::HostStatus {
+        if operation == Operation::HostUpgradePrepare {
+            maka_protocol::host::decode_retirement_input(value)?;
+            return Ok(value.clone());
+        }
+        if !matches!(
+            operation,
+            Operation::HostStatus | Operation::HostDiagnosticsQuery
+        ) {
             return Err(ProtocolError::invalid("Unknown operation"));
         }
         exact(record(value, "host.status input")?, &[])?;
         Ok(json!({}))
     }
     fn decode_output(&self, operation: Operation, value: &Value) -> Result<Value> {
-        if operation != Operation::HostStatus {
-            return Err(ProtocolError::invalid("Unknown operation"));
-        }
-        exact(
-            record(value, "host.status output")?,
-            &[
-                "hostEpoch",
-                "compositionId",
-                "compositionRevision",
-                "state",
-                "connections",
-                "activeOperations",
-                "activeResidencies",
-            ],
-        )?;
-        for key in ["hostEpoch", "compositionId", "compositionRevision"] {
-            string(&value[key], key, 128)?;
-        }
-        for key in ["connections", "activeOperations", "activeResidencies"] {
-            count(&value[key], key)?;
+        if operation == Operation::HostUpgradePrepare {
+            maka_protocol::host::decode_retirement_result(value)?;
+            return Ok(value.clone());
         }
-        if !matches!(
-            value["state"].as_str(),
-            Some("starting" | "containing" | "recovering" | "ready" | "draining")
-        ) {
-            return Err(ProtocolError::invalid("Invalid host lifecycle"));
+        match operation {
+            Operation::HostStatus => host::decode_status(value)?,
+            Operation::HostDiagnosticsQuery => host::decode_diagnostics(value)?,
+            _ => return Err(ProtocolError::invalid("Unknown operation")),
         }
         Ok(value.clone())
     }
     fn error_codes(&self, operation: Operation) -> Option<&[OperationErrorCode]> {
-        (operation == Operation::HostStatus).then_some(
+        if operation == Operation::HostUpgradePrepare {
+            return Some(&[
+                OperationErrorCode::OperationConflict,
+                OperationErrorCode::OperationUnavailable,
+                OperationErrorCode::InternalFailure,
+            ]);
+        }
+        matches!(
+            operation,
+            Operation::HostStatus | Operation::HostDiagnosticsQuery
+        )
+        .then_some(
             &[
                 OperationErrorCode::HostDraining,
                 OperationErrorCode::InternalFailure,
@@ -71,15 +71,3 @@ impl OperationRegistry for Operations {
         )
     }
 }
-
-pub(super) fn status(
-    epoch: &str,
-    connections: usize,
-    active: usize,
-    state: maka_protocol::handshake::Lifecycle,
-) -> Value {
-    json!({
-        "hostEpoch": epoch, "compositionId": COMPOSITION_ID, "compositionRevision": "3",
-        "state": state, "connections": connections, "activeOperations": active, "activeResidencies": active,
-    })
-}
diff --git a/crates/runtime-host/src/server/connection.rs b/crates/runtime-host/src/server/connection.rs
index b8a8a140cf..050f1c3388 100644
--- a/crates/runtime-host/src/server/connection.rs
+++ b/crates/runtime-host/src/server/connection.rs
@@ -36,10 +36,14 @@ use tokio::time::timeout;
 use tokio_util::sync::CancellationToken;
 use uuid::Uuid;
 
-type PendingReply = (
-    Response,
-    Option<tokio_util::task::task_tracker::TaskTrackerToken>,
-);
+type PendingReply = (Response, Option<RequestResidency>);
+
+/// The full request stays resident through response flush. Only mutations
+/// block cooperative maintenance; a concurrent status query is not work.
+pub(super) struct RequestResidency {
+    _request: tokio_util::task::task_tracker::TaskTrackerToken,
+    _command: Option<tokio_util::task::task_tracker::TaskTrackerToken>,
+}
 
 enum CompletedRequest {
     Reply(PendingReply),
@@ -266,9 +270,16 @@ impl Host {
             }
             // Register before checking admission, with no await between them:
             // drain cannot observe zero while an admitted request is untracked.
-            let mut residency = Some(self.requests.token());
+            let mut residency = Some(RequestResidency {
+                _request: self.requests.token(),
+                _command: (request.operation.mode() != maka_protocol::operation::OperationMode::Query)
+                    .then(|| self.commands.token()),
+            });
             in_flight.insert(request.request_id.clone(), request.operation);
-            let draining = self.draining.is_cancelled();
+            let draining = {
+                let retiring = self.handshake_gate.lock().unwrap_or_else(|e| e.into_inner());
+                *retiring || self.draining.is_cancelled()
+            };
             if draining {
                 residency.take();
             }
@@ -277,7 +288,7 @@ impl Host {
                     code: OperationErrorCode::Unauthorized,
                     message: "Runtime Host operation is not authorized".into(),
                 })
-            } else if draining && request.operation != Operation::HostStatus {
+            } else if draining && !matches!(request.operation, Operation::HostStatus | Operation::HostDiagnosticsQuery) {
                 Outcome::failure(OperationError {
                     code: OperationErrorCode::HostDraining,
                     message: "Host is draining".into(),
diff --git a/crates/runtime-host/src/server/diagnostics.rs b/crates/runtime-host/src/server/diagnostics.rs
new file mode 100644
index 0000000000..7f203b31ba
--- /dev/null
+++ b/crates/runtime-host/src/server/diagnostics.rs
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::Host;
+use maka_protocol::host::{Diagnostics, Platform, Residency, Status};
+use std::{collections::VecDeque, sync::atomic::Ordering};
+
+pub(super) struct Activity {
+    connections: usize,
+    commands: usize,
+    executions: usize,
+    shells: usize,
+    oauth: usize,
+}
+
+impl Activity {
+    pub(super) fn blocks_retirement(&self, own_commands: usize) -> bool {
+        self.connections > 1 || self.commands > own_commands || self.resident_count() != 0
+    }
+
+    pub(super) fn resident_count(&self) -> usize {
+        self.executions + self.shells + self.oauth
+    }
+
+    pub(super) fn residencies(&self) -> Vec<Residency<'static>> {
+        [
+            ("execution", self.executions),
+            ("shell", self.shells),
+            ("oauth", self.oauth),
+        ]
+        .into_iter()
+        .filter(|(_, count)| *count != 0)
+        .map(|(label, count)| Residency { label, count })
+        .collect()
+    }
+}
+
+impl Host {
+    pub(super) fn activity(&self) -> Activity {
+        Activity {
+            connections: self.accepted_connections.load(Ordering::SeqCst),
+            commands: self.commands.len(),
+            executions: self.executions.active_count(),
+            shells: self.shells.active_count(),
+            oauth: self.oauth.active_count(),
+        }
+    }
+
+    pub(super) fn status(&self, activity: &Activity) -> Status<'_> {
+        Status {
+            host_epoch: &self.epoch,
+            composition_id: maka_protocol::COMPOSITION_ID,
+            composition_revision: "3",
+            state: self.lifecycle(),
+            connections: activity.connections,
+            active_operations: activity.commands,
+            active_residencies: activity.resident_count(),
+        }
+    }
+
+    pub(super) fn diagnostics(&self) -> std::io::Result<Diagnostics<'_>> {
+        let activity = self.activity();
+        Ok(Diagnostics {
+            status: self.status(&activity),
+            // Identifies installed native modules, not future plugin capabilities.
+            composition_modules: &[
+                "sessions",
+                "execution",
+                "tools",
+                "shell",
+                "client-capability",
+                "skills",
+                "workhub",
+                "configuration",
+                "oauth",
+            ],
+            residencies: activity.residencies(),
+            upgrade_blocking_activity: activity.blocks_retirement(0),
+            protocol_version: 0,
+            compatibility_epoch: maka_protocol::COMPATIBILITY_EPOCH,
+            pid: std::process::id(),
+            process_uptime_seconds: self.started.elapsed().as_secs(),
+            node_version: "not applicable (Rust)",
+            #[cfg(target_os = "linux")]
+            platform: Platform::Linux,
+            #[cfg(target_os = "macos")]
+            platform: Platform::MacOs,
+            #[cfg(windows)]
+            platform: Platform::Windows,
+            arch: match std::env::consts::ARCH {
+                "x86_64" => "x64",
+                "aarch64" => "arm64",
+                arch => arch,
+            },
+            os_release: maka_process::system::os_release()?,
+            logs: self
+                .diagnostic_log
+                .lock()
+                .unwrap_or_else(|e| e.into_inner())
+                .entries
+                .iter()
+                .map(|(entry, _)| entry.clone())
+                .collect(),
+        })
+    }
+
+    pub(super) fn record_diagnostic(&self, message: impl std::fmt::Display) {
+        let entry = format!("+{:.3}s {message}", self.started.elapsed().as_secs_f64());
+        eprintln!("{entry}");
+        self.diagnostic_log
+            .lock()
+            .unwrap_or_else(|e| e.into_inner())
+            .push(entry);
+    }
+}
+
+/// Bounded recent Host lifecycle/transport diagnostics, not the execution log.
+#[derive(Default)]
+pub(super) struct Log {
+    entries: VecDeque<(String, usize)>,
+    encoded_bytes: usize,
+}
+
+impl Log {
+    fn push(&mut self, mut entry: String) {
+        const BUDGET: usize = 48 * 1024;
+        // Worst-case JSON escaping takes six bytes per input byte. Reserving
+        // 8 KiB per entry also leaves room for status/residency metadata.
+        let limit = entry.floor_char_boundary(8 * 1024 / 6);
+        entry.truncate(limit);
+        let bytes = serde_json::to_vec(&entry).expect("string serializes").len() + 1;
+        while self.entries.len() >= 256 || self.encoded_bytes + bytes > BUDGET {
+            let (_, removed) = self.entries.pop_front().expect("nonempty bounded log");
+            self.encoded_bytes -= removed;
+        }
+        self.encoded_bytes += bytes;
+        self.entries.push_back((entry, bytes));
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn diagnostic_log_bounds_encoded_escaped_unicode_and_keeps_recent_entries() {
+        let mut log = Log::default();
+        for _ in 0..300 {
+            log.push(format!("{}{}", "\u{0}".repeat(1300), "🦀".repeat(3000)));
+        }
+        log.push("last".into());
+        let entries: Vec<_> = log.entries.iter().map(|(entry, _)| entry).collect();
+        assert_eq!(entries.last().unwrap().as_str(), "last");
+        assert!(entries.len() <= 256);
+        assert!(entries.iter().all(|entry| entry.len() <= 10 * 1024));
+        assert!(serde_json::to_vec(&entries).unwrap().len() <= 48 * 1024 + 2);
+    }
+}
diff --git a/crates/runtime-host/src/server/dispatch.rs b/crates/runtime-host/src/server/dispatch.rs
index 31d587c4e9..aa6e7c885b 100644
--- a/crates/runtime-host/src/server/dispatch.rs
+++ b/crates/runtime-host/src/server/dispatch.rs
@@ -18,8 +18,7 @@
  */
 
 use super::{
-    Host, HostError, access, bootstrap, capabilities, configuration, context, operations, sessions,
-    turns,
+    Host, HostError, access, capabilities, configuration, context, operations, sessions, turns,
 };
 use maka_protocol::turn;
 use maka_protocol::{Operation, OperationRegistry, Outcome};
@@ -41,6 +40,15 @@ impl Host {
         authority: &super::authority::Authority,
         client_instance_id: &str,
     ) -> Result<Outcome, HostError> {
+        if operation == Operation::HostUpgradePrepare {
+            return match self
+                .prepare_retirement(maka_protocol::host::decode_retirement_input(&input)?)
+                .await
+            {
+                Ok(result) => Ok(Outcome::success(serde_json::to_value(result)?)),
+                Err(error) => Ok(Outcome::failure(error)),
+            };
+        }
         if maka_protocol::workhub::supports(operation) {
             return super::workhub::execute(self, operation, &input, connection_id).await;
         }
@@ -95,12 +103,12 @@ impl Host {
             };
         }
         if operation == Operation::HostStatus {
-            return Ok(Outcome::success(bootstrap::status(
-                &self.epoch,
-                self.connections.load(Ordering::SeqCst),
-                self.executions.active_count(),
-                self.lifecycle(),
-            )));
+            return Ok(Outcome::success(serde_json::to_value(
+                self.status(&self.activity()),
+            )?));
+        }
+        if operation == Operation::HostDiagnosticsQuery {
+            return Ok(Outcome::success(serde_json::to_value(self.diagnostics()?)?));
         }
         if turns::supports(operation) {
             let result = match operation {
diff --git a/crates/runtime-host/src/server/handshake.rs b/crates/runtime-host/src/server/handshake.rs
index 13d3171bb8..e574dbae8e 100644
--- a/crates/runtime-host/src/server/handshake.rs
+++ b/crates/runtime-host/src/server/handshake.rs
@@ -124,21 +124,15 @@ impl Host {
     }
 
     fn activity_snapshot(&self, version: Option<u8>) -> Value {
-        let executions = self.executions.active_count();
-        let shells = self.shells.active_count();
-        let residencies: Vec<_> = [("execution", executions), ("shell", shells)]
-            .into_iter()
-            .filter(|(_, count)| *count != 0)
-            .map(|(label, count)| json!({"label": label, "count": count}))
-            .collect();
+        let activity = self.activity();
         let mut result = json!({
             "connections": self.accepted_connections.load(Ordering::SeqCst),
-            "activeOperations": self.requests.len(),
+            "activeOperations": self.commands.len(),
             "processUptimeSeconds": self.started.elapsed().as_secs(),
-            "residencies": residencies,
+            "residencies": activity.residencies(),
         });
         if version == Some(2) {
-            result["drainResidencies"] = (executions + shells).into();
+            result["drainResidencies"] = activity.resident_count().into();
         }
         result
     }
diff --git a/crates/runtime-host/src/server/listeners.rs b/crates/runtime-host/src/server/listeners.rs
index cc82e4226a..952cb56c40 100644
--- a/crates/runtime-host/src/server/listeners.rs
+++ b/crates/runtime-host/src/server/listeners.rs
@@ -66,7 +66,7 @@ pub(super) async fn serve(
                 };
             },
             Some(result) = connections.join_next(), if !connections.is_empty() => {
-                if let Err(error) = result { eprintln!("host connection task failed: {error}"); }
+                if let Err(error) = result { host.record_diagnostic(format_args!("host connection task failed: {error}")); }
             },
             accepted = local.accept(), if connections.len() < 128 => {
                 let socket = match accepted {
@@ -78,7 +78,7 @@ pub(super) async fn serve(
                 connections.spawn(async move {
                     let (reader, writer) = ndjson::split(socket, token.clone());
                     let _cancel_on_exit = token.clone().drop_guard();
-                    report(host.authorized_connection(
+                    report(&host, host.clone().authorized_connection(
                         reader,
                         writer,
                         super::authority::Authority::LocalOwner,
@@ -99,12 +99,13 @@ pub(super) async fn serve(
                 connections.spawn(async move {
                     let _permit = permit;
                     let _cancel_on_exit = token.clone().drop_guard();
-                    report(super::websocket::connection(socket, host, origins, token).await);
+                    report(&host, super::websocket::connection(socket, host.clone(), origins, token).await);
                 });
             }
         }
     };
     host.draining.cancel();
+    host.record_diagnostic("Host admission closed; awaiting accepted response flush");
     host.requests.close();
     host.requests.wait().await;
     // Registry drain closes provider transports. Admitted ordinary responses
@@ -117,14 +118,15 @@ pub(super) async fn serve(
     host.capabilities.shutdown().await;
     let (log_closed, configuration_closed) =
         tokio::join!(host.log.shutdown(), host.configuration.shutdown());
+    host.record_diagnostic("Host execution, transport and storage drain finished");
     result
         .and(log_closed.map_err(Into::into))
         .and(configuration_closed.map_err(Into::into))
 }
 
-fn report(result: Result<(), HostError>) {
+fn report(host: &Host, result: Result<(), HostError>) {
     if let Err(error) = result {
         // Individual malformed/closed connections never terminate the Host.
-        eprintln!("host connection closed: {error}");
+        host.record_diagnostic(format_args!("host connection closed: {error}"));
     }
 }
diff --git a/crates/runtime-host/src/server/oauth.rs b/crates/runtime-host/src/server/oauth.rs
index 699424993d..417ae0ab82 100644
--- a/crates/runtime-host/src/server/oauth.rs
+++ b/crates/runtime-host/src/server/oauth.rs
@@ -38,6 +38,18 @@ pub(super) struct Coordinator {
     admission: tokio::sync::Mutex<()>,
     state: Mutex<State>,
 }
+
+impl Coordinator {
+    pub(super) fn active_count(&self) -> usize {
+        usize::from(
+            self.state
+                .lock()
+                .unwrap_or_else(|e| e.into_inner())
+                .active
+                .is_some(),
+        )
+    }
+}
 #[derive(Default)]
 struct State {
     active: Option<Arc<Attempt>>,
diff --git a/crates/runtime-host/src/server/outbound.rs b/crates/runtime-host/src/server/outbound.rs
index d9230076f4..f9b96c5166 100644
--- a/crates/runtime-host/src/server/outbound.rs
+++ b/crates/runtime-host/src/server/outbound.rs
@@ -18,6 +18,7 @@
  */
 
 use super::HostError;
+use super::connection::RequestResidency;
 use maka_protocol::Response;
 mod pty;
 mod scheduling;
@@ -30,7 +31,7 @@ use std::{
     time::Duration,
 };
 use tokio::sync::Notify;
-use tokio_util::{sync::CancellationToken, task::task_tracker::TaskTrackerToken};
+use tokio_util::sync::CancellationToken;
 
 const MAX_FRAMES: usize = 64;
 const MAX_BYTES: usize = 2 * 1024 * 1024;
@@ -41,7 +42,7 @@ struct Frame {
     bytes: usize,
     fatal: bool,
     // Kept until this exact frame flushes or the transport is abandoned.
-    _residency: Option<TaskTrackerToken>,
+    _residency: Option<RequestResidency>,
 }
 #[derive(Default)]
 struct State {
@@ -85,7 +86,7 @@ impl Outbound {
     pub async fn reply(
         &self,
         response: Response,
-        residency: Option<TaskTrackerToken>,
+        residency: Option<RequestResidency>,
         fatal: bool,
     ) -> Result<(), HostError> {
         self.push(serde_json::to_value(response)?, residency, fatal)
@@ -94,7 +95,7 @@ impl Outbound {
     async fn push(
         &self,
         value: Value,
-        residency: Option<TaskTrackerToken>,
+        residency: Option<RequestResidency>,
         fatal: bool,
     ) -> Result<(), HostError> {
         let bytes = serde_json::to_vec(&value)?.len() + 1;
diff --git a/crates/runtime-host/src/server/retirement.rs b/crates/runtime-host/src/server/retirement.rs
new file mode 100644
index 0000000000..03e65927e5
--- /dev/null
+++ b/crates/runtime-host/src/server/retirement.rs
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::Host;
+use maka_protocol::{
+    OperationError, OperationErrorCode,
+    host::{RetirementInput, RetirementResult},
+};
+
+impl Host {
+    pub(super) async fn prepare_retirement(
+        &self,
+        input: RetirementInput,
+    ) -> Result<RetirementResult, OperationError> {
+        let _admission = self.executions.lock_admission().await;
+        let mut retiring = self
+            .handshake_gate
+            .lock()
+            .unwrap_or_else(|e| e.into_inner());
+        if input.expected_host_epoch != self.epoch || *retiring || self.draining.is_cancelled() {
+            return Err(OperationError {
+                code: OperationErrorCode::OperationConflict,
+                message: "Host lifetime changed or retirement already began".into(),
+            });
+        }
+        if !input.allow_interrupt_active_tasks && self.activity().blocks_retirement(1) {
+            // Until durable step handoff is installed, this Host does not advertise
+            // it and never interprets permission to cooperate as permission to kill.
+            return Ok(RetirementResult::ActiveTasks);
+        }
+        *retiring = true;
+        self.draining.cancel();
+        drop(retiring);
+        drop(_admission);
+        self.record_diagnostic("Host retirement committed; draining owned work");
+        // The accepted request token protects this reply until flushed/abandoned.
+        // Root release still waits for execution, native I/O and storage cleanup.
+        Ok(RetirementResult::Prepared {
+            pid: std::num::NonZeroU32::new(std::process::id()).expect("process PID"),
+        })
+    }
+}
diff --git a/crates/runtime-host/tests/integration/main.rs b/crates/runtime-host/tests/integration/main.rs
index 462c124760..96ce6c3248 100644
--- a/crates/runtime-host/tests/integration/main.rs
+++ b/crates/runtime-host/tests/integration/main.rs
@@ -64,6 +64,7 @@ mod read_pages;
 mod relay_options;
 mod remote_capacity;
 mod resume;
+mod retirement;
 mod runtime_policy;
 mod system_prompt;
 mod transcript_limits;
diff --git a/crates/runtime-host/tests/integration/oauth.rs b/crates/runtime-host/tests/integration/oauth.rs
index 9af1b5bd99..367aa68347 100644
--- a/crates/runtime-host/tests/integration/oauth.rs
+++ b/crates/runtime-host/tests/integration/oauth.rs
@@ -34,7 +34,7 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
 async fn initiating_client_admission_replay_disconnect_cancel_and_drain_own_authorization() {
     let proxy = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
     let fixture = Fixture::new(Some(proxy.local_addr().unwrap().port())).await;
-    let (host, drain, server) = fixture.serve().await;
+    let (host, _drain, server) = fixture.serve().await;
     let mut first = Peer::new(host.clone(), "oauth-first").await;
     let mut second = Peer::new(host.clone(), "oauth-second").await;
     first.publish("first-publication").await;
@@ -78,6 +78,26 @@ async fn initiating_client_admission_replay_disconnect_cancel_and_drain_own_auth
         queried["result"], started["result"],
         "disconnect must not abandon authorization"
     );
+    let diagnostics = second.rpc("host.diagnostics.query", json!({})).await;
+    assert_eq!(diagnostics["result"]["connections"], 1);
+    assert_eq!(diagnostics["result"]["upgradeBlockingActivity"], true);
+    assert_eq!(
+        diagnostics["result"]["residencies"],
+        json!([{"label":"oauth", "count":1}])
+    );
+    let epoch = diagnostics["result"]["hostEpoch"].clone();
+    for cooperate in [false, true] {
+        let refused = second
+            .rpc(
+                "host.upgrade.prepare",
+                json!({
+                    "expectedHostEpoch":epoch, "allowInterruptActiveTasks":false,
+                    "allowCooperativeHandoff":cooperate,
+                }),
+            )
+            .await;
+        assert_eq!(refused["result"], json!({"kind":"active_tasks"}));
+    }
     let cancelled = second
         .rpc("oauth.login.cancel", json!({"attemptId":"active"}))
         .await;
@@ -118,7 +138,18 @@ async fn initiating_client_admission_replay_disconnect_cancel_and_drain_own_auth
         .await;
     assert_eq!(settled["result"]["phase"], "cancelled");
     let mut socket = pending_connect(&proxy).await;
-    drain.cancel();
+    let retired = second
+        .rpc(
+            "host.upgrade.prepare",
+            json!({
+                "expectedHostEpoch":epoch, "allowInterruptActiveTasks":true,
+            }),
+        )
+        .await;
+    assert_eq!(
+        retired["result"],
+        json!({"kind":"prepared", "pid":std::process::id()})
+    );
     assert_eq!(
         tokio::time::timeout(Duration::from_secs(5), socket.read_u8())
             .await
diff --git a/crates/runtime-host/tests/integration/retirement.rs b/crates/runtime-host/tests/integration/retirement.rs
new file mode 100644
index 0000000000..2c4562a5aa
--- /dev/null
+++ b/crates/runtime-host/tests/integration/retirement.rs
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use maka_runtime_host::server::{Host, local::LocalListener};
+use maka_transport::{MessageReader, MessageWriter, TransportError};
+use serde_json::{Value, json};
+use std::time::Duration;
+use tokio::sync::mpsc;
+use tokio_util::sync::CancellationToken;
+
+struct Reader(mpsc::UnboundedReceiver<Value>);
+impl MessageReader for Reader {
+    async fn read(&mut self) -> Result<Option<Value>, TransportError> {
+        Ok(self.0.recv().await)
+    }
+}
+
+struct Writer {
+    frames: mpsc::UnboundedSender<Value>,
+    gate: Option<(CancellationToken, CancellationToken)>,
+    fail: bool,
+}
+impl MessageWriter for Writer {
+    async fn write(&mut self, value: &Value) -> Result<(), TransportError> {
+        if value["requestId"] == "retire"
+            && let Some((entered, release)) = &self.gate
+        {
+            entered.cancel();
+            release.cancelled().await;
+            if self.fail {
+                return Err(TransportError::Closed);
+            }
+        }
+        self.frames
+            .send(value.clone())
+            .map_err(|_| TransportError::Closed)
+    }
+    async fn close_after_flush(&mut self) -> Result<(), TransportError> {
+        Ok(())
+    }
+}
+
+fn hello() -> Value {
+    json!({"kind":"hello", "clientInstanceId":"retirement", "surface":"desktop",
+        "protocolMin":0, "protocolMax":0, "compositionId":"maka.interactive",
+        "compatibilityEpoch":maka_protocol::COMPATIBILITY_EPOCH})
+}
+
+async fn receive(frames: &mut mpsc::UnboundedReceiver<Value>) -> Value {
+    tokio::time::timeout(Duration::from_secs(5), frames.recv())
+        .await
+        .unwrap()
+        .unwrap()
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_abandoned() {
+    for fail in [false, true] {
+        #[cfg(unix)]
+        let directory = {
+            use std::os::unix::fs::PermissionsExt;
+            tempfile::Builder::new()
+                .permissions(std::fs::Permissions::from_mode(0o700))
+                .tempdir_in("/tmp")
+                .unwrap()
+        };
+        #[cfg(windows)]
+        let directory = tempfile::tempdir().unwrap();
+        let namespaces = RootNamespaces {
+            ownership: directory.path().join("owners"),
+            control: directory.path().join("control"),
+        };
+        let root = directory.path().join("root");
+        let owner = RootOwner::create(&root, &namespaces).unwrap();
+        let host = Host::open(owner).await.unwrap();
+        #[cfg(unix)]
+        let endpoint = directory.path().join("h.sock");
+        #[cfg(windows)]
+        let endpoint =
+            std::path::PathBuf::from(format!(r"\\.\pipe\maka-test-{}", uuid::Uuid::new_v4()));
+        let server = tokio::spawn(
+            LocalListener::bind(&endpoint)
+                .unwrap()
+                .serve(host.clone(), CancellationToken::new()),
+        );
+        let entered = CancellationToken::new();
+        let release = CancellationToken::new();
+        let (requests, reader) = mpsc::unbounded_channel();
+        let (frames, mut responses) = mpsc::unbounded_channel();
+        let connection = tokio::spawn(host.clone().local_owner_connection(
+            Reader(reader),
+            Writer {
+                frames,
+                gate: Some((entered.clone(), release.clone())),
+                fail,
+            },
+        ));
+        requests.send(hello()).unwrap();
+        let handshake = receive(&mut responses).await;
+        assert_eq!(handshake["state"], "ready");
+        requests.send(json!({"requestId":"retire", "operation":"host.upgrade.prepare",
+            "input":{"expectedHostEpoch":handshake["hostEpoch"], "allowInterruptActiveTasks":false}}))
+            .unwrap();
+        tokio::time::timeout(Duration::from_secs(5), entered.cancelled())
+            .await
+            .unwrap();
+        assert!(!server.is_finished(), "retirement receipt is still owned");
+        assert!(responses.try_recv().is_err(), "receipt has not flushed");
+        assert!(RootOwner::open(&root, &namespaces).is_err());
+
+        let (other_requests, reader) = mpsc::unbounded_channel();
+        let (frames, mut responses_after_fence) = mpsc::unbounded_channel();
+        let other = tokio::spawn(host.clone().local_owner_connection(
+            Reader(reader),
+            Writer {
+                frames,
+                gate: None,
+                fail: false,
+            },
+        ));
+        other_requests.send(hello()).unwrap();
+        assert_eq!(
+            receive(&mut responses_after_fence).await["kind"],
+            "draining"
+        );
+        other.await.unwrap().unwrap();
+        drop(other_requests);
+
+        release.cancel();
+        if !fail {
+            let receipt = receive(&mut responses).await;
+            assert_eq!(
+                receipt["result"],
+                json!({"kind":"prepared", "pid":std::process::id()})
+            );
+        }
+        drop(requests);
+        assert_eq!(connection.await.unwrap().is_err(), fail);
+        drop(host);
+        tokio::time::timeout(Duration::from_secs(5), server)
+            .await
+            .unwrap()
+            .unwrap()
+            .unwrap();
+        drop(RootOwner::open(&root, &namespaces).unwrap());
+    }
+}
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 6c7d0e7480..1434c775cb 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -69,6 +69,10 @@ Do not run untrusted code or point test instances at existing user data.
   provider extensions remain open-ended.
 - A State Root has one writer and execution authority. Session, Turn, Run and
   invocation identities remain distinct.
+- Host diagnostics and retirement share active-work accounting, including pending
+  OAuth authorization. Retirement targets an exact Host epoch and retains authority
+  through response flush and resource cleanup. Cooperative step handoff is not yet
+  advertised. The legacy `nodeVersion` field reports `not applicable (Rust)`.
 - Manual resume checks a sealed source without claiming it, then atomically opens
   a new continuation. Replay follows the selected lineage, excluding later branches
   and unfinished response fragments. Unknown effects block admission; repeating
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 87c5825ad4..6cef9a741f 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -62,6 +62,9 @@ Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 每个 State Root 只有一个写入与执行 authority;Session、Turn、Run、invocation
   身份保持独立。
+- Host 诊断与退出共用活动工作统计,包含等待授权的 OAuth 登录。退出绑定精确 Host epoch,
+  回复发送与资源清理完成前不释放 authority。尚未公布协作步骤交接能力;旧协议字段
+  `nodeVersion` 如实返回 `not applicable (Rust)`。
 - 手动 resume 先只读检查已封口的源,再原子开启续跑。模型只回放选定谱系,不混入后来的旁支或
   未完成的响应片段。未知副作用阻止准入;重复已接受的请求返回原 Turn,重启后仍然如此。
 - 类型化模型列表贯通发现、存储和目录投影,连接自有声明单独保存;
diff --git a/tests/fixtures/client-candidate-entry.mjs b/tests/fixtures/client-candidate-entry.mjs
index a17202c3c7..73028f15e6 100644
--- a/tests/fixtures/client-candidate-entry.mjs
+++ b/tests/fixtures/client-candidate-entry.mjs
@@ -19,6 +19,7 @@
 
 import assert from 'node:assert/strict';
 import { parseArgs } from 'node:util';
+import { release as osRelease } from 'node:os';
 import { createNativeRuntimeHostCandidateLaunchBarrier } from '../../apps/desktop/src/main/native-runtime-host.js';
 import { connectExistingRuntimeHost } from '../../packages/runtime-host/src/client/connection.js';
 
@@ -50,6 +51,47 @@ try {
   assert.equal(result.registration.pid, result.spawnedProcess.pid);
   assert.equal(result.registration.hostEpoch, result.connection.hostEpoch);
   assert.equal((await result.connection.status(2_000)).state, 'ready');
+  const diagnostics = await result.connection.request('host.diagnostics.query', {});
+  assert.equal(diagnostics.hostEpoch, result.registration.hostEpoch);
+  assert.equal(diagnostics.pid, result.spawnedProcess.pid);
+  assert.equal(diagnostics.platform, process.platform);
+  assert.equal(diagnostics.arch, process.arch);
+  assert.equal(diagnostics.osRelease, osRelease());
+  assert.equal(diagnostics.nodeVersion, 'not applicable (Rust)');
+  assert.equal(diagnostics.activeOperations, 0, 'queries are not active commands');
+  assert.equal(diagnostics.activeResidencies, 0);
+  assert.equal(diagnostics.upgradeBlockingActivity, false);
+  assert(diagnostics.logs.some((entry) => entry.includes('Host ready')));
+  await assert.rejects(
+    result.connection.request('host.upgrade.prepare', {
+      expectedHostEpoch: 'stale-epoch',
+      allowInterruptActiveTasks: true,
+    }),
+    (error) => error.code === 'operation_conflict',
+  );
+  const observer = await connectExistingRuntimeHost({
+    rootPath: values.root,
+    protocol: { min: 0, max: 0 },
+    compositionId: 'maka.interactive',
+    generation: 'native-cli-test',
+  });
+  assert.equal(observer.kind, 'connected');
+  try {
+    const busyDiagnostics = await result.connection.request('host.diagnostics.query', {});
+    assert.equal(busyDiagnostics.connections, 2);
+    assert.equal(busyDiagnostics.upgradeBlockingActivity, true);
+    assert.deepEqual(
+      await result.connection.request('host.upgrade.prepare', {
+        expectedHostEpoch: result.registration.hostEpoch,
+        allowInterruptActiveTasks: false,
+        allowCooperativeHandoff: true,
+      }),
+      { kind: 'active_tasks' },
+      'permission to cooperate is not permission to interrupt another client',
+    );
+  } finally {
+    await observer.connection.close();
+  }
   assert.deepEqual(await result.connection.request('network-proxy.test', {}), {
     ok: false,
     latencyMs: 0,
@@ -90,9 +132,15 @@ try {
   assert.equal(result.kind, 'connected', JSON.stringify(result));
   assert.notEqual(result.spawnedProcess.pid, previous.pid);
   assert.equal((await result.connection.status(2_000)).state, 'ready');
-  await result.connection.close();
   barrier.pause();
-  await barrier.retireExcept(-1);
+  assert.deepEqual(
+    await result.connection.request('host.upgrade.prepare', {
+      expectedHostEpoch: result.registration.hostEpoch,
+      allowInterruptActiveTasks: false,
+    }),
+    { kind: 'prepared', pid: result.spawnedProcess.pid },
+    'retirement must flush its receipt before the process exits',
+  );
   const exit = await result.spawnedProcess.exited;
   assert.equal(exit.code, 0, exit.stderr);
   assert.equal(exit.signal, null);


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 13/29: fix(storage): retain and revalidate named writer leases

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit c6c0852c67761643fbeb34f6cdf98010a4d063fe
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 16:53:56 2026 +0800

    fix(storage): retain and revalidate named writer leases
    
    Generated-by: OpenAI Codex
---
 crates/event-log/src/connection.rs           | 22 ++++++++++++----------
 crates/event-log/src/connection/authority.rs | 14 ++++++++++----
 crates/event-log/src/lib.rs                  | 19 +++++++------------
 crates/event-log/src/root.rs                 |  1 +
 crates/event-log/src/root_lock.rs            | 26 +++++++++++++++++++++++++-
 5 files changed, 55 insertions(+), 27 deletions(-)

diff --git a/crates/event-log/src/connection.rs b/crates/event-log/src/connection.rs
index c84796e751..c65de9eff4 100644
--- a/crates/event-log/src/connection.rs
+++ b/crates/event-log/src/connection.rs
@@ -177,14 +177,7 @@ mod tests {
         let directory = tempfile::tempdir().unwrap();
         let path = directory.path().join("owner.sqlite");
         let lock = directory.path().join("writer.lock");
-        let lease = OpenOptions::new()
-            .read(true)
-            .write(true)
-            .create(true)
-            .truncate(false)
-            .open(&lock)
-            .unwrap();
-        lease.try_lock().unwrap();
+        let lease = Arc::new(crate::root::FileLease::acquire(&lock).unwrap());
         let contender = OpenOptions::new()
             .read(true)
             .write(true)
@@ -273,8 +266,7 @@ mod tests {
     async fn shutdown_is_idempotent_while_handles_remain_alive() {
         let directory = tempfile::tempdir().unwrap();
         let path = directory.path().join("writer.lock");
-        let lease = File::create(&path).unwrap();
-        lease.try_lock().unwrap();
+        let lease = Arc::new(crate::root::FileLease::acquire(&path).unwrap());
         let contender = File::open(&path).unwrap();
         let owner = Arc::new(
             OwnedConnection::open(
@@ -286,6 +278,16 @@ mod tests {
             .unwrap(),
         );
         let retained = owner.clone();
+        // Replacing the pathname must prevent the old worker from accepting new work.
+        std::fs::rename(&path, directory.path().join("displaced.lock")).unwrap();
+        let replacement = crate::root::FileLease::acquire(&path).unwrap();
+        assert!(matches!(
+            retained
+                .run::<()>(|_| Box::pin(async { panic!("replaced lease must reject work") }))
+                .await,
+            Err(StoreError::Io(_))
+        ));
+        drop(replacement);
         let (first, second) = tokio::join!(owner.shutdown(), retained.shutdown());
         first.unwrap();
         second.unwrap();
diff --git a/crates/event-log/src/connection/authority.rs b/crates/event-log/src/connection/authority.rs
index 8808c2cb12..c2742786c3 100644
--- a/crates/event-log/src/connection/authority.rs
+++ b/crates/event-log/src/connection/authority.rs
@@ -17,14 +17,17 @@
  * under the License.
  */
 
-use std::{fs::File, sync::Arc};
+use std::sync::Arc;
 
-use crate::{StoreError, root::RootOwner};
+use crate::{
+    StoreError,
+    root::{FileLease, RootOwner},
+};
 
 /// Authority retained until the database worker has actually closed.
 pub enum ConnectionAuthority {
     Writer {
-        lease: File,
+        lease: Arc<FileLease>,
         root: Option<Arc<RootOwner>>,
     },
     Root(Arc<RootOwner>),
@@ -33,7 +36,10 @@ pub enum ConnectionAuthority {
 impl ConnectionAuthority {
     pub(super) fn validate(&self) -> Result<(), StoreError> {
         let root = match self {
-            Self::Writer { root, .. } => root.as_ref(),
+            Self::Writer { root, lease } => {
+                lease.validate()?;
+                root.as_ref()
+            }
             Self::Root(root) => Some(root),
         };
         if let Some(root) = root {
diff --git a/crates/event-log/src/lib.rs b/crates/event-log/src/lib.rs
index d5108de4e2..8578d422f3 100644
--- a/crates/event-log/src/lib.rs
+++ b/crates/event-log/src/lib.rs
@@ -53,7 +53,6 @@ pub mod transcript;
 pub mod turns;
 pub mod workhub;
 
-use std::fs::OpenOptions;
 use std::path::Path;
 use std::sync::Arc;
 
@@ -189,17 +188,13 @@ impl EventLog {
         // byte-range locks on the database. Never unlink it while in use.
         let mut lock_path = path.as_os_str().to_os_string();
         lock_path.push(".writer.lock");
-        let lease = OpenOptions::new()
-            .read(true)
-            .write(true)
-            .create(true)
-            .truncate(false)
-            .open(lock_path)?;
-        match lease.try_lock() {
-            Ok(()) => {}
-            Err(std::fs::TryLockError::WouldBlock) => return Err(StoreError::WriterBusy),
-            Err(std::fs::TryLockError::Error(error)) => return Err(StoreError::Io(error)),
-        }
+        let lease = match root::FileLease::acquire(Path::new(&lock_path)) {
+            Ok(lease) => Arc::new(lease),
+            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+                return Err(StoreError::WriterBusy);
+            }
+            Err(error) => return Err(StoreError::Io(error)),
+        };
         let connection = OwnedConnection::open(
             SqliteConnectOptions::new()
                 .filename(path)
diff --git a/crates/event-log/src/root.rs b/crates/event-log/src/root.rs
index ccd3e2028f..29d23ae572 100644
--- a/crates/event-log/src/root.rs
+++ b/crates/event-log/src/root.rs
@@ -21,6 +21,7 @@
 
 #[path = "root_lock.rs"]
 mod lock;
+pub use lock::{FileLease, private_directory};
 
 #[cfg(windows)]
 pub mod windows;
diff --git a/crates/event-log/src/root_lock.rs b/crates/event-log/src/root_lock.rs
index f316dfa732..d0f683d480 100644
--- a/crates/event-log/src/root_lock.rs
+++ b/crates/event-log/src/root_lock.rs
@@ -27,6 +27,30 @@ use std::{
     path::{Path, PathBuf},
 };
 
+/// A stable, exclusively locked file. Never unlink it as part of cleanup.
+/// Uses native whole-file locks; State Root's TS-compatible leases are separate.
+pub struct FileLease {
+    file: File,
+    path: PathBuf,
+}
+
+impl FileLease {
+    pub fn acquire(path: &Path) -> io::Result<Self> {
+        let file = open_regular(path, true)?;
+        file.try_lock().map_err(io::Error::from)?;
+        let lease = Self {
+            file,
+            path: path.to_owned(),
+        };
+        lease.validate()?;
+        Ok(lease)
+    }
+
+    pub fn validate(&self) -> io::Result<()> {
+        stable(&self.file, &self.path)
+    }
+}
+
 #[cfg(unix)]
 pub(super) fn identity(metadata: &Metadata) -> io::Result<(u64, u64)> {
     use std::os::unix::fs::MetadataExt;
@@ -108,7 +132,7 @@ pub(super) fn acquire(path: &Path) -> io::Result<File> {
     Ok(file)
 }
 
-pub(super) fn private_directory(path: &Path) -> io::Result<()> {
+pub fn private_directory(path: &Path) -> io::Result<()> {
     #[cfg(unix)]
     {
         use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 15/29: feat(host): activate native deployments through verified live owners

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 375772c87951be8d02c14b52d1e14180943b3f6f
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 17:29:26 2026 +0800

    feat(host): activate native deployments through verified live owners
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                 |   1 +
 crates/cli/Cargo.toml                      |   3 +-
 crates/cli/src/args.rs                     |   7 +-
 crates/cli/src/candidate.rs                |   7 +-
 crates/cli/src/deployment.rs               |   8 +-
 crates/cli/src/deployment/activation.rs    | 279 +++++++++++++++++++++++++++++
 crates/cli/src/host_client.rs              | 102 +++++++++--
 crates/cli/tests/integration/candidate.rs  |  15 ++
 crates/cli/tests/integration/deployment.rs |  65 ++++++-
 docs/rust-runtime.md                       |   6 +-
 docs/rust-runtime.zh-CN.md                 |   3 +-
 tests/fixtures/client-activation-entry.mjs |  51 ++++++
 tests/fixtures/client.mjs                  |   1 +
 13 files changed, 519 insertions(+), 29 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 43d049aabe..9f103ffdba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3937,6 +3937,7 @@ dependencies = [
 name = "maka-cli"
 version = "0.0.0"
 dependencies = [
+ "base64 0.22.1",
  "clap",
  "libc",
  "maka-event-log",
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index cffb7b1a30..83e7764f66 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -29,6 +29,7 @@ name = "maka"
 path = "src/main.rs"
 
 [dependencies]
+base64 = "0.22"
 clap.workspace = true
 maka-runtime-host.workspace = true
 maka-runtime.workspace = true
@@ -40,7 +41,7 @@ serde.workspace = true
 serde_json.workspace = true
 sha2.workspace = true
 sqlx.workspace = true
-tokio.workspace = true
+tokio = { workspace = true, features = ["process"] }
 tokio-util.workspace = true
 uuid.workspace = true
 tempfile.workspace = true
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 78e06ee030..34741f9705 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -51,6 +51,8 @@ enum HostCommand {
     Init(Root),
     /// Install this native executable as the root's managed Host.
     Install(crate::deployment::Install),
+    /// Activate an installed Host and report its verified loopback endpoint.
+    Activate(crate::deployment::Activate),
     /// Query the existing Host without opening its State Root for writing.
     Status(Root),
     /// Retire the exact current Host, preserving work at safe step boundaries.
@@ -99,6 +101,7 @@ impl Cli {
         match self.command {
             Command::Host(HostCommand::Candidate(args)) => args.run().await,
             Command::Host(HostCommand::Install(args)) => args.run().await,
+            Command::Host(HostCommand::Activate(args)) => args.run().await,
             Command::Host(HostCommand::Init(args)) => {
                 let root_id = initialize(&args.root, &RootNamespaces::for_current_account()?)?;
                 println!("{}", serde_json::json!({"rootId": root_id}));
@@ -108,7 +111,7 @@ impl Cli {
                 serve::run(&root.root, websocket).await
             }
             Command::Host(HostCommand::Status(root)) => {
-                let mut client = crate::host_client::HostClient::connect(&root.root).await?;
+                let mut client = crate::host_client::HostClient::connect(&root.root, None).await?;
                 let status = client.status().await?;
                 drop(client);
                 println!("{}", serde_json::to_string(&status)?);
@@ -119,7 +122,7 @@ impl Cli {
                 expected_host_epoch,
                 allow_interrupt_active_tasks,
             }) => {
-                let mut client = crate::host_client::HostClient::connect(&root.root).await?;
+                let mut client = crate::host_client::HostClient::connect(&root.root, None).await?;
                 let result = client
                     .retire(expected_host_epoch.as_deref(), allow_interrupt_active_tasks)
                     .await?;
diff --git a/crates/cli/src/candidate.rs b/crates/cli/src/candidate.rs
index 5ec8942685..269612de27 100644
--- a/crates/cli/src/candidate.rs
+++ b/crates/cli/src/candidate.rs
@@ -100,11 +100,16 @@ impl Candidate {
         )?;
         let idle_host = host.clone();
         let idle_cancel = cancel.clone();
+        let idle_grace = if deployment.is_some() {
+            30_000
+        } else {
+            self.idle_grace_ms
+        };
         let expiry = tokio::spawn(async move {
             idle_host
                 .wait_until_idle(
                     Duration::from_millis(self.initial_connection_timeout_ms),
-                    Duration::from_millis(self.idle_grace_ms),
+                    Duration::from_millis(idle_grace),
                 )
                 .await;
             idle_cancel.cancel();
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index 0108c3d9c2..53671b2a82 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -17,8 +17,10 @@
  * under the License.
  */
 
+mod activation;
 mod package;
 mod store;
+pub(super) use activation::Activate;
 
 use clap::{Args, ValueEnum};
 use maka_event_log::root::{self, FileLease, RootLocation, RootNamespaces, RootOwner};
@@ -77,7 +79,7 @@ impl Deployment {
                 .bytes()
                 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
             || self.executable != package::path(directory, &self.sha256)
-            || !self.websocket.ip().is_loopback()
+            || self.websocket.ip() != std::net::Ipv4Addr::LOCALHOST
         {
             return Err("deployment does not match its native root or package".into());
         }
@@ -98,8 +100,8 @@ pub(super) fn directory(root_id: &str) -> Result<PathBuf, HostError> {
 
 impl Install {
     pub async fn run(self) -> Result<(), HostError> {
-        if !self.websocket.ip().is_loopback() {
-            return Err("managed Host listener must be loopback".into());
+        if self.websocket.ip() != std::net::Ipv4Addr::LOCALHOST {
+            return Err("managed Host listener must use 127.0.0.1".into());
         }
         let root_path = self.root.root;
         let (root, directory, lease) = tokio::task::spawn_blocking(move || {
diff --git a/crates/cli/src/deployment/activation.rs b/crates/cli/src/deployment/activation.rs
new file mode 100644
index 0000000000..7feba81057
--- /dev/null
+++ b/crates/cli/src/deployment/activation.rs
@@ -0,0 +1,279 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Deployment, Mode, directory, store};
+use crate::host_client::{HostClient, LiveHost};
+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
+use clap::Args;
+use maka_event_log::root::{self, FileLease, RootNamespaces, RootOwner};
+use maka_runtime_host::server::HostError;
+use serde::Serialize;
+use std::{
+    io::Write,
+    num::{NonZeroU16, NonZeroU32},
+    process::Stdio,
+    str::FromStr,
+    time::Duration,
+};
+use tokio::{
+    io::AsyncWriteExt,
+    process::{Child, Command},
+};
+use uuid::Uuid;
+
+#[derive(Clone)]
+struct RootId(String);
+
+impl FromStr for RootId {
+    type Err = &'static str;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        if value.len() == 64
+            && value
+                .bytes()
+                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+        {
+            Ok(Self(value.into()))
+        } else {
+            Err("root ID must contain 64 lowercase hexadecimal characters")
+        }
+    }
+}
+
+#[derive(Args)]
+pub(crate) struct Activate {
+    #[arg(long)]
+    root_id: RootId,
+    #[arg(long)]
+    framed: bool,
+}
+
+#[derive(Serialize)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase"
+)]
+enum Frame {
+    Result {
+        schema_version: u8,
+        deployment_id: Uuid,
+        config_revision: u64,
+        root_id: String,
+        host_epoch: String,
+        pid: NonZeroU32,
+        protocol_version: u8,
+        endpoint: Endpoint,
+    },
+    Error {
+        schema_version: u8,
+        error: Failure,
+    },
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Endpoint {
+    host: &'static str,
+    port: NonZeroU16,
+    websocket_path: &'static str,
+}
+
+#[derive(Serialize)]
+struct Failure {
+    code: &'static str,
+    message: String,
+}
+
+impl Activate {
+    pub async fn run(self) -> Result<(), HostError> {
+        let result = self.activate().await;
+        if let Err(error) = &result
+            && self.framed
+        {
+            let mut message = error.to_string();
+            message.truncate(message.floor_char_boundary(2048));
+            emit(
+                &Frame::Error {
+                    schema_version: 1,
+                    error: Failure {
+                        code: "activation_failed",
+                        message,
+                    },
+                },
+                true,
+            )?;
+        }
+        result
+    }
+
+    async fn activate(&self) -> Result<(), HostError> {
+        let directory = directory(&self.root_id.0)?;
+        // Activation never creates a deployment database.
+        if !directory.is_dir() {
+            return Err("Host deployment is not installed".into());
+        }
+        let lease = FileLease::acquire(&directory.join("executor.lock"))?;
+        let store::Installation::Installed(deployment) = store::read(&directory).await? else {
+            return Err("Host deployment is absent or incomplete".into());
+        };
+        let root = root::resolve(&deployment.root_path)?;
+        if root.root_id() != self.root_id.0 {
+            return Err("deployment root identity changed".into());
+        }
+        deployment.validate(&root, &directory)?;
+        lease.validate()?;
+        let (client, live) = connect_or_launch(&deployment).await?;
+        lease.validate()?;
+        emit(
+            &Frame::Result {
+                schema_version: 1,
+                deployment_id: deployment.deployment_id,
+                config_revision: deployment.config_revision,
+                root_id: deployment.root_id,
+                host_epoch: live.epoch,
+                pid: live.pid,
+                protocol_version: 0,
+                endpoint: Endpoint {
+                    host: "127.0.0.1",
+                    port: live.port,
+                    websocket_path: "/runtime-host",
+                },
+            },
+            self.framed,
+        )?;
+        // Keep the transport resident until the complete result has been flushed.
+        drop(client);
+        Ok(())
+    }
+}
+
+async fn connect_or_launch(deployment: &Deployment) -> Result<(HostClient, LiveHost), HostError> {
+    let mut child: Option<Child> = None;
+    let result = tokio::time::timeout(Duration::from_secs(30), async {
+        loop {
+            if let Ok(mut client) =
+                HostClient::connect(&deployment.root_path, Some(&deployment.generation())).await
+            {
+                let live = client.live_host(deployment.websocket.port()).await?;
+                return Ok::<_, HostError>((client, live));
+            }
+            if let Some(child) = &mut child {
+                if let Some(status) = child.try_wait()? {
+                    return Err(format!("Host candidate exited before Ready: {status}").into());
+                }
+            } else {
+                if deployment.mode != Mode::OnDemand {
+                    return Err("the supervised Host must be started by its service".into());
+                }
+                match RootOwner::open(
+                    &deployment.root_path,
+                    &RootNamespaces::for_current_account()?,
+                ) {
+                    Ok(owner) => {
+                        if owner.root_id() != deployment.root_id {
+                            return Err("State Root changed before launch".into());
+                        }
+                        drop(owner);
+                        let mut command = Command::new(&deployment.executable);
+                        command
+                            .args(["host", "candidate", "--root"])
+                            .arg(&deployment.root_path)
+                            .args([
+                                "--expected-root-id",
+                                &deployment.root_id,
+                                "--startup-attempt-id",
+                                &Uuid::new_v4().to_string(),
+                                "--owner-stdin",
+                                "--initial-connection-timeout-ms",
+                                "30000",
+                            ])
+                            .stdin(Stdio::piped())
+                            .stdout(Stdio::null())
+                            // No inherited SSH capture pipes may outlive the operator.
+                            .stderr(Stdio::null());
+                        #[cfg(unix)]
+                        {
+                            // The SSH operator may exit while its released Host continues.
+                            // SAFETY: setsid is async-signal-safe and touches no Rust state.
+                            unsafe {
+                                command.pre_exec(|| {
+                                    if libc::setsid() == -1 {
+                                        return Err(std::io::Error::last_os_error());
+                                    }
+                                    Ok(())
+                                });
+                            }
+                        }
+                        #[cfg(windows)]
+                        command.creation_flags(0x00000008 | 0x00000200); // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
+                        child = Some(command.spawn()?);
+                    }
+                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
+                    Err(error) => return Err(error.into()),
+                }
+            }
+            tokio::time::sleep(Duration::from_millis(25)).await;
+        }
+    })
+    .await
+    .map_err(HostError::from)
+    .and_then(|result| result);
+
+    match result {
+        Ok(connected) => {
+            if let Some(child) = &mut child {
+                let mut owner = child
+                    .stdin
+                    .take()
+                    .ok_or("candidate owner pipe is missing")?;
+                owner
+                    .write_all(b"{\"kind\":\"runtime-host-launch-owner-release\"}\n")
+                    .await?;
+                drop(owner);
+            }
+            Ok(connected)
+        }
+        Err(error) => {
+            if let Some(mut child) = child {
+                // EOF requests the existing orderly drain. Never hard-kill a
+                // candidate which may already have recovered committed work.
+                drop(child.stdin.take());
+                child.wait().await?;
+            }
+            Err(error)
+        }
+    }
+}
+
+fn emit(frame: &Frame, framed: bool) -> Result<(), HostError> {
+    let bytes = serde_json::to_vec(frame)?;
+    let line = if framed {
+        let encoded = URL_SAFE_NO_PAD.encode(bytes);
+        if encoded.len() > 16 * 1024 {
+            return Err("activation frame exceeds its limit".into());
+        }
+        format!("MAKA_RUNTIME_HOST_ACTIVATION_V1 {encoded}\n")
+    } else {
+        format!("{}\n", String::from_utf8(bytes)?)
+    };
+    let mut stdout = std::io::stdout().lock();
+    stdout.write_all(line.as_bytes())?;
+    stdout.flush()?;
+    Ok(())
+}
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
index 0983e8d8db..fedcb14fff 100644
--- a/crates/cli/src/host_client.rs
+++ b/crates/cli/src/host_client.rs
@@ -20,7 +20,7 @@
 use maka_event_log::root::{self, RootNamespaces};
 use maka_protocol::{
     COMPATIBILITY_EPOCH, COMPOSITION_ID, Operation, Outcome, Request,
-    handshake::{HostHandshake, Lifecycle, decode_host_handshake},
+    handshake::{ClientHello, HostHandshake, Lifecycle, decode_host_handshake},
     host::{RetirementInput, RetirementResult, decode_retirement_result},
 };
 use maka_runtime_host::server::{HostError, HostOperations};
@@ -29,7 +29,7 @@ use serde::Deserialize;
 use serde_json::{Value, json};
 use std::{
     io::Read,
-    num::NonZeroU32,
+    num::{NonZeroU16, NonZeroU32},
     path::{Path, PathBuf},
     time::Duration,
 };
@@ -49,6 +49,14 @@ struct Discovery {
     host_epoch: String,
     endpoint: PathBuf,
     pid: NonZeroU32,
+    #[serde(default)]
+    websocket_endpoints: Vec<String>,
+}
+
+pub(super) struct LiveHost {
+    pub epoch: String,
+    pub pid: NonZeroU32,
+    pub port: NonZeroU16,
 }
 
 pub(super) struct HostClient {
@@ -58,7 +66,7 @@ pub(super) struct HostClient {
 }
 
 impl HostClient {
-    pub async fn connect(root: &Path) -> Result<Self, HostError> {
+    pub async fn connect(root: &Path, generation: Option<&str>) -> Result<Self, HostError> {
         let root = root.to_owned();
         tokio::time::timeout(Duration::from_secs(5), async move {
             let discovery = tokio::task::spawn_blocking(move || read_discovery(&root)).await??;
@@ -71,31 +79,51 @@ impl HostClient {
                     match ClientOptions::new().open(&discovery.endpoint) {
                         Ok(stream) => break stream,
                         // An existing pipe instance may be between accepts.
-                        Err(error) if error.raw_os_error() == Some(231) =>
-                            tokio::time::sleep(Duration::from_millis(10)).await,
+                        Err(error) if error.raw_os_error() == Some(231) => {
+                            tokio::time::sleep(Duration::from_millis(10)).await
+                        }
                         Err(error) => return Err(error.into()),
                     }
                 }
             };
-            let (mut reader, mut writer) = maka_transport::ndjson::split(stream, CancellationToken::new());
-            writer.write(&json!({
-                "kind":"hello", "clientInstanceId":format!("maka-operator-{}", uuid::Uuid::new_v4()),
-                "surface":"cli", "activitySnapshotVersion":2,
-                "protocolMin":0, "protocolMax":0, "compatibilityEpoch":COMPATIBILITY_EPOCH,
-                "compositionId":COMPOSITION_ID,
-            })).await?;
+            let (mut reader, mut writer) =
+                maka_transport::ndjson::split(stream, CancellationToken::new());
+            writer
+                .write(&ClientHello {
+                    client_instance_id: format!("maka-operator-{}", uuid::Uuid::new_v4()),
+                    activity_snapshot_version: Some(2),
+                    protocol_min: 0,
+                    protocol_max: 0,
+                    compatibility_epoch: COMPATIBILITY_EPOCH,
+                    composition_id: COMPOSITION_ID.into(),
+                    generation: generation.map(str::to_owned),
+                    takeover: None,
+                })
+                .await?;
             let hello = reader.read().await?.ok_or("Host closed before handshake")?;
             match decode_host_handshake(&hello)? {
                 HostHandshake::Accepted {
-                    root_id, host_epoch, composition_id, selected_protocol,
-                    compatibility_epoch, state: Lifecycle::Ready, ..
-                } if root_id == discovery.root_id && host_epoch == discovery.host_epoch
-                    && composition_id == COMPOSITION_ID && selected_protocol == 0
+                    root_id,
+                    host_epoch,
+                    composition_id,
+                    selected_protocol,
+                    compatibility_epoch,
+                    state: Lifecycle::Ready,
+                    ..
+                } if root_id == discovery.root_id
+                    && host_epoch == discovery.host_epoch
+                    && composition_id == COMPOSITION_ID
+                    && selected_protocol == 0
                     && compatibility_epoch == COMPATIBILITY_EPOCH => {}
                 _ => return Err("Host discovery is stale, incompatible or not ready".into()),
             }
-            Ok(Self { discovery, reader, writer })
-        }).await?
+            Ok(Self {
+                discovery,
+                reader,
+                writer,
+            })
+        })
+        .await?
     }
 
     pub async fn status(&mut self) -> Result<Value, HostError> {
@@ -106,6 +134,44 @@ impl HostClient {
         Ok(result)
     }
 
+    pub async fn live_host(&mut self, configured_port: u16) -> Result<LiveHost, HostError> {
+        #[derive(Deserialize)]
+        #[serde(rename_all = "camelCase")]
+        struct Identity {
+            host_epoch: String,
+            pid: NonZeroU32,
+            protocol_version: u64,
+            compatibility_epoch: u64,
+        }
+        let result = self
+            .request(Operation::HostDiagnosticsQuery, json!({}))
+            .await?;
+        let identity: Identity = serde_json::from_value(result)?;
+        if identity.host_epoch != self.discovery.host_epoch
+            || identity.pid != self.discovery.pid
+            || identity.protocol_version != 0
+            || identity.compatibility_epoch != COMPATIBILITY_EPOCH
+        {
+            return Err("live Host diagnostics disagree with discovery".into());
+        }
+        let [endpoint] = self.discovery.websocket_endpoints.as_slice() else {
+            return Err("managed Host must publish exactly one WebSocket endpoint".into());
+        };
+        let port: NonZeroU16 = endpoint
+            .strip_prefix("ws://127.0.0.1:")
+            .and_then(|value| value.strip_suffix("/runtime-host"))
+            .ok_or("managed Host published an invalid WebSocket endpoint")?
+            .parse()?;
+        if configured_port != 0 && configured_port != port.get() {
+            return Err("managed Host listener differs from deployment configuration".into());
+        }
+        Ok(LiveHost {
+            epoch: identity.host_epoch,
+            pid: identity.pid,
+            port,
+        })
+    }
+
     pub async fn retire(
         &mut self,
         expected_epoch: Option<&str>,
diff --git a/crates/cli/tests/integration/candidate.rs b/crates/cli/tests/integration/candidate.rs
index 2b5c504a78..819f82a998 100644
--- a/crates/cli/tests/integration/candidate.rs
+++ b/crates/cli/tests/integration/candidate.rs
@@ -254,6 +254,20 @@ impl CandidateFixture {
             std::thread::sleep(Duration::from_millis(20));
         }
     }
+
+    pub fn retire_registered(&self) {
+        if let Ok(bytes) = fs::read(&self.registration)
+            && let Ok(record) = serde_json::from_slice::<Value>(&bytes)
+            && let Some(epoch) = record["hostEpoch"].as_str()
+        {
+            // This fixture owns a fresh, random root; the CLI rechecks the live epoch.
+            let _ = Command::new(env!("CARGO_BIN_EXE_maka"))
+                .args(["host", "retire", "--root"])
+                .arg(&self.root)
+                .args(["--expected-host-epoch", epoch])
+                .output();
+        }
+    }
 }
 
 impl Drop for CandidateFixture {
@@ -262,6 +276,7 @@ impl Drop for CandidateFixture {
             let _ = child.kill();
             let _ = child.wait();
         }
+        self.retire_registered();
         let control = self.registration.parent().unwrap();
         let namespaces = RootNamespaces {
             ownership: self.lock.parent().unwrap().to_owned(),
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index ed5d6b93e8..d7dd613903 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -18,13 +18,15 @@
  */
 
 use super::candidate::CandidateFixture;
+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
 use maka_event_log::root::{RootNamespaces, RootOwner};
 use serde_json::Value;
 use std::{
     io::{Read, Write},
     net::{SocketAddr, TcpStream},
+    path::Path,
     process::{Command, Stdio},
-    time::Duration,
+    time::{Duration, Instant},
 };
 
 #[test]
@@ -150,6 +152,20 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
             serde_json::from_slice::<Value>(&repeated.stdout).unwrap(),
             installed
         );
+        let mut activate = Command::new(executable);
+        activate.args([
+            "host",
+            "activate",
+            "--framed",
+            "--root-id",
+            &fixture.root_id,
+        ]);
+        let connected = activate.output().unwrap();
+        assert!(connected.status.success(), "{connected:?}");
+        let connected = decode_activation(&connected.stdout);
+        assert_eq!(connected["hostEpoch"], registration["hostEpoch"]);
+        assert_eq!(connected["pid"], registration["pid"]);
+        assert_eq!(connected["endpoint"]["port"], address.port());
         assert!(RootOwner::open(&fixture.root, &namespaces).is_err());
         let retired = Command::new(env!("CARGO_BIN_EXE_maka"))
             .args(["host", "retire", "--root"])
@@ -163,5 +179,52 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         );
         assert!(fixture.wait_for_exit().success());
         drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
+
+        let started = Instant::now();
+        let activated = activate.output().unwrap();
+        if !candidate {
+            assert!(
+                !activated.status.success(),
+                "must not bypass service ownership"
+            );
+            assert_eq!(decode_activation(&activated.stdout)["kind"], "error");
+            continue;
+        }
+        assert!(activated.status.success(), "{activated:?}");
+        assert!(
+            started.elapsed() < Duration::from_secs(15),
+            "inherited capture pipes kept activation alive"
+        );
+        let frame = decode_activation(&activated.stdout);
+        assert_ne!(frame["hostEpoch"], registration["hostEpoch"]);
+        assert_eq!(frame["deploymentId"], installed["deploymentId"]);
+        // The operator has exited; a relay still has time to establish its client.
+        std::thread::sleep(Duration::from_millis(1100));
+        let client = Command::new("node")
+            .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
+            .arg("--activation-frame")
+            .arg(String::from_utf8(activated.stdout).unwrap())
+            .arg("--root")
+            .arg(&fixture.root)
+            .output()
+            .unwrap();
+        assert!(
+            client.status.success(),
+            "{}",
+            String::from_utf8_lossy(&client.stderr)
+        );
+        let reused = activate.output().unwrap();
+        assert!(reused.status.success(), "{reused:?}");
+        assert_eq!(decode_activation(&reused.stdout), frame);
+        fixture.retire_registered();
     }
 }
+
+fn decode_activation(bytes: &[u8]) -> Value {
+    let line = std::str::from_utf8(bytes).unwrap();
+    let encoded = line
+        .trim()
+        .strip_prefix("MAKA_RUNTIME_HOST_ACTIVATION_V1 ")
+        .unwrap();
+    serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()).unwrap()
+}
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 9e9c603d25..f06c2de177 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -55,8 +55,10 @@ socket on Linux/macOS or a private named pipe on Windows. An optional
 
 `host install --root <directory>` pins the current executable and on-demand policy;
 `--mode supervised` selects persistent serving. Only the returned `executable`
-may start that managed root. Installation does not yet register an OS service or
-provide automatic activation/updates. Deployment authority lives in account-level
+may start that managed root. `host activate --root-id <rootId> --framed` reuses a ready
+Host or starts the pinned on-demand executable; supervised mode requires a running
+service. Installation does not yet register OS services or automate updates.
+Deployment authority lives in account-level
 SQLite, outside the State Root; startup checks it before database migrations.
 
 The single binary also provides `host candidate` for Desktop-owned startup,
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index f5cb0895ff..cebad0c8f0 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -50,7 +50,8 @@ maka host retire --root /absolute/path/to/new-root
 Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要认证,尚不支持 TLS。
 
 `host install --root <目录>` 固定当前可执行文件和按需策略;`--mode supervised` 选择持续运行。
-此后只有返回的 `executable` 可以启动该托管根。安装尚不注册 OS 服务,也不提供自动激活或更新。
+此后只有返回的 `executable` 可以启动该托管根。`host activate --root-id <rootId> --framed`
+复用就绪 Host,或启动固定的按需版本;supervised 模式要求服务已运行。安装尚不注册 OS 服务或自动更新。
 部署权威保存在 State Root 之外的账户级 SQLite 中,启动时先校验再执行数据库迁移。
 
 唯一二进制还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
diff --git a/tests/fixtures/client-activation-entry.mjs b/tests/fixtures/client-activation-entry.mjs
new file mode 100644
index 0000000000..b34c938b08
--- /dev/null
+++ b/tests/fixtures/client-activation-entry.mjs
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { parseArgs } from 'node:util';
+import { connectExistingRuntimeHost } from '../../packages/runtime-host/src/client/connection.js';
+import { decodeRuntimeHostActivationFrame } from '../../packages/runtime-host/src/operator/activation-frame.js';
+
+const { values } = parseArgs({
+  options: {
+    'activation-frame': { type: 'string' },
+    root: { type: 'string' },
+  },
+});
+const frame = decodeRuntimeHostActivationFrame(values['activation-frame']);
+assert.equal(frame?.kind, 'result');
+const input = {
+  rootPath: values.root,
+  protocol: { min: 0, max: 0 },
+  compositionId: 'maka.interactive',
+  generation: `${frame.deploymentId}:${frame.configRevision}`,
+};
+const connection = await connectExistingRuntimeHost(input);
+assert.equal(connection.kind, 'connected', JSON.stringify(connection));
+try {
+  assert.equal(connection.connection.rootId, frame.rootId);
+  assert.equal(connection.connection.hostEpoch, frame.hostEpoch);
+  const diagnostics = await connection.connection.request('host.diagnostics.query', {});
+  assert.equal(diagnostics.pid, frame.pid);
+  const stale = await connectExistingRuntimeHost({ ...input, generation: 'stale-deployment' });
+  assert.equal(stale.kind, 'upgrade_required');
+  assert.equal(stale.handshake.generation, input.generation, 'live handshake rejected stale code');
+} finally {
+  await connection.connection.close();
+}
diff --git a/tests/fixtures/client.mjs b/tests/fixtures/client.mjs
index 452b4a4d62..9afe61c4a5 100644
--- a/tests/fixtures/client.mjs
+++ b/tests/fixtures/client.mjs
@@ -35,6 +35,7 @@ const entries = [
   ['--onboarding-workspace', 'client-onboarding-entry.mjs'],
   ['--project-workspace', 'client-project-entry.mjs'],
   ['--native-candidate', 'client-candidate-entry.mjs'],
+  ['--activation-frame', 'client-activation-entry.mjs'],
   ['--system-prompt-workspace', 'client-system-prompt-entry.mjs'],
   ['--model-overrides-workspace', 'client-model-overrides-entry.mjs'],
   ['--runtime-policy-workspace', 'client-runtime-policy-entry.mjs'],


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 11/29: feat(cli): add native host discovery and retirement control

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 749bbfe25a14a7c92037f7b7d5888881b6aac309
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 16:35:36 2026 +0800

    feat(cli): add native host discovery and retirement control
    
    Generated-by: OpenAI Codex
---
 Cargo.lock                                   |   3 +
 crates/cli/Cargo.toml                        |   5 +
 crates/cli/src/args.rs                       |  32 ++++
 crates/cli/src/host_client.rs                | 222 +++++++++++++++++++++++++++
 crates/cli/src/main.rs                       |   1 +
 crates/cli/src/serve.rs                      |   6 +-
 crates/cli/tests/integration/candidate.rs    |  81 +++++++++-
 crates/event-log/src/root.rs                 |  56 +++++--
 crates/event-log/tests/root_authority.rs     |   9 ++
 crates/protocol/src/lib.rs                   |   3 +-
 crates/runtime-host/src/server.rs            |   1 +
 crates/runtime-host/src/server/operations.rs |   3 +-
 docs/rust-runtime.md                         |   2 +
 docs/rust-runtime.zh-CN.md                   |   2 +
 14 files changed, 401 insertions(+), 25 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 7e0fd8cff8..ffb64913fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3938,10 +3938,13 @@ name = "maka-cli"
 version = "0.0.0"
 dependencies = [
  "clap",
+ "libc",
  "maka-event-log",
  "maka-js-runtime",
+ "maka-protocol",
  "maka-runtime",
  "maka-runtime-host",
+ "maka-transport",
  "serde",
  "serde_json",
  "tempfile",
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 1f77f49b64..fab1d36fb5 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -33,6 +33,8 @@ clap.workspace = true
 maka-runtime-host.workspace = true
 maka-runtime.workspace = true
 maka-event-log.workspace = true
+maka-protocol.workspace = true
+maka-transport.workspace = true
 maka-js-runtime.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -43,3 +45,6 @@ tempfile.workspace = true
 
 [dev-dependencies]
 tempfile.workspace = true
+
+[target.'cfg(unix)'.dependencies]
+libc.workspace = true
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 52ebcd2c13..c5ff57a466 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -49,6 +49,18 @@ enum Command {
 enum HostCommand {
     /// Initialize an empty native State Root, or verify its existing identity.
     Init(Root),
+    /// Query the existing Host without opening its State Root for writing.
+    Status(Root),
+    /// Retire the exact current Host, preserving work at safe step boundaries.
+    Retire {
+        #[command(flatten)]
+        root: Root,
+        #[arg(long)]
+        expected_host_epoch: Option<String>,
+        /// Permit interrupting other clients and non-cooperative resources.
+        #[arg(long)]
+        allow_interrupt_active_tasks: bool,
+    },
     /// Run a discoverable ephemeral Host under its launcher.
     Candidate(candidate::Candidate),
     /// Serve a native State Root over a private local endpoint.
@@ -92,6 +104,26 @@ impl Cli {
             Command::Host(HostCommand::Serve { root, websocket }) => {
                 serve::run(&root.root, websocket).await
             }
+            Command::Host(HostCommand::Status(root)) => {
+                let mut client = crate::host_client::HostClient::connect(&root.root).await?;
+                let status = client.status().await?;
+                drop(client);
+                println!("{}", serde_json::to_string(&status)?);
+                Ok(())
+            }
+            Command::Host(HostCommand::Retire {
+                root,
+                expected_host_epoch,
+                allow_interrupt_active_tasks,
+            }) => {
+                let mut client = crate::host_client::HostClient::connect(&root.root).await?;
+                let result = client
+                    .retire(expected_host_epoch.as_deref(), allow_interrupt_active_tasks)
+                    .await?;
+                drop(client);
+                println!("{}", serde_json::to_string(&result)?);
+                Ok(())
+            }
             Command::Code(args) => code::run(&args.log).await,
             Command::Inspect(args) => {
                 let log = EventLog::open(&args.log).await?;
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
new file mode 100644
index 0000000000..0983e8d8db
--- /dev/null
+++ b/crates/cli/src/host_client.rs
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_event_log::root::{self, RootNamespaces};
+use maka_protocol::{
+    COMPATIBILITY_EPOCH, COMPOSITION_ID, Operation, Outcome, Request,
+    handshake::{HostHandshake, Lifecycle, decode_host_handshake},
+    host::{RetirementInput, RetirementResult, decode_retirement_result},
+};
+use maka_runtime_host::server::{HostError, HostOperations};
+use maka_transport::ndjson::{NdjsonReader, NdjsonWriter};
+use serde::Deserialize;
+use serde_json::{Value, json};
+use std::{
+    io::Read,
+    num::NonZeroU32,
+    path::{Path, PathBuf},
+    time::Duration,
+};
+use tokio::io::{ReadHalf, WriteHalf};
+use tokio_util::sync::CancellationToken;
+
+#[cfg(unix)]
+type Stream = tokio::net::UnixStream;
+#[cfg(windows)]
+type Stream = tokio::net::windows::named_pipe::NamedPipeClient;
+
+/// Discovery is only a hint. The live handshake must confirm both root and epoch.
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Discovery {
+    root_id: String,
+    host_epoch: String,
+    endpoint: PathBuf,
+    pid: NonZeroU32,
+}
+
+pub(super) struct HostClient {
+    discovery: Discovery,
+    reader: NdjsonReader<ReadHalf<Stream>>,
+    writer: NdjsonWriter<WriteHalf<Stream>>,
+}
+
+impl HostClient {
+    pub async fn connect(root: &Path) -> Result<Self, HostError> {
+        let root = root.to_owned();
+        tokio::time::timeout(Duration::from_secs(5), async move {
+            let discovery = tokio::task::spawn_blocking(move || read_discovery(&root)).await??;
+            #[cfg(unix)]
+            let stream = Stream::connect(&discovery.endpoint).await?;
+            #[cfg(windows)]
+            let stream = {
+                use tokio::net::windows::named_pipe::ClientOptions;
+                loop {
+                    match ClientOptions::new().open(&discovery.endpoint) {
+                        Ok(stream) => break stream,
+                        // An existing pipe instance may be between accepts.
+                        Err(error) if error.raw_os_error() == Some(231) =>
+                            tokio::time::sleep(Duration::from_millis(10)).await,
+                        Err(error) => return Err(error.into()),
+                    }
+                }
+            };
+            let (mut reader, mut writer) = maka_transport::ndjson::split(stream, CancellationToken::new());
+            writer.write(&json!({
+                "kind":"hello", "clientInstanceId":format!("maka-operator-{}", uuid::Uuid::new_v4()),
+                "surface":"cli", "activitySnapshotVersion":2,
+                "protocolMin":0, "protocolMax":0, "compatibilityEpoch":COMPATIBILITY_EPOCH,
+                "compositionId":COMPOSITION_ID,
+            })).await?;
+            let hello = reader.read().await?.ok_or("Host closed before handshake")?;
+            match decode_host_handshake(&hello)? {
+                HostHandshake::Accepted {
+                    root_id, host_epoch, composition_id, selected_protocol,
+                    compatibility_epoch, state: Lifecycle::Ready, ..
+                } if root_id == discovery.root_id && host_epoch == discovery.host_epoch
+                    && composition_id == COMPOSITION_ID && selected_protocol == 0
+                    && compatibility_epoch == COMPATIBILITY_EPOCH => {}
+                _ => return Err("Host discovery is stale, incompatible or not ready".into()),
+            }
+            Ok(Self { discovery, reader, writer })
+        }).await?
+    }
+
+    pub async fn status(&mut self) -> Result<Value, HostError> {
+        let result = self.request(Operation::HostStatus, json!({})).await?;
+        if result["hostEpoch"] != self.discovery.host_epoch {
+            return Err("Host epoch changed during status query".into());
+        }
+        Ok(result)
+    }
+
+    pub async fn retire(
+        &mut self,
+        expected_epoch: Option<&str>,
+        interrupt: bool,
+    ) -> Result<RetirementResult, HostError> {
+        if expected_epoch.is_some_and(|epoch| epoch != self.discovery.host_epoch) {
+            return Err("Host epoch does not match the retirement target".into());
+        }
+        let input = RetirementInput {
+            expected_host_epoch: self.discovery.host_epoch.clone(),
+            allow_interrupt_active_tasks: interrupt,
+            allow_cooperative_handoff: Some(true),
+        };
+        let result = decode_retirement_result(
+            &self
+                .request(Operation::HostUpgradePrepare, serde_json::to_value(input)?)
+                .await?,
+        )?;
+        if let RetirementResult::Prepared { pid } = result
+            && pid != self.discovery.pid
+        {
+            return Err("Retirement receipt belongs to another process".into());
+        }
+        Ok(result)
+    }
+
+    async fn request(&mut self, operation: Operation, input: Value) -> Result<Value, HostError> {
+        // A timeout is not evidence of rollback. The caller must rediscover the
+        // exact owner before deciding whether any subsequent mutation is safe.
+        tokio::time::timeout(Duration::from_secs(15), async {
+            let request_id = uuid::Uuid::new_v4().to_string();
+            self.writer
+                .write(&Request {
+                    request_id: request_id.clone(),
+                    operation,
+                    input,
+                })
+                .await?;
+            loop {
+                let frame = self
+                    .reader
+                    .read()
+                    .await?
+                    .ok_or("Host closed before its response")?;
+                if frame.get("requestId").is_none() {
+                    continue;
+                }
+                let response = maka_protocol::decode_response(&frame, &HostOperations)?;
+                if response.request_id != request_id || response.operation != operation {
+                    return Err("Host returned an unrelated response".into());
+                }
+                return match response.outcome {
+                    Outcome::Success { result } => Ok(result),
+                    Outcome::Failure { error } => Err(error.into()),
+                };
+            }
+        })
+        .await?
+    }
+}
+
+fn read_discovery(path: &Path) -> Result<Discovery, HostError> {
+    let root = root::resolve(path)?;
+    let path = RootNamespaces::for_current_account()?
+        .control
+        .join(root.root_id())
+        .join("registration.json");
+    let before = path.symlink_metadata()?;
+    if !before.is_file() || before.len() > 16 * 1024 {
+        return Err("Invalid Host discovery record".into());
+    }
+    #[cfg(unix)]
+    let file = {
+        use std::os::unix::fs::OpenOptionsExt;
+        std::fs::OpenOptions::new()
+            .read(true)
+            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+            .open(&path)?
+    };
+    #[cfg(windows)]
+    let file = maka_event_log::root::windows::open_nofollow(&path, false)?;
+    if !file.metadata()?.is_file() {
+        return Err("Host discovery is not a regular file".into());
+    }
+    let mut bytes = Vec::new();
+    file.take(16 * 1024 + 1).read_to_end(&mut bytes)?;
+    let after = path.symlink_metadata()?;
+    if !after.is_file()
+        || bytes.len() > 16 * 1024
+        || before.len() != bytes.len() as u64
+        || before.len() != after.len()
+        || before.modified()? != after.modified()?
+    {
+        return Err("Host discovery changed while reading".into());
+    }
+    let discovery: Discovery = serde_json::from_slice(&bytes)?;
+    if discovery.root_id != root.root_id()
+        || discovery.host_epoch.is_empty()
+        || discovery.host_epoch.len() > 128
+        || !discovery.endpoint.is_absolute()
+    {
+        return Err("Host discovery does not match the native root".into());
+    }
+    #[cfg(windows)]
+    if !discovery
+        .endpoint
+        .to_str()
+        .and_then(|path| path.strip_prefix(r"\\.\pipe\"))
+        .is_some_and(|name| !name.is_empty() && !name.contains(['\\', '/', '\0']))
+    {
+        return Err("Host discovery is not a local named pipe".into());
+    }
+    Ok(discovery)
+}
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 6664497c34..0a2e72651a 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -22,6 +22,7 @@ mod args;
 mod candidate;
 mod code;
 mod endpoint;
+mod host_client;
 mod serve;
 mod signals;
 
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index 4f4de1b2e7..f39df116f7 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -49,12 +49,13 @@ pub(super) async fn run(
     let endpoint = super::endpoint::LocalEndpoint::bind()?;
     let cancellation = CancellationToken::new();
     let _signals = super::signals::watch(cancellation.clone())?;
+    let registration = host.publish_registration(&endpoint.path)?;
     let mut ready = json!({"kind":"ready","rootId":host.root_id(),"socketPath":endpoint.path});
     if let Some(listener) = &websocket {
         ready["websocketUrl"] = format!("ws://{}/runtime-host", listener.local_addr()?).into();
     }
     println!("{ready}");
-    match websocket {
+    let result = match websocket {
         Some(websocket) => {
             endpoint
                 .listener
@@ -62,7 +63,8 @@ pub(super) async fn run(
                 .await
         }
         None => endpoint.listener.serve(host, cancellation).await,
-    }
+    };
+    result.and(registration.remove())
 }
 
 pub(super) fn global_instructions()
diff --git a/crates/cli/tests/integration/candidate.rs b/crates/cli/tests/integration/candidate.rs
index accb236acb..c18de6cb6e 100644
--- a/crates/cli/tests/integration/candidate.rs
+++ b/crates/cli/tests/integration/candidate.rs
@@ -42,9 +42,23 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
     };
     drop(owner);
 
-    for release in [false, true] {
-        fixture.child = Some(
-            Command::new(env!("CARGO_BIN_EXE_maka"))
+    enum End {
+        OwnerLoss,
+        ReleasedIdle,
+        Retirement,
+        Standalone,
+    }
+    for end in [
+        End::OwnerLoss,
+        End::ReleasedIdle,
+        End::Retirement,
+        End::Standalone,
+    ] {
+        let mut command = Command::new(env!("CARGO_BIN_EXE_maka"));
+        if matches!(end, End::Standalone) {
+            command.args(["host", "serve", "--root"]).arg(&fixture.root);
+        } else {
+            command
                 .args(["host", "candidate", "--root"])
                 .arg(&fixture.root)
                 .args([
@@ -57,7 +71,10 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
                     "--idle-grace-ms",
                     "1000",
                     "--owner-stdin",
-                ])
+                ]);
+        }
+        fixture.child = Some(
+            command
                 .stdin(Stdio::piped())
                 .stdout(Stdio::null())
                 .stderr(Stdio::inherit())
@@ -67,7 +84,9 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
         let registration = fixture.wait_for_registration();
         assert_eq!(registration["pid"], fixture.child.as_ref().unwrap().id());
         assert_eq!(registration["rootId"], fixture.root_id);
-        assert_eq!(registration["generation"], "native-cli-test");
+        if !matches!(end, End::Standalone) {
+            assert_eq!(registration["generation"], "native-cli-test");
+        }
         assert!(RootOwner::open(&fixture.root, &namespaces).is_err());
 
         // Initialization verifies identity without competing for an active writer lease.
@@ -87,7 +106,7 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
         );
 
         let mut stdin = fixture.child.as_mut().unwrap().stdin.take().unwrap();
-        if release {
+        if matches!(end, End::ReleasedIdle) {
             stdin
                 .write_all(b"{\"kind\":\"runtime-host-launch-owner-release\"}\n")
                 .unwrap();
@@ -112,7 +131,55 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
             String::from_utf8_lossy(&probe.stderr)
         );
 
-        if !release {
+        let status = Command::new(env!("CARGO_BIN_EXE_maka"))
+            .args(["host", "status", "--root"])
+            .arg(&fixture.root)
+            .output()
+            .unwrap();
+        assert!(
+            status.status.success(),
+            "{}",
+            String::from_utf8_lossy(&status.stderr)
+        );
+        let status: Value = serde_json::from_slice(&status.stdout).unwrap();
+        assert_eq!(status["hostEpoch"], registration["hostEpoch"]);
+        assert_eq!(status["state"], "ready");
+        if matches!(end, End::Retirement | End::Standalone) {
+            let stale = Command::new(env!("CARGO_BIN_EXE_maka"))
+                .args(["host", "retire", "--root"])
+                .arg(&fixture.root)
+                .args(["--expected-host-epoch", "stale"])
+                .output()
+                .unwrap();
+            assert!(!stale.status.success());
+            assert!(
+                fixture
+                    .child
+                    .as_mut()
+                    .unwrap()
+                    .try_wait()
+                    .unwrap()
+                    .is_none()
+            );
+            let retired = Command::new(env!("CARGO_BIN_EXE_maka"))
+                .args(["host", "retire", "--root"])
+                .arg(&fixture.root)
+                .args([
+                    "--expected-host-epoch",
+                    registration["hostEpoch"].as_str().unwrap(),
+                ])
+                .output()
+                .unwrap();
+            assert!(
+                retired.status.success(),
+                "{}",
+                String::from_utf8_lossy(&retired.stderr)
+            );
+            let receipt: Value = serde_json::from_slice(&retired.stdout).unwrap();
+            assert_eq!(receipt["kind"], "prepared", "{receipt}");
+            assert_eq!(receipt["pid"], registration["pid"]);
+        }
+        if matches!(end, End::OwnerLoss) {
             drop(fixture.child.as_mut().unwrap().stdin.take());
         }
         let status = fixture.wait_for_exit();
diff --git a/crates/event-log/src/root.rs b/crates/event-log/src/root.rs
index 3d0a378be4..ccd3e2028f 100644
--- a/crates/event-log/src/root.rs
+++ b/crates/event-log/src/root.rs
@@ -90,18 +90,52 @@ pub struct RootOwner {
     compatibility_lease: File,
 }
 
+/// A verified location, not a writer lease. Every mutation still needs RootOwner.
+#[derive(Debug, Clone)]
+pub struct RootLocation {
+    canonical_path: PathBuf,
+    root_id: String,
+}
+
+impl RootLocation {
+    pub fn canonical_path(&self) -> &Path {
+        &self.canonical_path
+    }
+
+    pub fn root_id(&self) -> &str {
+        &self.root_id
+    }
+}
+
+/// Inspect only an existing native root. Never initialize, repair or acquire it.
+pub fn resolve(path: &Path) -> io::Result<RootLocation> {
+    let (canonical_path, marker) = inspect(path)?;
+    Ok(RootLocation {
+        canonical_path,
+        root_id: marker.root_id,
+    })
+}
+
+fn inspect(path: &Path) -> io::Result<(PathBuf, Marker)> {
+    let canonical_path = path.canonicalize()?;
+    let identity = directory_identity(&canonical_path)?;
+    check_layout(&canonical_path)?;
+    let marker = read_marker(&canonical_path)?;
+    if marker.root_identity != identity
+        || directory_identity(&canonical_path)? != identity
+        || canonical_path.canonicalize()? != canonical_path
+    {
+        return Err(io::Error::other("root identity collision"));
+    }
+    Ok((canonical_path, marker))
+}
+
 /// Verify an existing native root without taking its writer lease, or initialize an empty one.
 pub fn initialize(path: &Path, namespaces: &RootNamespaces) -> io::Result<String> {
     if !path.join(ROOT_MARKER).exists() {
         return Ok(RootOwner::create(path, namespaces)?.root_id().to_owned());
     }
-    let path = path.canonicalize()?;
-    check_layout(&path)?;
-    let marker = read_marker(&path)?;
-    if marker.root_identity != directory_identity(&path)? {
-        return Err(io::Error::other("root identity collision"));
-    }
-    Ok(marker.root_id)
+    Ok(resolve(path)?.root_id)
 }
 
 impl RootOwner {
@@ -134,13 +168,7 @@ impl RootOwner {
 
     /// Open only a marked Rust prototype root, rejecting legacy layouts before database access.
     pub fn open(path: &Path, namespaces: &RootNamespaces) -> io::Result<Self> {
-        let canonical_path = path.canonicalize()?;
-        let identity = directory_identity(&canonical_path)?;
-        check_layout(&canonical_path)?;
-        let marker = read_marker(&canonical_path)?;
-        if marker.root_identity != identity {
-            return Err(io::Error::other("root identity collision"));
-        }
+        let (canonical_path, marker) = inspect(path)?;
         lock::private_directory(&namespaces.ownership)?;
         let lock_path = namespaces
             .ownership
diff --git a/crates/event-log/tests/root_authority.rs b/crates/event-log/tests/root_authority.rs
index 5af228dc1c..2c8b448af8 100644
--- a/crates/event-log/tests/root_authority.rs
+++ b/crates/event-log/tests/root_authority.rs
@@ -33,6 +33,11 @@ fn identity_and_lease_survive_reopen_and_symlink_alias() {
     let temp = tempfile::tempdir().unwrap();
     let ns = namespaces(temp.path());
     let path = temp.path().join("root");
+    assert!(maka_event_log::root::resolve(&path).is_err());
+    assert!(
+        !path.exists(),
+        "read-only resolution must not create a root"
+    );
     let owner = RootOwner::create(&path, &ns).unwrap();
     let id = owner.root_id().to_owned();
     assert_eq!(id.len(), 64);
@@ -43,6 +48,9 @@ fn identity_and_lease_survive_reopen_and_symlink_alias() {
     assert_eq!(marker["rootId"], id);
     let alias = temp.path().join("alias");
     std::os::unix::fs::symlink(&path, &alias).unwrap();
+    let location = maka_event_log::root::resolve(&alias).unwrap();
+    assert_eq!(location.root_id(), id);
+    assert_eq!(location.canonical_path(), owner.canonical_path());
     assert!(RootOwner::open(&alias, &ns).is_err());
     owner.validate_current().unwrap();
     drop(owner);
@@ -61,6 +69,7 @@ fn copied_marker_and_rebound_path_cannot_reuse_identity() {
         fs::copy(path.join(name), copy.join(name)).unwrap();
     }
     assert!(RootOwner::open(&copy, &ns).is_err());
+    assert!(maka_event_log::root::resolve(&copy).is_err());
     fs::rename(&path, temp.path().join("moved")).unwrap();
     fs::rename(&copy, &path).unwrap();
     assert!(owner.validate_current().is_err());
diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs
index 82b75f4eca..7f4812115d 100644
--- a/crates/protocol/src/lib.rs
+++ b/crates/protocol/src/lib.rs
@@ -142,7 +142,8 @@ pub struct Request {
     pub input: Value,
 }
 
-#[derive(Debug, Clone, PartialEq, Serialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, thiserror::Error)]
+#[error("{code:?}: {message}")]
 pub struct OperationError {
     pub code: OperationErrorCode,
     pub message: String,
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index a524d67452..04ffde47cf 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -67,6 +67,7 @@ use tokio_util::task::TaskTracker;
 use uuid::Uuid;
 
 pub type HostError = Box<dyn Error + Send + Sync>;
+pub use operations::Operations as HostOperations;
 pub use projects::DirectoryRootSpec;
 pub use registration::Registration;
 
diff --git a/crates/runtime-host/src/server/operations.rs b/crates/runtime-host/src/server/operations.rs
index 6b3dcf7b30..b45742b42b 100644
--- a/crates/runtime-host/src/server/operations.rs
+++ b/crates/runtime-host/src/server/operations.rs
@@ -25,7 +25,8 @@ use maka_protocol::OperationErrorCode;
 use maka_protocol::{Operation, OperationRegistry, Result};
 use serde_json::Value;
 
-pub(super) struct Operations;
+/// Wire contracts supported by this Host build, shared with native operators.
+pub struct Operations;
 
 impl OperationRegistry for Operations {
     fn decode_input(&self, operation: Operation, value: &Value) -> Result<Value> {
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 6beb718fc8..27f60242e2 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -45,6 +45,8 @@ execution:
 ```sh
 maka host init --root /absolute/path/to/new-root
 maka host serve --root /absolute/path/to/new-root
+maka host status --root /absolute/path/to/new-root
+maka host retire --root /absolute/path/to/new-root
 ```
 
 Use `target/debug/maka` if the binary is not on PATH. Local transport is a Unix
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 628bbfb17d..fb250d6682 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -42,6 +42,8 @@ TypeScript State Root。独立运行时使用新目录:
 ```sh
 maka host init --root /absolute/path/to/new-root
 maka host serve --root /absolute/path/to/new-root
+maka host status --root /absolute/path/to/new-root
+maka host retire --root /absolute/path/to/new-root
 ```
 
 二进制未加入 PATH 时使用 `target/debug/maka`。Linux/macOS 使用 Unix socket,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 07/29: fix(runtime-host): retain work ownership through cooperative pauses

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit cb3a3ef378e80f1661bb3c366abb0768e086b8b2
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 14:50:19 2026 +0800

    fix(runtime-host): retain work ownership through cooperative pauses
    
    Resolve frozen control owners only through same-Turn handoffs. Recover WorkHub cancellation atomically and keep queued messages behind unclaimed seals until cancellation releases them.
    
    Generated-by: Codex
---
 crates/event-log/src/handoff.rs                    |  32 +-
 crates/event-log/src/handoff/cancel.rs             | 174 ++++++---
 crates/event-log/src/handoff/owner.rs              | 117 ++++++
 crates/event-log/src/turns.rs                      |  16 +
 crates/event-log/src/workhub/correction/resolve.rs |  22 +-
 crates/event-log/src/workhub/stop.rs               |  37 +-
 crates/event-log/src/workhub/stop/admit.rs         |   4 +-
 crates/event-log/src/workhub/stop/subject.rs       |   3 +-
 .../tests/continuation/handoff_consumers.rs        | 197 +++++++++-
 crates/runtime-host/src/execution.rs               |  13 +-
 crates/runtime-host/src/execution/successor.rs     |  31 ++
 crates/runtime-host/src/execution/workhub.rs       |  55 +--
 crates/runtime-host/src/server/workhub/action.rs   |   2 +-
 .../src/server/workhub/action/correction.rs        |  17 +-
 .../runtime-host/src/server/workhub/action/stop.rs |   4 +
 crates/runtime-host/tests/integration/handoff.rs   | 429 +++++++++++++--------
 16 files changed, 839 insertions(+), 314 deletions(-)

diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
index 897c1307ac..f3bdf50ef1 100644
--- a/crates/event-log/src/handoff.rs
+++ b/crates/event-log/src/handoff.rs
@@ -23,8 +23,21 @@ use sqlx::{Connection, SqliteConnection};
 
 mod budget;
 mod cancel;
+mod owner;
+pub(crate) use cancel::apply as cancel;
+pub(crate) use owner::read as owner;
 
 impl EventLog {
+    /// Ordinary queued work cannot cross an unclaimed cooperative seal.
+    pub async fn has_pending_handoff(&self, session: &str) -> Result<bool, StoreError> {
+        self.validate_root()?;
+        crate::sessions::validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |tx| Box::pin(async move { Ok(reservation(tx, &session).await?.is_some()) }))
+            .await
+    }
+
     /// Eligibility only. The source still owns execution until its seal commits.
     pub async fn check_handoff(
         &self,
@@ -81,12 +94,7 @@ pub(crate) async fn validate(
         }
         // Admission permits one physical owner per Session. A paused owner
         // blocks the next opening, so only the latest opening can reserve it.
-        let reserved: Option<String> = sqlx::query_scalar(
-            "SELECT pause.invocation_id FROM runtime_events pause
-             WHERE pause.kind='invocation_ended' AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
-             AND pause.invocation_id=(SELECT invocation_id FROM runtime_events WHERE kind='invocation_opened'
-               AND json_extract(event_json,'$.invocation.session_id')=?1 ORDER BY sequence DESC LIMIT 1)",
-        ).bind(&event.invocation.session_id).fetch_optional(&mut *tx).await?;
+        let reserved = reservation(tx, &event.invocation.session_id).await?;
         if let InvocationInput::Handoff { claim, pause } = input {
             if reserved.as_deref() != Some(&claim.source.invocation.invocation_id) {
                 return Err(invalid(
@@ -231,6 +239,18 @@ pub(crate) async fn validate(
     Ok(())
 }
 
+async fn reservation(
+    tx: &mut SqliteConnection,
+    session: &str,
+) -> Result<Option<String>, StoreError> {
+    Ok(sqlx::query_scalar(
+        "SELECT pause.invocation_id FROM runtime_events pause
+         WHERE pause.kind='invocation_ended' AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
+         AND pause.invocation_id=(SELECT invocation_id FROM runtime_events WHERE kind='invocation_opened'
+           AND json_extract(event_json,'$.invocation.session_id')=? ORDER BY sequence DESC LIMIT 1)"
+    ).bind(session).fetch_optional(tx).await?)
+}
+
 async fn reservation_conflict(
     tx: &mut SqliteConnection,
     run: &str,
diff --git a/crates/event-log/src/handoff/cancel.rs b/crates/event-log/src/handoff/cancel.rs
index b6d71bc707..878e52c19c 100644
--- a/crates/event-log/src/handoff/cancel.rs
+++ b/crates/event-log/src/handoff/cancel.rs
@@ -25,7 +25,7 @@ use maka_runtime::{
         RuntimeEvent,
     },
 };
-use sqlx::Connection;
+use sqlx::{Connection, SqliteConnection};
 
 impl EventLog {
     /// Settle an unclaimed seal without loading a provider or replaying effects.
@@ -47,70 +47,118 @@ impl EventLog {
         }
         let source = source.clone();
         let commits = self.commits.clone();
-        self.connection.run(move |connection| Box::pin(async move {
-            let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
-            let prefix = crate::run_prefix::read(
-                &mut tx, &source.session_id, &source.run_id, &source.invocation_id,
-                i64::MAX, MAX_SOURCE_EVENTS, MAX_SOURCE_BYTES,
-            ).await?;
-            if prefix.invocation != source {
-                return Err(super::invalid("handoff cancellation source changed"));
-            }
-            let Some(seal) = prefix.events.last() else {
-                return Err(super::invalid("handoff cancellation source is missing"));
-            };
-            let Fact::InvocationEnded { outcome: InvocationOutcome::HandoffPaused { pause } } = &seal.event.fact else {
-                return Err(super::invalid("handoff cancellation requires a sealed pause"));
-            };
-            let claimed: bool = sqlx::query_scalar(
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    let (boundary, sequence) = apply(&mut tx, &source, cause).await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    if let Some(sequence) = sequence {
+                        commits.send_replace(sequence);
+                    }
+                    Ok(boundary)
+                })
+            })
+            .await
+    }
+}
+
+pub(crate) async fn apply(
+    tx: &mut SqliteConnection,
+    source: &Invocation,
+    cause: CancellationCause,
+) -> Result<(TurnBoundary, Option<u64>), StoreError> {
+    let prefix = crate::run_prefix::read(
+        tx,
+        &source.session_id,
+        &source.run_id,
+        &source.invocation_id,
+        i64::MAX,
+        MAX_SOURCE_EVENTS,
+        MAX_SOURCE_BYTES,
+    )
+    .await?;
+    if prefix.invocation != *source {
+        return Err(super::invalid("handoff cancellation source changed"));
+    }
+    let Some(seal) = prefix.events.last() else {
+        return Err(super::invalid("handoff cancellation source is missing"));
+    };
+    let Fact::InvocationEnded {
+        outcome: InvocationOutcome::HandoffPaused { pause },
+    } = &seal.event.fact
+    else {
+        return Err(super::invalid(
+            "handoff cancellation requires a sealed pause",
+        ));
+    };
+    let claimed: bool = sqlx::query_scalar(
                 "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened')"
             ).bind(&pause.intent.successor_invocation_id).fetch_one(&mut *tx).await?;
-            if claimed {
-                return crate::turns::read(&mut tx, &source.session_id, Some(&source.turn_id)).await?
-                    .ok_or_else(|| super::invalid("claimed handoff has no Turn"));
-            }
-            let opening = prefix.events.first().expect("checked nonempty prefix");
-            let Fact::InvocationOpened { input, configuration: Some(configuration) } = &opening.event.fact else {
-                return Err(super::invalid("handoff source has no admitted configuration"));
-            };
-            let base = match input {
-                InvocationInput::Message { .. } => {
-                    crate::context::frozen::fresh_base(&mut tx, &source, opening.sequence).await?
-                }
-                InvocationInput::Handoff { claim, .. } | InvocationInput::Continuation { claim, .. } => claim.base.clone(),
-                _ => return Err(super::invalid("handoff source is not a model Run")),
-            };
-            let invocation = pause.intent.successor(&source);
-            let opening = RuntimeEvent::new(invocation.clone(), Fact::InvocationOpened {
-                configuration: Some(configuration.clone()),
-                input: InvocationInput::Handoff {
-                    pause: Box::new(pause.clone()),
-                    claim: Box::new(ContinuationClaim {
-                        id: pause.intent.claim_id.clone(),
-                        source: RunBoundary { invocation: source, high_water: prefix.high_water, digest: prefix.digest },
-                        base,
-                        replay: pause.execution.replay.clone(),
-                    }),
+    if claimed {
+        return Ok((super::owner(tx, source).await?, None));
+    }
+    let opening = prefix.events.first().expect("checked nonempty prefix");
+    let Fact::InvocationOpened {
+        input,
+        configuration: Some(configuration),
+    } = &opening.event.fact
+    else {
+        return Err(super::invalid(
+            "handoff source has no admitted configuration",
+        ));
+    };
+    let base = match input {
+        InvocationInput::Message { .. } => {
+            crate::context::frozen::fresh_base(tx, source, opening.sequence).await?
+        }
+        InvocationInput::Handoff { claim, .. } | InvocationInput::Continuation { claim, .. } => {
+            claim.base.clone()
+        }
+        _ => return Err(super::invalid("handoff source is not a model Run")),
+    };
+    let invocation = pause.intent.successor(source);
+    let opening = RuntimeEvent::new(
+        invocation.clone(),
+        Fact::InvocationOpened {
+            configuration: Some(configuration.clone()),
+            input: InvocationInput::Handoff {
+                pause: Box::new(pause.clone()),
+                claim: Box::new(ContinuationClaim {
+                    id: pause.intent.claim_id.clone(),
+                    source: RunBoundary {
+                        invocation: source.clone(),
+                        high_water: prefix.high_water,
+                        digest: prefix.digest,
+                    },
+                    base,
+                    replay: pause.execution.replay.clone(),
+                }),
+            },
+        },
+    );
+    let writes = [
+        EventWrite::plain(opening.clone()),
+        EventWrite::plain(RuntimeEvent::new(
+            invocation,
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::Cancelled {
+                    source: cause.source(),
                 },
-            });
-            let writes = [
-                EventWrite::plain(opening.clone()),
-                EventWrite::plain(RuntimeEvent::new(invocation, Fact::InvocationEnded {
-                    outcome: InvocationOutcome::Cancelled { source: cause.source() },
-                })),
-            ].into_iter().collect::<Result<Vec<_>, _>>().map_err(|e| super::invalid(&e.to_string()))?;
-            let mut last = 0;
-            for write in &writes {
-                last = match EventLog::append_in_transaction(&mut tx, write).await? {
-                    crate::append::AppendResult::Existing(sequence)
-                    | crate::append::AppendResult::Inserted(sequence) => sequence,
-                };
-            }
-            crate::context::validate_batch(&mut tx, &writes).await?;
-            let boundary = crate::turns::project(&mut tx, opening).await?;
-            tx.commit().await.map_err(StoreError::CommitUnknown)?;
-            commits.send_replace(last);
-            Ok(boundary)
-        })).await
+            },
+        )),
+    ]
+    .into_iter()
+    .collect::<Result<Vec<_>, _>>()
+    .map_err(|e| super::invalid(&e.to_string()))?;
+    let mut last = 0;
+    for write in &writes {
+        last = match EventLog::append_in_transaction(tx, write).await? {
+            crate::append::AppendResult::Existing(sequence)
+            | crate::append::AppendResult::Inserted(sequence) => sequence,
+        };
     }
+    crate::context::validate_batch(tx, &writes).await?;
+    let boundary = crate::turns::project(tx, opening).await?;
+    Ok((boundary, Some(last)))
 }
diff --git a/crates/event-log/src/handoff/owner.rs b/crates/event-log/src/handoff/owner.rs
new file mode 100644
index 0000000000..dfb2f6efe7
--- /dev/null
+++ b/crates/event-log/src/handoff/owner.rs
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+use crate::{
+    EventLog, StoreError,
+    turns::{InvocationState, TurnBoundary},
+};
+use maka_runtime::{
+    continuation::MAX_ANCESTRY,
+    event::{Fact, Invocation, InvocationInput, InvocationOutcome, RuntimeEvent},
+};
+use sqlx::{Connection, SqliteConnection};
+
+impl EventLog {
+    /// Resolve one frozen owner through cooperative handoffs, never manual resume.
+    pub async fn handoff_owner(&self, source: &Invocation) -> Result<TurnBoundary, StoreError> {
+        self.validate_root()?;
+        let source = source.clone();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    read(&mut tx, &source).await
+                })
+            })
+            .await
+    }
+}
+
+async fn opening(tx: &mut SqliteConnection, id: &str) -> Result<Option<RuntimeEvent>, StoreError> {
+    let json: Option<Option<String>> = sqlx::query_scalar(
+        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
+         FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened'",
+    )
+    .bind(id)
+    .fetch_optional(tx)
+    .await?;
+    json.map(|json| {
+        Ok(serde_json::from_str(
+            &json.ok_or(StoreError::PrefixTooLarge)?,
+        )?)
+    })
+    .transpose()
+}
+
+/// Canonical append authenticates each immutable prefix. Control reads only
+/// openings, seals and edge identity, keeping model-history size out of Stop.
+pub(crate) async fn read(
+    tx: &mut SqliteConnection,
+    source: &Invocation,
+) -> Result<TurnBoundary, StoreError> {
+    let mut current = opening(tx, &source.invocation_id)
+        .await?
+        .ok_or_else(|| super::invalid("handoff owner opening is missing"))?;
+    if current.invocation != *source {
+        return Err(super::invalid("handoff owner identity changed"));
+    }
+    for _ in 0..=MAX_ANCESTRY {
+        let boundary = crate::turns::project(tx, current).await?;
+        let InvocationState::Ended {
+            outcome: InvocationOutcome::HandoffPaused { pause },
+            ..
+        } = &boundary.state
+        else {
+            return Ok(boundary);
+        };
+        let Some(next) = opening(tx, &pause.intent.successor_invocation_id).await? else {
+            return Ok(boundary);
+        };
+        let Fact::InvocationOpened {
+            input:
+                InvocationInput::Handoff {
+                    claim,
+                    pause: inherited,
+                },
+            ..
+        } = &next.fact
+        else {
+            return Err(super::invalid("handoff successor has no canonical claim"));
+        };
+        pause
+            .validate_claim(claim, &next.invocation)
+            .map_err(super::invalid)?;
+        if inherited.as_ref() != pause || claim.source.invocation != boundary.invocation {
+            return Err(super::invalid(
+                "handoff successor changed its sealed source",
+            ));
+        }
+        let count: i64 =
+            sqlx::query_scalar("SELECT COUNT(*) FROM runtime_events WHERE invocation_id=?")
+                .bind(&boundary.invocation.invocation_id)
+                .fetch_one(&mut *tx)
+                .await?;
+        if u64::try_from(count).ok() != Some(claim.source.high_water) {
+            return Err(super::invalid(
+                "handoff successor changed its source boundary",
+            ));
+        }
+        current = next;
+    }
+    Err(super::invalid("handoff owner exceeds ancestry limit"))
+}
diff --git a/crates/event-log/src/turns.rs b/crates/event-log/src/turns.rs
index 2fb759c01c..b315629fea 100644
--- a/crates/event-log/src/turns.rs
+++ b/crates/event-log/src/turns.rs
@@ -61,6 +61,22 @@ pub enum InvocationState {
     },
 }
 
+impl InvocationState {
+    /// A sealed physical Run still owns unfinished logical work.
+    pub fn terminal_outcome(&self) -> Option<&InvocationOutcome> {
+        match self {
+            Self::Ended {
+                outcome: InvocationOutcome::HandoffPaused { .. },
+                ..
+            }
+            | Self::Admitted
+            | Self::Running
+            | Self::WaitingForUser => None,
+            Self::Ended { outcome, .. } => Some(outcome),
+        }
+    }
+}
+
 impl EventLog {
     /// Exact physical Run, including an earlier Run of the same logical Turn.
     pub async fn run_boundary(
diff --git a/crates/event-log/src/workhub/correction/resolve.rs b/crates/event-log/src/workhub/correction/resolve.rs
index 893a65c410..152067ee11 100644
--- a/crates/event-log/src/workhub/correction/resolve.rs
+++ b/crates/event-log/src/workhub/correction/resolve.rs
@@ -19,7 +19,6 @@
 
 use super::{Assignment, CorrectionIntent, Delegation, StoreError, control, invalid};
 use maka_runtime::{
-    event::{Fact, RuntimeEvent},
     session_event::{SessionEvent, SessionFact},
     workhub::DelegationKind,
 };
@@ -32,20 +31,13 @@ pub(super) async fn retired(
     let Some(owner) = &intent.owner else {
         return Ok(());
     };
-    let json: Option<Option<String>> = sqlx::query_scalar(
-        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
-         FROM runtime_events WHERE invocation_id = ? AND kind = 'invocation_ended'",
-    )
-    .bind(&owner.invocation_id)
-    .fetch_optional(tx)
-    .await?;
-    let terminal: RuntimeEvent = serde_json::from_str(
-        &json
-            .ok_or(StoreError::SessionBusy)?
-            .ok_or(StoreError::PrefixTooLarge)?,
-    )?;
-    if terminal.invocation != *owner || !matches!(terminal.fact, Fact::InvocationEnded { .. }) {
-        return Err(invalid("WorkHub correction retirement owner changed"));
+    if crate::handoff::owner(tx, owner)
+        .await?
+        .state
+        .terminal_outcome()
+        .is_none()
+    {
+        return Err(StoreError::SessionBusy);
     }
     Ok(())
 }
diff --git a/crates/event-log/src/workhub/stop.rs b/crates/event-log/src/workhub/stop.rs
index e209c91e3d..2d2ebf2569 100644
--- a/crates/event-log/src/workhub/stop.rs
+++ b/crates/event-log/src/workhub/stop.rs
@@ -104,6 +104,18 @@ impl EventLog {
                 let mut last = None;
                 for action in actions {
                     let action = ActionId::new(action).map_err(invalid)?;
+                    let record = read(&mut tx, &action).await?
+                        .ok_or_else(|| invalid("WorkHub stop disappeared during recovery"))?;
+                    if let Some(owner) = &record.intent.owner {
+                        let boundary = crate::handoff::owner(&mut tx, owner).await?;
+                        if matches!(boundary.state, crate::turns::InvocationState::Ended {
+                            outcome: maka_runtime::event::InvocationOutcome::HandoffPaused { .. }, ..
+                        }) {
+                            crate::handoff::cancel(&mut tx, &boundary.invocation,
+                                maka_runtime::event::CancellationCause::WorkhubStop { action_id: action.clone() }
+                            ).await?;
+                        }
+                    }
                     let (_, sequence) = resolve(&mut tx, &action).await?;
                     last = sequence.or(last);
                     recovered += 1;
@@ -163,28 +175,15 @@ async fn resolve(
         .owner
         .as_ref()
         .ok_or_else(|| invalid("WorkHub stop owner is missing"))?;
-    let terminal: Option<Option<String>> = sqlx::query_scalar(
-        "SELECT CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
-         FROM runtime_events WHERE invocation_id = ? AND kind = 'invocation_ended'",
-    )
-    .bind(&owner.invocation_id)
-    .fetch_optional(&mut *tx)
-    .await?;
-    let terminal: maka_runtime::event::RuntimeEvent = serde_json::from_str(
-        &terminal
-            .ok_or(StoreError::SessionBusy)?
-            .ok_or(StoreError::PrefixTooLarge)?,
-    )?;
-    if terminal.invocation != *owner {
-        return Err(invalid("WorkHub stop terminal owner changed"));
-    }
-    let maka_runtime::event::Fact::InvocationEnded { outcome } = terminal.fact else {
-        return Err(invalid("WorkHub stop owner has no terminal"));
-    };
+    let boundary = crate::handoff::owner(tx, owner).await?;
+    let outcome = boundary
+        .state
+        .terminal_outcome()
+        .ok_or(StoreError::SessionBusy)?;
     let resolution = if matches!(
         outcome,
         maka_runtime::event::InvocationOutcome::Cancelled { source }
-            if source == maka_runtime::workhub::stop_abort_source(action)
+            if source == &maka_runtime::workhub::stop_abort_source(action)
     ) {
         StopResolution::StopDelivered {
             target_turn_id: owner.turn_id.clone(),
diff --git a/crates/event-log/src/workhub/stop/admit.rs b/crates/event-log/src/workhub/stop/admit.rs
index 5d9b36d8e4..5ed995ab43 100644
--- a/crates/event-log/src/workhub/stop/admit.rs
+++ b/crates/event-log/src/workhub/stop/admit.rs
@@ -18,7 +18,7 @@
  */
 
 use super::{StopIntent, StopRecord, StopRequest, StopResolution, invalid, subject};
-use crate::{StoreError, message_resolution::MessageExecution, turns::InvocationState};
+use crate::{StoreError, message_resolution::MessageExecution};
 use maka_runtime::session_event::{SessionEvent, SessionFact};
 use sqlx::SqliteConnection;
 
@@ -83,7 +83,7 @@ pub(super) async fn apply(
             target_turn_id: None,
         }),
         MessageExecution::Owned(boundary) => {
-            let terminal = matches!(boundary.state, InvocationState::Ended { .. });
+            let terminal = boundary.state.terminal_outcome().is_some();
             intent.owner = Some(boundary.invocation.clone());
             terminal.then_some(StopResolution::AlreadyTerminal {
                 target_turn_id: Some(boundary.invocation.turn_id),
diff --git a/crates/event-log/src/workhub/stop/subject.rs b/crates/event-log/src/workhub/stop/subject.rs
index 76230ab607..65f42c1a8f 100644
--- a/crates/event-log/src/workhub/stop/subject.rs
+++ b/crates/event-log/src/workhub/stop/subject.rs
@@ -20,7 +20,6 @@
 use crate::{
     StoreError,
     message_resolution::{MessageExecution, owner},
-    turns::InvocationState,
 };
 use maka_runtime::workhub::ActionId;
 use maka_runtime::workhub::Delegation;
@@ -65,7 +64,7 @@ pub(super) async fn select(
             let retired = match &work {
                 MessageExecution::Cancelled => true,
                 MessageExecution::Owned(boundary) | MessageExecution::Shared(boundary) => {
-                    matches!(boundary.state, InvocationState::Ended { .. })
+                    boundary.state.terminal_outcome().is_some()
                 }
                 MessageExecution::Pending | MessageExecution::Missing => false,
             };
diff --git a/crates/event-log/tests/continuation/handoff_consumers.rs b/crates/event-log/tests/continuation/handoff_consumers.rs
index 957d116813..8d3bce2a44 100644
--- a/crates/event-log/tests/continuation/handoff_consumers.rs
+++ b/crates/event-log/tests/continuation/handoff_consumers.rs
@@ -35,13 +35,12 @@ use maka_runtime::{
 };
 use serde_json::{Value, json};
 
-async fn successor(log: &EventLog, source: &RuntimeEvent) -> RuntimeEvent {
+async fn seal(
+    log: &EventLog,
+    source: &RuntimeEvent,
+) -> (maka_runtime::handoff::HandoffPause, SessionBase) {
     use maka_runtime::handoff::{HandoffIntent, HandoffPause};
-    let Fact::InvocationOpened {
-        configuration,
-        input,
-    } = &source.fact
-    else {
+    let Fact::InvocationOpened { input, .. } = &source.fact else {
         unreachable!()
     };
     let base = if let Some(claim) = input.inherited_claim() {
@@ -85,6 +84,14 @@ async fn successor(log: &EventLog, source: &RuntimeEvent) -> RuntimeEvent {
         ),
     )
     .await;
+    (pause, base)
+}
+
+async fn successor(log: &EventLog, source: &RuntimeEvent) -> RuntimeEvent {
+    let (pause, base) = seal(log, source).await;
+    let Fact::InvocationOpened { configuration, .. } = &source.fact else {
+        unreachable!()
+    };
     let claim = claim(log, &pause.intent.claim_id, source, base).await;
     let next = RuntimeEvent::new(
         pause.intent.successor(&source.invocation),
@@ -382,7 +389,19 @@ async fn workhub_handoff_keeps_user_authority_and_recovers_correction_after_coor
         },
     );
     append(&log, &event).await;
-    assert_eq!(log.pending_messages("old").await.unwrap().len(), 1);
+    let pending = log.pending_messages("old").await.unwrap().remove(0);
+    let mut target = opening("target", None);
+    target.invocation = delegation.target.clone();
+    let Fact::InvocationOpened { input, .. } = &mut target.fact else {
+        unreachable!()
+    };
+    *input = InvocationInput::Message {
+        content: pending.source.message.content.clone(),
+        source_messages: vec![pending.source],
+        request_fingerprint: None,
+        skill_invocation: None,
+    };
+    append(&log, &target).await;
     let request = CorrectionRequest {
         action_id: "correction".parse().unwrap(),
         request_fingerprint: content_digest(b"correction"),
@@ -408,7 +427,9 @@ async fn workhub_handoff_keeps_user_authority_and_recovers_correction_after_coor
         .request_workhub_correction(request.clone(), Some(1), None)
         .await
         .unwrap();
-    assert!(intent.intent.owner.is_none());
+    assert_eq!(intent.intent.owner.as_ref(), Some(&target.invocation));
+    let tip = successor(&log, &target).await;
+    seal(&log, &tip).await;
     assert!(log.pending_messages("old").await.unwrap().is_empty());
     close(&log, &coordinator).await;
     let rows = transcript(&log, COORDINATION_SESSION_ID).await;
@@ -432,6 +453,22 @@ async fn workhub_handoff_keeps_user_authority_and_recovers_correction_after_coor
         description: Some(DelegationDescription::Existing { name: "new".into() }),
         ..delegation
     };
+    assert!(
+        matches!(
+            log.finish_workhub_correction::<Value>(&request.action_id, replacement.clone(), None)
+                .await,
+            Err(maka_event_log::StoreError::SessionBusy)
+        ),
+        "a physical seal is not retirement of delegated work"
+    );
+    log.cancel_handoff(
+        &tip.invocation,
+        maka_runtime::event::CancellationCause::WorkhubCorrection {
+            action_id: request.action_id.clone(),
+        },
+    )
+    .await
+    .unwrap();
     let resolved = log
         .finish_workhub_correction::<Value>(&request.action_id, replacement.clone(), None)
         .await
@@ -488,6 +525,150 @@ async fn workhub_handoff_keeps_user_authority_and_recovers_correction_after_coor
     log.close().await.unwrap();
 }
 
+#[tokio::test]
+async fn stop_recovers_a_frozen_owner_through_handoffs_but_never_manual_resume() {
+    use maka_runtime::workhub::{StopOutcome, StopRequest};
+    for admitted_before_handoff in [false, true] {
+        let directory = tempfile::tempdir().unwrap();
+        let path = directory.path().join("stop.sqlite");
+        let log = EventLog::open(&path).await.unwrap();
+        for session in [COORDINATION_SESSION_ID, "session"] {
+            log.create_session(session, "fixture", &json!({"name":session}), 1)
+                .await
+                .unwrap();
+        }
+        let mut coordinator = opening("coordinator", None);
+        coordinator.invocation.session_id = COORDINATION_SESSION_ID.into();
+        append(&log, &coordinator).await;
+        let mut target = opening("target", None);
+        let delegation = Delegation {
+            kind: Default::default(),
+            description: None,
+            delivery: Default::default(),
+            action_id: "assignment".parse().unwrap(),
+            request_fingerprint: content_digest(b"assignment"),
+            source_message_event_id: coordinator.id.clone(),
+            target: target.invocation.clone(),
+            target_revision: 1,
+            delegation_text: "finish this work".into(),
+        };
+        append(
+            &log,
+            &RuntimeEvent::new(
+                coordinator.invocation.clone(),
+                Fact::WorkhubDelegated {
+                    delegation: Box::new(delegation),
+                },
+            ),
+        )
+        .await;
+        let pending = log.pending_messages("session").await.unwrap().remove(0);
+        let Fact::InvocationOpened { input, .. } = &mut target.fact else {
+            unreachable!()
+        };
+        *input = InvocationInput::Message {
+            content: pending.source.message.content.clone(),
+            source_messages: vec![pending.source],
+            request_fingerprint: None,
+            skill_invocation: None,
+        };
+        append(&log, &target).await;
+        let request = StopRequest {
+            action_id: "stop".parse().unwrap(),
+            request_fingerprint: content_digest(b"stop"),
+            source: coordinator.invocation.clone(),
+            target_session_id: "session".into(),
+        };
+        if admitted_before_handoff {
+            log.request_workhub_stop(request.clone()).await.unwrap();
+        }
+        let first = successor(&log, &target).await;
+        let tip = successor(&log, &first).await;
+        seal(&log, &tip).await;
+        let intent = log.request_workhub_stop(request.clone()).await.unwrap();
+        assert!(intent.resolution.is_none());
+        assert_eq!(
+            intent.intent.owner.as_ref(),
+            Some(if admitted_before_handoff {
+                &target.invocation
+            } else {
+                &tip.invocation
+            })
+        );
+        assert!(matches!(
+            log.resolve_workhub_stop(&request.action_id).await,
+            Err(maka_event_log::StoreError::SessionBusy)
+        ));
+        assert!(log.has_pending_handoff("session").await.unwrap());
+        let sealed = log.prefix(100, 128 * 1024).await.unwrap();
+        close(&log, &coordinator).await;
+        log.close().await.unwrap();
+        let log = EventLog::open(&path).await.unwrap();
+        let before_recovery = log.prefix(100, 128 * 1024).await.unwrap();
+        let database = rusqlite::Connection::open(&path).unwrap();
+        database.execute_batch("CREATE TRIGGER reject_stop_recovery BEFORE INSERT ON event_log
+            WHEN NEW.kind='workhub_stop_resolved' BEGIN SELECT RAISE(ABORT,'resolution failed'); END;").unwrap();
+        assert!(log.recover_workhub_stops().await.is_err());
+        assert_eq!(
+            log.prefix(100, 128 * 1024).await.unwrap().digest,
+            before_recovery.digest,
+            "failed receipt publication must roll back its cancellation and claim"
+        );
+        database
+            .execute_batch("DROP TRIGGER reject_stop_recovery;")
+            .unwrap();
+        drop(database);
+        assert_eq!(log.recover_workhub_stops().await.unwrap(), 1);
+        let resolved = log.workhub_stop(&request.action_id).await.unwrap().unwrap();
+        assert_eq!(
+            resolved.intent, intent.intent,
+            "recovery must not rewrite the frozen physical owner"
+        );
+        assert_eq!(
+            resolved.resolution.as_ref().unwrap().outcome(),
+            StopOutcome::StopDelivered
+        );
+        let cancelled = log.handoff_owner(&target.invocation).await.unwrap();
+        assert!(
+            matches!(cancelled.state.terminal_outcome(), Some(InvocationOutcome::Cancelled { source })
+            if source == &maka_runtime::workhub::stop_abort_source(&request.action_id))
+        );
+        assert!(!log.has_pending_handoff("session").await.unwrap());
+        let after = log.prefix(100, 128 * 1024).await.unwrap();
+        assert_eq!(
+            serde_json::to_value(&after.events[..sealed.events.len()]).unwrap(),
+            serde_json::to_value(&sealed.events).unwrap()
+        );
+        let source = after
+            .events
+            .iter()
+            .find(|event| {
+                event.event.invocation == cancelled.invocation
+                    && matches!(event.event.fact, Fact::InvocationOpened { .. })
+            })
+            .unwrap()
+            .event
+            .clone();
+        let base = cancelled.input.inherited_claim().unwrap().base.clone();
+        let manual = opening("manual", Some(claim(&log, "manual", &source, base).await));
+        append(&log, &manual).await;
+        assert_eq!(
+            log.handoff_owner(&target.invocation)
+                .await
+                .unwrap()
+                .invocation,
+            cancelled.invocation
+        );
+        assert_eq!(
+            log.resolve_workhub_stop(&request.action_id).await.unwrap(),
+            resolved
+        );
+        assert_eq!(log.recover_workhub_stops().await.unwrap(), 0);
+        close(&log, &manual).await;
+        log.close().await.unwrap();
+    }
+}
+
 async fn transcript(log: &EventLog, session: &str) -> Vec<Value> {
     let through = *log.subscribe_commits().borrow();
     while !log.prepare_transcript(session, through, 32).await.unwrap() {}
diff --git a/crates/runtime-host/src/execution.rs b/crates/runtime-host/src/execution.rs
index 9078f25bea..a66cf27f9f 100644
--- a/crates/runtime-host/src/execution.rs
+++ b/crates/runtime-host/src/execution.rs
@@ -178,8 +178,8 @@ impl Executions {
             .ok_or_else(|| failure(Code::NotFound, "Turn does not exist"))
     }
 
-    pub(crate) async fn stop(&self, input: TurnStopInput) -> Result<TurnSnapshot> {
-        let _admission = self.lock_admission().await;
+    pub(crate) async fn stop(self: &Arc<Self>, input: TurnStopInput) -> Result<TurnSnapshot> {
+        let mut admission = Some(self.lock_admission().await);
         let boundary = self
             .log
             .turn_boundary(&input.session_id, &input.turn_id)
@@ -189,13 +189,14 @@ impl Executions {
         if boundary.root_invocation().run_id != input.run_id {
             return Err(failure(Code::OperationConflict, "Run identity changed"));
         }
-        let boundary = if matches!(
+        let paused = matches!(
             boundary.state,
             maka_event_log::turns::InvocationState::Ended {
                 outcome: maka_runtime::event::InvocationOutcome::HandoffPaused { .. },
                 ..
             }
-        ) {
+        );
+        let boundary = if paused {
             self.log
                 .cancel_handoff(&boundary.invocation, maka_agent::CancellationCause::Runtime)
                 .await
@@ -222,6 +223,10 @@ impl Executions {
             self.interactions.stop_run(&invocation).await?;
             cancellation.cancel();
         }
+        if paused {
+            self.dispatch_pending(&input.session_id, &mut admission)
+                .await?;
+        }
         self.query(TurnQueryInput {
             session_id: input.session_id,
             turn_id: input.turn_id,
diff --git a/crates/runtime-host/src/execution/successor.rs b/crates/runtime-host/src/execution/successor.rs
index 55e7755535..877f89ec96 100644
--- a/crates/runtime-host/src/execution/successor.rs
+++ b/crates/runtime-host/src/execution/successor.rs
@@ -31,6 +31,29 @@ use tokio_util::sync::CancellationToken;
 use uuid::Uuid;
 
 impl Executions {
+    /// Dispatch accepted pending work unless an existing worker owns its cleanup.
+    pub(crate) async fn dispatch_pending<'a>(
+        self: &'a Arc<Self>,
+        session: &str,
+        admission: &mut Option<tokio::sync::MutexGuard<'a, ()>>,
+    ) -> Result<()> {
+        if self.has_active_session(session) {
+            return Ok(());
+        }
+        match self.next_message(session, admission, None).await {
+            Ok(Some(running)) => self.track(running),
+            Ok(None) => {}
+            Err(error) => {
+                self.begin_drain();
+                return Err(failure(
+                    Code::HostDraining,
+                    &format!("Pending work startup failed: {}", error.message),
+                ));
+            }
+        }
+        Ok(())
+    }
+
     /// One worker owns the whole chain; root ownership survives cleanup and
     /// the gate-protected handoff. Never await effects while holding the gate.
     pub(super) async fn drive(self: Arc<Self>, mut running: RunningInvocation) {
@@ -104,6 +127,14 @@ impl Executions {
             if queue.is_empty() {
                 return Ok(None);
             }
+            if self
+                .log
+                .has_pending_handoff(session)
+                .await
+                .map_err(internal)?
+            {
+                return Ok(None);
+            }
             let Some((basis, candidate)) = environment.take() else {
                 let record = self
                     .log
diff --git a/crates/runtime-host/src/execution/workhub.rs b/crates/runtime-host/src/execution/workhub.rs
index 3e6508ee78..bef4f738a7 100644
--- a/crates/runtime-host/src/execution/workhub.rs
+++ b/crates/runtime-host/src/execution/workhub.rs
@@ -62,6 +62,34 @@ impl Executions {
         owner: &Invocation,
         cause: maka_agent::CancellationCause,
     ) -> Result<Option<tokio_util::sync::CancellationToken>> {
+        let stored = |error: maka_event_log::StoreError| {
+            if matches!(
+                error,
+                maka_event_log::StoreError::CommitUnknown(_)
+                    | maka_event_log::StoreError::OperationUnknown
+            ) {
+                self.begin_drain();
+                failure(Code::CommitOutcomeUnknown, &error.to_string())
+            } else {
+                internal(error)
+            }
+        };
+        let boundary = self.log.handoff_owner(owner).await.map_err(stored)?;
+        let boundary = if matches!(
+            boundary.state,
+            maka_event_log::turns::InvocationState::Ended {
+                outcome: maka_runtime::event::InvocationOutcome::HandoffPaused { .. },
+                ..
+            }
+        ) {
+            self.log
+                .cancel_handoff(&boundary.invocation, cause.clone())
+                .await
+                .map_err(stored)?
+        } else {
+            boundary
+        };
+        let owner = &boundary.invocation;
         let active = self
             .active
             .lock()
@@ -109,33 +137,6 @@ impl Executions {
         Ok(boundary)
     }
 
-    /// The action already owns a durable pending root. Reuse normal recovery/delivery.
-    pub(crate) async fn dispatch_workhub_pending<'a>(
-        self: &'a Arc<Self>,
-        session: &str,
-        admission: &mut Option<tokio::sync::MutexGuard<'a, ()>>,
-    ) -> Result<()> {
-        // The existing worker owns steering consumption and terminal handoff.
-        if self.has_active_session(session) {
-            return Ok(());
-        }
-        match self.next_message(session, admission, None).await {
-            Ok(Some(running)) => self.track(running),
-            Ok(None) => {}
-            Err(error) => {
-                self.begin_drain();
-                return Err(failure(
-                    Code::HostDraining,
-                    &format!(
-                        "Delegation committed; target startup failed: {}",
-                        error.message
-                    ),
-                ));
-            }
-        }
-        Ok(())
-    }
-
     /// The WorkHub entry point holds the shared admission gate and proves the
     /// stable Session identity before calling this method.
     pub(crate) async fn answer_workhub(
diff --git a/crates/runtime-host/src/server/workhub/action.rs b/crates/runtime-host/src/server/workhub/action.rs
index b68b781927..0d6eac9044 100644
--- a/crates/runtime-host/src/server/workhub/action.rs
+++ b/crates/runtime-host/src/server/workhub/action.rs
@@ -250,7 +250,7 @@ async fn admit(
         });
     }
     host.executions
-        .dispatch_workhub_pending(target.id(), &mut admission)
+        .dispatch_pending(target.id(), &mut admission)
         .await?;
     Ok(result)
 }
diff --git a/crates/runtime-host/src/server/workhub/action/correction.rs b/crates/runtime-host/src/server/workhub/action/correction.rs
index 36b3271de1..b8fdbb6540 100644
--- a/crates/runtime-host/src/server/workhub/action/correction.rs
+++ b/crates/runtime-host/src/server/workhub/action/correction.rs
@@ -162,7 +162,12 @@ pub(super) async fn act(host: &Arc<Host>, input: ActInput) -> Result<ActResult,
     let record = finish(host, &record.intent.request.action_id).await?;
     if let Some(CorrectionResolution::Assigned(assigned)) = &record.resolution {
         host.executions
-            .dispatch_workhub_pending(&assigned.delegation.target.session_id, &mut admission)
+            .dispatch_pending(&assigned.delegation.target.session_id, &mut admission)
+            .await?;
+    }
+    if let Some(owner) = &record.intent.owner {
+        host.executions
+            .dispatch_pending(&owner.session_id, &mut admission)
             .await?;
     }
     receipt(record)
@@ -182,6 +187,16 @@ pub(in crate::server) async fn recover(host: &Arc<Host>) -> Result<(), Operation
             return Ok(());
         }
         for record in records {
+            if let Some(owner) = &record.intent.owner {
+                host.executions
+                    .retire_workhub_owner(
+                        owner,
+                        maka_agent::CancellationCause::WorkhubCorrection {
+                            action_id: record.intent.request.action_id.clone(),
+                        },
+                    )
+                    .await?;
+            }
             finish(host, &record.intent.request.action_id).await?;
         }
     }
diff --git a/crates/runtime-host/src/server/workhub/action/stop.rs b/crates/runtime-host/src/server/workhub/action/stop.rs
index f1ef4db50d..6c96e7de3d 100644
--- a/crates/runtime-host/src/server/workhub/action/stop.rs
+++ b/crates/runtime-host/src/server/workhub/action/stop.rs
@@ -97,6 +97,10 @@ pub(super) async fn act(host: &Arc<Host>, input: ActInput) -> Result<ActResult,
                 stored(host, error)
             }
         })?;
+    let mut admission = Some(host.executions.lock_admission().await);
+    host.executions
+        .dispatch_pending(&record.intent.request.target_session_id, &mut admission)
+        .await?;
     receipt(resolved)
 }
 
diff --git a/crates/runtime-host/tests/integration/handoff.rs b/crates/runtime-host/tests/integration/handoff.rs
index 5eeef215c6..2367e149f3 100644
--- a/crates/runtime-host/tests/integration/handoff.rs
+++ b/crates/runtime-host/tests/integration/handoff.rs
@@ -35,188 +35,285 @@ use tokio_util::sync::CancellationToken;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn stop_sealed_turn_uses_public_identity_without_provider_and_survives_restart() {
-    let fixture = ClientFixture::new("maka-handoff-stop-");
-    let log = fixture.log().await;
-    let cwd = fixture.workspace.to_string_lossy().into_owned();
-    let configuration = PreparedSession::new(
-        serde_json::from_value(json!({
-            "sessionId":"session", "workspace":{"kind":"host_path", "path":cwd},
-            "modelTarget":{"kind":"default"}
-        }))
-        .unwrap(),
-    )
-    .unwrap()
-    .bind(
-        WorkspaceProjection {
-            target: WorkspaceTarget::HostPath { path: cwd.clone() },
-            host_cwd: cwd,
-        },
-        SessionModel {
+    for queued in [false, true] {
+        let fixture = ClientFixture::new("maka-handoff-stop-");
+        let unavailable = SessionModel {
             connection_id: "removed-provider".into(),
             connection_slug: "removed".into(),
             model: "unavailable".into(),
-        },
-        PermissionMode::Explore,
-        ToolMode::Direct,
-    );
-    log.create_session("session", "fixture", &configuration, 1)
-        .await
-        .unwrap();
-    let invocation = Invocation {
-        session_id: "session".into(),
-        turn_id: "turn".into(),
-        run_id: "public-run".into(),
-        invocation_id: "source-invocation".into(),
-    };
-    let pause = HandoffPause {
-        intent: HandoffIntent {
-            handoff_id: "handoff".into(),
-            host_epoch: "old-host".into(),
-            root_run_id: invocation.run_id.clone(),
-            successor_run_id: "physical-successor".into(),
-            successor_invocation_id: "successor-invocation".into(),
-            claim_id: "claim".into(),
-        },
-        remaining_steps: NonZeroU16::new(1).unwrap(),
-        execution: Box::new(HandoffExecution {
-            replay: ReplayEvidence {
-                version: REPLAY_VERSION,
-                digest: content_digest(b"admission"),
-                route_identity: content_digest(b"missing-provider"),
-            },
-            context: None,
-            provider_options: json!({}),
-            main_output_limit: None,
-            supports_vision: false,
-            tools: HandoffTools {
-                catalog_digest: content_digest(b"unavailable-tools"),
-                loaded: Default::default(),
-            },
-            compaction_attempted: false,
-            replay_base: None,
-        }),
-    };
-    for fact in [
-        Fact::InvocationOpened {
-            configuration: Some(configuration.invocation_configuration().await.unwrap()),
-            input: InvocationInput::Message {
-                content: "stop this task".into(),
-                request_fingerprint: None,
-                source_messages: Vec::new(),
-                skill_invocation: None,
+        };
+        let provider = if queued {
+            Some(super::support::message_recovery::Provider::start().await)
+        } else {
+            None
+        };
+        let model = if let Some(provider) = &provider {
+            super::support::message_recovery::configure(&fixture, &provider.base_url).await
+        } else {
+            unavailable.clone()
+        };
+        let log = fixture.log().await;
+        let cwd = fixture.workspace.to_string_lossy().into_owned();
+        let configuration = PreparedSession::new(
+            serde_json::from_value(json!({
+                "sessionId":"session", "workspace":{"kind":"host_path", "path":cwd},
+                "modelTarget":{"kind":"default"}
+            }))
+            .unwrap(),
+        )
+        .unwrap()
+        .bind(
+            WorkspaceProjection {
+                target: WorkspaceTarget::HostPath { path: cwd.clone() },
+                host_cwd: cwd,
             },
-        },
-        Fact::InvocationEnded {
-            outcome: InvocationOutcome::HandoffPaused { pause },
-        },
-    ] {
-        log.append(&EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap())
+            model,
+            PermissionMode::Explore,
+            ToolMode::Direct,
+        );
+        log.create_session("session", "fixture", &configuration, 1)
             .await
             .unwrap();
-    }
-    let sealed = log.prefix(100, 128 * 1024).await.unwrap();
-    log.close().await.unwrap();
-    let mut result = None;
-    let mut settled = None;
-    for reopened in [false, true] {
-        let host = Host::open(fixture.owner()).await.unwrap();
-        #[cfg(unix)]
-        let endpoint = fixture.workspace.parent().unwrap().join("handoff.sock");
-        #[cfg(windows)]
-        let endpoint =
-            std::path::PathBuf::from(format!(r"\\.\pipe\maka-handoff-{}", uuid::Uuid::new_v4()));
-        let cancel = CancellationToken::new();
-        let server = tokio::spawn(
-            LocalListener::bind(&endpoint)
-                .unwrap()
-                .serve(host.clone(), cancel.clone()),
-        );
-        let mut peer = Peer::new(host.clone(), "handoff-stop").await;
-        let turn = peer
-            .rpc(
-                "turn.query",
-                json!({"sessionId":"session", "turnId":"turn"}),
-            )
-            .await;
-        assert_eq!(turn["result"]["runId"], "public-run", "{turn}");
-        assert_eq!(
-            turn["result"]["status"],
-            if reopened { "cancelled" } else { "running" },
-            "{turn}"
-        );
-        let wrong = peer
-            .rpc(
-                "turn.stop",
-                json!({
-                    "sessionId":"session", "turnId":"turn", "runId":"physical-successor"
-                }),
-            )
-            .await;
-        assert_eq!(wrong["error"]["code"], "operation_conflict", "{wrong}");
-        let after_wrong = peer
-            .rpc(
-                "turn.query",
-                json!({"sessionId":"session", "turnId":"turn"}),
-            )
-            .await;
-        assert_eq!(
-            after_wrong, turn,
-            "wrong public identity must not claim or cancel the seal"
-        );
-        for _ in 0..2 {
-            let stopped = peer
+        let invocation = Invocation {
+            session_id: "session".into(),
+            turn_id: "turn".into(),
+            run_id: "public-run".into(),
+            invocation_id: "source-invocation".into(),
+        };
+        let pause = HandoffPause {
+            intent: HandoffIntent {
+                handoff_id: "handoff".into(),
+                host_epoch: "old-host".into(),
+                root_run_id: invocation.run_id.clone(),
+                successor_run_id: "physical-successor".into(),
+                successor_invocation_id: "successor-invocation".into(),
+                claim_id: "claim".into(),
+            },
+            remaining_steps: NonZeroU16::new(1).unwrap(),
+            execution: Box::new(HandoffExecution {
+                replay: ReplayEvidence {
+                    version: REPLAY_VERSION,
+                    digest: content_digest(b"admission"),
+                    route_identity: content_digest(b"missing-provider"),
+                },
+                context: None,
+                provider_options: json!({}),
+                main_output_limit: None,
+                supports_vision: false,
+                tools: HandoffTools {
+                    catalog_digest: content_digest(b"unavailable-tools"),
+                    loaded: Default::default(),
+                },
+                compaction_attempted: false,
+                replay_base: None,
+            }),
+        };
+        let mut admitted = configuration.invocation_configuration().await.unwrap();
+        admitted.model = Some(unavailable);
+        for (index, fact) in [
+            Fact::InvocationOpened {
+                configuration: Some(admitted),
+                input: InvocationInput::Message {
+                    content: "stop this task".into(),
+                    request_fingerprint: None,
+                    source_messages: Vec::new(),
+                    skill_invocation: None,
+                },
+            },
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::HandoffPaused { pause },
+            },
+        ]
+        .into_iter()
+        .enumerate()
+        {
+            log.append(&EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap())
+                .await
+                .unwrap();
+            if queued && index == 0 {
+                let content: maka_runtime::input::MessageInput = "followup work".into();
+                log.admit_message(
+                    maka_event_log::message_admissions::PendingMessageAdmission {
+                        invocation: invocation.clone(),
+                        steering_invocation: None,
+                        required_tools: Default::default(),
+                        admitted_at: 1,
+                        source: maka_runtime::message::RootSourceMessage {
+                            message: maka_runtime::input::DeliveredMessage {
+                                message_id: "followup".into(),
+                                submitted_content_digest: content.content_digest().unwrap(),
+                                content,
+                            },
+                            submitted_placement: maka_runtime::message::Placement::NextTurn,
+                            disposition: maka_runtime::message::MessageDisposition::Followup,
+                            skill_invocation: Default::default(),
+                            submitted_intent: None,
+                        },
+                    },
+                )
+                .await
+                .unwrap();
+            }
+        }
+        let sealed = log.prefix(100, 128 * 1024).await.unwrap();
+        log.close().await.unwrap();
+        let mut result = None;
+        let mut settled = None;
+        for reopened in [false, true] {
+            let host = Host::open(fixture.owner()).await.unwrap();
+            #[cfg(unix)]
+            let endpoint = fixture.workspace.parent().unwrap().join("handoff.sock");
+            #[cfg(windows)]
+            let endpoint = std::path::PathBuf::from(format!(
+                r"\\.\pipe\maka-handoff-{}",
+                uuid::Uuid::new_v4()
+            ));
+            let cancel = CancellationToken::new();
+            let server = tokio::spawn(
+                LocalListener::bind(&endpoint)
+                    .unwrap()
+                    .serve(host.clone(), cancel.clone()),
+            );
+            let mut peer = Peer::new(host.clone(), "handoff-stop").await;
+            if let Some(provider) = &provider {
+                assert_eq!(
+                    provider.requests.lock().unwrap().len(),
+                    usize::from(reopened),
+                    "startup must leave queued work behind the unclaimed seal"
+                );
+            }
+            let turn = peer
+                .rpc(
+                    "turn.query",
+                    json!({"sessionId":"session", "turnId":"turn"}),
+                )
+                .await;
+            assert_eq!(turn["result"]["runId"], "public-run", "{turn}");
+            assert_eq!(
+                turn["result"]["status"],
+                if reopened { "cancelled" } else { "running" },
+                "{turn}"
+            );
+            let wrong = peer
                 .rpc(
                     "turn.stop",
                     json!({
-                        "sessionId":"session", "turnId":"turn", "runId":"public-run"
+                        "sessionId":"session", "turnId":"turn", "runId":"physical-successor"
                     }),
                 )
                 .await;
-            assert_eq!(stopped["ok"], true, "{stopped}");
-            assert_eq!(stopped["result"]["status"], "cancelled", "{stopped}");
-            assert_eq!(stopped["result"]["runId"], "public-run", "{stopped}");
-            if let Some(expected) = &result {
-                assert_eq!(&stopped, expected);
-            } else {
-                result = Some(stopped);
+            assert_eq!(wrong["error"]["code"], "operation_conflict", "{wrong}");
+            let after_wrong = peer
+                .rpc(
+                    "turn.query",
+                    json!({"sessionId":"session", "turnId":"turn"}),
+                )
+                .await;
+            assert_eq!(
+                after_wrong, turn,
+                "wrong public identity must not claim or cancel the seal"
+            );
+            for _ in 0..2 {
+                let stopped = peer
+                    .rpc(
+                        "turn.stop",
+                        json!({
+                            "sessionId":"session", "turnId":"turn", "runId":"public-run"
+                        }),
+                    )
+                    .await;
+                assert_eq!(stopped["ok"], true, "{stopped}");
+                assert_eq!(stopped["result"]["status"], "cancelled", "{stopped}");
+                assert_eq!(stopped["result"]["runId"], "public-run", "{stopped}");
+                if let Some(expected) = &result {
+                    assert_eq!(&stopped, expected);
+                } else {
+                    result = Some(stopped);
+                }
             }
-        }
-        peer.close().await;
-        cancel.cancel();
-        tokio::time::timeout(Duration::from_secs(10), server)
-            .await
-            .unwrap()
-            .unwrap()
-            .unwrap();
-        drop(host);
-        let log = fixture.log().await;
-        let prefix = log.prefix(100, 128 * 1024).await.unwrap();
-        assert_eq!(
-            prefix.events.len(),
-            4,
-            "only one claim and one cancellation are added"
-        );
-        assert_eq!(
-            serde_json::to_value(&prefix.events[..2]).unwrap(),
-            serde_json::to_value(&sealed.events).unwrap()
-        );
-        assert!(matches!(
-            &prefix.events[2].event.fact,
-            Fact::InvocationOpened {
-                input: InvocationInput::Handoff { .. },
-                ..
+            if queued {
+                tokio::time::timeout(Duration::from_secs(10), async {
+                    loop {
+                        let message = peer
+                            .rpc(
+                                "turn.message.execution.query",
+                                json!({"sessionId":"session", "messageIds":["followup"]}),
+                            )
+                            .await;
+                        assert_eq!(message["ok"], true, "{message}");
+                        let resolution = &message["result"]["resolutions"][0];
+                        if resolution["state"] == "owned" {
+                            assert_ne!(resolution["turnId"], "turn");
+                            let turn = peer
+                                .rpc(
+                                    "turn.query",
+                                    json!({"sessionId":"session", "turnId":resolution["turnId"]}),
+                                )
+                                .await;
+                            assert_ne!(turn["result"]["status"], "failed", "{turn}");
+                            if turn["result"]["status"] == "completed" {
+                                break;
+                            }
+                        }
+                        tokio::time::sleep(Duration::from_millis(10)).await;
+                    }
+                })
+                .await
+                .expect("cancelling a seal must wake its queued successor");
+                assert_eq!(provider.as_ref().unwrap().requests.lock().unwrap().len(), 1);
+            }
+            peer.close().await;
+            cancel.cancel();
+            tokio::time::timeout(Duration::from_secs(10), server)
+                .await
+                .unwrap()
+                .unwrap()
+                .unwrap();
+            drop(host);
+            let log = fixture.log().await;
+            let prefix = log.prefix(100, 128 * 1024).await.unwrap();
+            if !queued {
+                assert_eq!(
+                    prefix.events.len(),
+                    4,
+                    "only one claim and one cancellation are added"
+                );
             }
-        ));
-        assert!(
-            matches!(&prefix.events[3].event.fact, Fact::InvocationEnded {
+            assert_eq!(
+                prefix
+                    .events
+                    .iter()
+                    .filter(|event| matches!(
+                        event.event.fact,
+                        Fact::InvocationEnded {
+                            outcome: InvocationOutcome::Cancelled { .. }
+                        }
+                    ))
+                    .count(),
+                1
+            );
+            assert_eq!(
+                serde_json::to_value(&prefix.events[..2]).unwrap(),
+                serde_json::to_value(&sealed.events).unwrap()
+            );
+            assert!(matches!(
+                &prefix.events[2].event.fact,
+                Fact::InvocationOpened {
+                    input: InvocationInput::Handoff { .. },
+                    ..
+                }
+            ));
+            assert!(
+                matches!(&prefix.events[3].event.fact, Fact::InvocationEnded {
             outcome: InvocationOutcome::Cancelled { source }
         } if source == "runtime_cancellation")
-        );
-        if let Some(expected) = &settled {
-            assert_eq!(&prefix.digest, expected);
-        } else {
-            settled = Some(prefix.digest);
+            );
+            if let Some(expected) = &settled {
+                assert_eq!(&prefix.digest, expected);
+            } else {
+                settled = Some(prefix.digest);
+            }
+            log.close().await.unwrap();
         }
-        log.close().await.unwrap();
     }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 21/29: feat(host): control and revoke native deployments safely

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 18f178b14be3702d36673ac98570800141a2bb62
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 21:32:14 2026 +0800

    feat(host): control and revoke native deployments safely
    
    Generated-by: OpenAI Codex
---
 crates/cli/src/args.rs                       |  15 +++
 crates/cli/src/deployment.rs                 |  69 ++++++++++--
 crates/cli/src/deployment/activation.rs      |   2 +
 crates/cli/src/deployment/control.rs         | 155 +++++++++++++++++++++++++++
 crates/cli/src/deployment/service.rs         |  44 ++++++++
 crates/cli/src/deployment/service/linux.rs   |  38 ++++++-
 crates/cli/src/deployment/service/macos.rs   |  39 +++++--
 crates/cli/src/deployment/service/windows.rs |  94 +++++++++++-----
 crates/cli/src/deployment/store.rs           |  69 ++++++++++++
 crates/cli/src/deployment/update.rs          |  12 ++-
 crates/cli/src/deployment/updates.rs         |  55 +++++++++-
 crates/cli/tests/integration/deployment.rs   |  63 ++++++++++-
 docs/rust-runtime.md                         |   6 ++
 docs/rust-runtime.zh-CN.md                   |   4 +
 14 files changed, 610 insertions(+), 55 deletions(-)

diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 960f34bd09..9b7f51445c 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -57,6 +57,12 @@ enum HostCommand {
     Update(crate::deployment::Update),
     /// Finish an interrupted deployment update without choosing another target.
     Reconcile(crate::deployment::Update),
+    /// Stop a deployment without changing its configuration or pending update.
+    Stop(crate::deployment::Control),
+    /// Restart the current deployment without applying a pending update.
+    Restart(crate::deployment::Control),
+    /// Revoke startup and unregister its service, retaining State Root data.
+    Uninstall(crate::deployment::Control),
     /// Query the existing Host without opening its State Root for writing.
     Status(Root),
     /// Retire the exact current Host, preserving work at safe step boundaries.
@@ -130,6 +136,15 @@ impl Cli {
             Command::Host(HostCommand::Activate(args)) => args.run().await,
             Command::Host(HostCommand::Update(args)) => args.run(false).await,
             Command::Host(HostCommand::Reconcile(args)) => args.run(true).await,
+            Command::Host(HostCommand::Stop(args)) => {
+                args.run(crate::deployment::ControlAction::Stop).await
+            }
+            Command::Host(HostCommand::Restart(args)) => {
+                args.run(crate::deployment::ControlAction::Restart).await
+            }
+            Command::Host(HostCommand::Uninstall(args)) => {
+                args.run(crate::deployment::ControlAction::Uninstall).await
+            }
             Command::Host(HostCommand::Init(args)) => {
                 let root_id = initialize(&args.root, &RootNamespaces::for_current_account()?)?;
                 println!("{}", serde_json::json!({"rootId": root_id}));
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index be06af0294..ac550c9fcb 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -18,12 +18,14 @@
  */
 
 mod activation;
+mod control;
 mod package;
 mod service;
 mod store;
 mod update;
 mod updates;
 pub(super) use activation::Activate;
+pub(super) use control::{Control, ControlAction};
 pub(super) use update::Update;
 
 use clap::{Args, ValueEnum};
@@ -73,7 +75,21 @@ pub(super) struct Install {
     websocket: SocketAddr,
 }
 
-/// Active configuration authorizes startup; it does not assert process health.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum Admission {
+    #[default]
+    Active,
+    Revoked,
+}
+
+impl Admission {
+    fn is_active(&self) -> bool {
+        *self == Self::Active
+    }
+}
+
+/// Deployment admission authorizes startup; it does not assert process health.
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase", deny_unknown_fields)]
 pub(super) struct Deployment {
@@ -85,9 +101,20 @@ pub(super) struct Deployment {
     sha256: String,
     pub mode: Mode,
     pub websocket: SocketAddr,
+    // Active remains readable by previously installed executables. Older readers
+    // reject the explicit tombstone through deny_unknown_fields.
+    #[serde(default, skip_serializing_if = "Admission::is_active")]
+    admission: Admission,
 }
 
 impl Deployment {
+    fn require_active(&self) -> Result<(), HostError> {
+        if !self.admission.is_active() {
+            return Err("deployment has been uninstalled".into());
+        }
+        Ok(())
+    }
+
     pub fn generation(&self) -> String {
         format!("{}:{}", self.deployment_id, self.config_revision)
     }
@@ -138,7 +165,9 @@ impl Install {
         })
         .await??;
         let existing = store::read(&directory).await?;
-        if let store::Installation::Installed(existing) = &existing {
+        if let store::Installation::Installed(existing) = &existing
+            && existing.admission.is_active()
+        {
             existing.validate(&root, &directory)?;
             if existing.mode != self.mode || existing.websocket != self.websocket {
                 return Err("deployment configuration changes require an update".into());
@@ -167,18 +196,37 @@ impl Install {
             sha256,
             mode: self.mode,
             websocket: self.websocket,
+            admission: Admission::Active,
         };
         let deployment = if let store::Installation::Installed(existing) = existing {
             existing.validate(&root, &directory)?;
-            if existing.executable != requested.executable
-                || existing.mode != requested.mode
-                || existing.websocket != requested.websocket
-            {
-                return Err(
-                    "deployment is already installed; changing it requires an update".into(),
-                );
+            if existing.admission == Admission::Revoked {
+                let owner = Arc::new(RootOwner::open(
+                    root.canonical_path(),
+                    &RootNamespaces::for_current_account()?,
+                )?);
+                // Remove the old projection before granting a new installation:
+                // an old login trigger must not resurrect it across the cut.
+                service::remove(existing.clone(), lease.clone(), owner.clone()).await?;
+                store::change(
+                    &directory,
+                    lease.clone(),
+                    owner,
+                    existing,
+                    store::Change::Reinstall(requested),
+                )
+                .await?
+            } else {
+                if existing.executable != requested.executable
+                    || existing.mode != requested.mode
+                    || existing.websocket != requested.websocket
+                {
+                    return Err(
+                        "deployment is already installed; changing it requires an update".into(),
+                    );
+                }
+                existing
             }
-            existing
         } else {
             // No deployment database is created before obtaining the Root. A busy
             // unmanaged Host remains unmanaged; staging alone cannot fence startup.
@@ -209,6 +257,7 @@ pub(super) async fn admit(owner: &RootOwner, mode: Mode) -> Result<Option<Deploy
         store::Installation::Installed(deployment) => deployment,
     };
     deployment.validate(&root, &directory)?;
+    deployment.require_active()?;
     if !deployment.executable.symlink_metadata()?.is_file()
         || deployment.executable.canonicalize()? != deployment.executable
         || deployment.mode != mode
diff --git a/crates/cli/src/deployment/activation.rs b/crates/cli/src/deployment/activation.rs
index ad2ba732cb..9933162d32 100644
--- a/crates/cli/src/deployment/activation.rs
+++ b/crates/cli/src/deployment/activation.rs
@@ -116,6 +116,7 @@ impl Activate {
             return Err("deployment root identity changed".into());
         }
         deployment.validate(&root, &directory)?;
+        deployment.require_active()?;
         lease.validate()?;
         let (client, live) = connect_or_launch(&deployment, lease.clone()).await?;
         lease.validate()?;
@@ -146,6 +147,7 @@ pub(super) async fn connect_or_launch(
     deployment: &Deployment,
     lease: Arc<FileLease>,
 ) -> Result<(HostClient, LiveHost), HostError> {
+    deployment.require_active()?;
     let mut child: Option<Child> = None;
     let mut service_started = false;
     let result = tokio::time::timeout(Duration::from_secs(30), async {
diff --git a/crates/cli/src/deployment/control.rs b/crates/cli/src/deployment/control.rs
new file mode 100644
index 0000000000..f65ce0f3f4
--- /dev/null
+++ b/crates/cli/src/deployment/control.rs
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Admission, Deployment, RootId, activation, directory, service, store, update};
+use clap::Args;
+use maka_event_log::root::{self, FileLease};
+use maka_runtime_host::server::HostError;
+use serde::Serialize;
+use std::sync::Arc;
+use uuid::Uuid;
+
+#[derive(Args)]
+pub(crate) struct Control {
+    #[arg(long)]
+    root_id: RootId,
+    #[arg(long)]
+    expected_deployment_id: Uuid,
+    #[arg(long, value_parser = clap::value_parser!(u64).range(1..=9_007_199_254_740_991))]
+    expected_revision: u64,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ControlAction {
+    Stop,
+    Restart,
+    Uninstall,
+}
+
+#[derive(Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum Outcome {
+    ActiveTasks {
+        deployment: Deployment,
+    },
+    Stopped {
+        deployment: Deployment,
+    },
+    Ready {
+        deployment: Deployment,
+        host: crate::host_client::LiveHost,
+    },
+    Unregistered {
+        deployment: Deployment,
+        cleanup: Cleanup,
+    },
+}
+
+#[derive(Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum Cleanup {
+    Complete,
+    Pending { message: String },
+}
+
+impl Control {
+    pub async fn run(self, action: ControlAction) -> Result<(), HostError> {
+        let directory = directory(&self.root_id.0)?;
+        if !directory.is_dir() {
+            return Err("Host deployment is not installed".into());
+        }
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+        let store::Installation::Installed(current) = store::read(&directory).await? else {
+            return Err("Host deployment is absent or incomplete".into());
+        };
+        let root = root::resolve(&current.root_path)?;
+        current.validate(&root, &directory)?;
+        let retry = action == ControlAction::Uninstall
+            && current.admission == Admission::Revoked
+            && current.config_revision == self.expected_revision + 1;
+        if current.root_id != self.root_id.0
+            || current.deployment_id != self.expected_deployment_id
+            || (current.config_revision != self.expected_revision && !retry)
+        {
+            return Err("deployment identity or revision changed".into());
+        }
+        if action != ControlAction::Uninstall {
+            current.require_active()?;
+        }
+        let Some(owner) = update::retire(&current).await? else {
+            println!(
+                "{}",
+                serde_json::to_string(&Outcome::ActiveTasks {
+                    deployment: current
+                })?
+            );
+            return Ok(());
+        };
+        let owner = Arc::new(owner);
+        let outcome = if action == ControlAction::Uninstall {
+            let deployment = if current.admission == Admission::Revoked {
+                current
+            } else {
+                store::change(
+                    &directory,
+                    lease.clone(),
+                    owner.clone(),
+                    current,
+                    store::Change::Revoke,
+                )
+                .await?
+            };
+            // Once revoked, startup remains denied even if OS cleanup fails.
+            // Retain Root data, packages and the tombstone for explicit recovery.
+            let cleanup = match service::remove(deployment.clone(), lease.clone(), owner).await {
+                Ok(()) => Cleanup::Complete,
+                Err(error) => Cleanup::Pending {
+                    message: error.to_string().chars().take(2048).collect(),
+                },
+            };
+            Outcome::Unregistered {
+                deployment,
+                cleanup,
+            }
+        } else {
+            service::stop(current.clone(), lease.clone(), owner.clone()).await?;
+            drop(owner);
+            if action == ControlAction::Restart {
+                let (client, host) = activation::connect_or_launch(&current, lease.clone()).await?;
+                // The live receipt, not OS status, establishes restart readiness.
+                lease.validate()?;
+                println!(
+                    "{}",
+                    serde_json::to_string(&Outcome::Ready {
+                        deployment: current,
+                        host
+                    })?
+                );
+                drop(client);
+                return Ok(());
+            }
+            Outcome::Stopped {
+                deployment: current,
+            }
+        };
+        lease.validate()?;
+        println!("{}", serde_json::to_string(&outcome)?);
+        Ok(())
+    }
+}
diff --git a/crates/cli/src/deployment/service.rs b/crates/cli/src/deployment/service.rs
index 636a697447..1de2614b8c 100644
--- a/crates/cli/src/deployment/service.rs
+++ b/crates/cli/src/deployment/service.rs
@@ -60,6 +60,50 @@ pub(super) async fn start(
     .await?
 }
 
+pub(super) async fn stop(
+    deployment: Deployment,
+    lease: Arc<FileLease>,
+    owner: Arc<RootOwner>,
+) -> Result<(), HostError> {
+    change(deployment, lease, owner, false).await
+}
+
+pub(super) async fn remove(
+    deployment: Deployment,
+    lease: Arc<FileLease>,
+    owner: Arc<RootOwner>,
+) -> Result<(), HostError> {
+    change(deployment, lease, owner, true).await
+}
+
+async fn change(
+    deployment: Deployment,
+    lease: Arc<FileLease>,
+    owner: Arc<RootOwner>,
+    remove: bool,
+) -> Result<(), HostError> {
+    if deployment.mode == super::Mode::OnDemand {
+        return Ok(());
+    }
+    tokio::task::spawn_blocking(move || {
+        lease.validate()?;
+        owner.validate_current()?;
+        if owner.root_id() != deployment.root_id || owner.canonical_path() != deployment.root_path {
+            return Err("State Root changed before service control".into());
+        }
+        let service = platform::Service::new(&deployment)?;
+        if remove {
+            service.remove()?;
+        } else {
+            service.stop()?;
+        }
+        owner.validate_current()?;
+        lease.validate()?;
+        Ok::<_, HostError>(())
+    })
+    .await?
+}
+
 fn arguments(deployment: &Deployment) -> Result<[&str; 5], HostError> {
     let executable = deployment
         .executable
diff --git a/crates/cli/src/deployment/service/linux.rs b/crates/cli/src/deployment/service/linux.rs
index ce6f52109c..314840bcff 100644
--- a/crates/cli/src/deployment/service/linux.rs
+++ b/crates/cli/src/deployment/service/linux.rs
@@ -62,6 +62,14 @@ impl Service {
                 format!("persistent user services require lingering for user {uid}").into(),
             );
         }
+        self.stop()?;
+        publish(&self.path, &self.definition)?;
+        checked(command("systemctl", &["--user", "daemon-reload"])?)?;
+        checked(command("systemctl", &["--user", "enable", &self.unit])?)?;
+        Ok(())
+    }
+
+    pub fn stop(&self) -> Result<(), HostError> {
         let loaded = checked(command(
             "systemctl",
             &[
@@ -80,9 +88,35 @@ impl Service {
             }
             _ => return Err("unrecognized systemd service load state".into()),
         }
-        publish(&self.path, &self.definition)?;
+        Ok(())
+    }
+
+    pub fn remove(&self) -> Result<(), HostError> {
+        let fragment = checked(command(
+            "systemctl",
+            &[
+                "--user",
+                "show",
+                "--property=FragmentPath",
+                "--value",
+                &self.unit,
+            ],
+        )?)?;
+        if !fragment.trim().is_empty() && std::path::Path::new(fragment.trim()) != self.path {
+            return Err("service definition location differs; restore its original XDG_CONFIG_HOME before cleanup".into());
+        }
+        self.stop()?;
+        match self.path.symlink_metadata() {
+            Ok(metadata) if metadata.is_file() => {
+                checked(command("systemctl", &["--user", "disable", &self.unit])?)?;
+                std::fs::remove_file(&self.path)?;
+                std::fs::File::open(self.path.parent().ok_or("service path has no parent")?)?
+                    .sync_all()?;
+            }
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+            _ => return Err("service definition is not a regular file".into()),
+        }
         checked(command("systemctl", &["--user", "daemon-reload"])?)?;
-        checked(command("systemctl", &["--user", "enable", &self.unit])?)?;
         Ok(())
     }
 
diff --git a/crates/cli/src/deployment/service/macos.rs b/crates/cli/src/deployment/service/macos.rs
index 8cf6cc3264..0265e5fa19 100644
--- a/crates/cli/src/deployment/service/macos.rs
+++ b/crates/cli/src/deployment/service/macos.rs
@@ -67,6 +67,21 @@ impl Service {
     }
 
     pub fn prepare(&self) -> Result<(), HostError> {
+        self.stop()?;
+        publish(&self.path, &self.definition)?;
+        checked(command("/bin/launchctl", &["enable", &self.target])?)?;
+        checked(command(
+            "/bin/launchctl",
+            &[
+                "bootstrap",
+                &format!("gui/{}", self.uid),
+                self.path.to_str().ok_or("service path must be UTF-8")?,
+            ],
+        )?)?;
+        Ok(())
+    }
+
+    pub fn stop(&self) -> Result<(), HostError> {
         if self.in_user_domain(&["managername"])?.trim() != "Aqua"
             || self.in_user_domain(&["manageruid"])?.trim() != self.uid
         {
@@ -86,16 +101,20 @@ impl Service {
                 std::thread::sleep(Duration::from_millis(25));
             }
         }
-        publish(&self.path, &self.definition)?;
-        checked(command("/bin/launchctl", &["enable", &self.target])?)?;
-        checked(command(
-            "/bin/launchctl",
-            &[
-                "bootstrap",
-                &format!("gui/{}", self.uid),
-                self.path.to_str().ok_or("service path must be UTF-8")?,
-            ],
-        )?)?;
+        Ok(())
+    }
+
+    pub fn remove(&self) -> Result<(), HostError> {
+        self.stop()?;
+        match self.path.symlink_metadata() {
+            Ok(metadata) if metadata.is_file() => {
+                std::fs::remove_file(&self.path)?;
+                std::fs::File::open(self.path.parent().ok_or("service path has no parent")?)?
+                    .sync_all()?;
+            }
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+            _ => return Err("service definition is not a regular file".into()),
+        }
         Ok(())
     }
 
diff --git a/crates/cli/src/deployment/service/windows.rs b/crates/cli/src/deployment/service/windows.rs
index 2742cf0131..72d28fd62d 100644
--- a/crates/cli/src/deployment/service/windows.rs
+++ b/crates/cli/src/deployment/service/windows.rs
@@ -21,15 +21,15 @@ use super::{Deployment, HostError, arguments, label, xml};
 use std::time::{Duration, Instant};
 use windows::{
     Win32::{
-        Foundation::RPC_E_CHANGED_MODE,
+        Foundation::{RPC_E_CHANGED_MODE, VARIANT_BOOL},
         System::{
             Com::{
                 CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx,
                 CoUninitialize,
             },
             TaskScheduler::{
-                ITaskFolder, ITaskService, TASK_CREATE_OR_UPDATE, TASK_LOGON_INTERACTIVE_TOKEN,
-                TaskScheduler,
+                IRegisteredTask, ITaskFolder, ITaskService, TASK_CREATE_OR_UPDATE,
+                TASK_LOGON_INTERACTIVE_TOKEN, TASK_STATE_DISABLED, TaskScheduler,
             },
             Variant::VARIANT,
         },
@@ -108,32 +108,9 @@ impl Service {
     }
 
     pub fn prepare(&self) -> Result<(), HostError> {
-        // Root is held by the caller; no instance can be executing user work.
+        self.stop()?;
         // SAFETY: these COM interfaces are confined to their initialized thread.
         unsafe {
-            match self.folder.GetTask(&BSTR::from(&self.name)) {
-                Ok(task) => {
-                    let mut description = BSTR::new();
-                    task.Definition()?
-                        .RegistrationInfo()?
-                        .Description(&mut description)?;
-                    if description != self.name.as_str() {
-                        return Err("scheduled task ownership marker differs".into());
-                    }
-                    if task.GetInstances(0)?.Count()? != 0 {
-                        task.Stop(0)?;
-                        let deadline = Instant::now() + Duration::from_secs(45);
-                        while task.GetInstances(0)?.Count()? != 0 {
-                            if Instant::now() >= deadline {
-                                return Err("scheduled task did not stop".into());
-                            }
-                            std::thread::sleep(Duration::from_millis(25));
-                        }
-                    }
-                }
-                Err(error) if error.code().0 as u32 == 0x80070002 => {}
-                Err(error) => return Err(error.into()),
-            }
             let definition = self.scheduler.NewTask(0)?;
             definition.SetXmlText(&BSTR::from(&self.definition))?;
             let empty = VARIANT::default();
@@ -150,6 +127,69 @@ impl Service {
         Ok(())
     }
 
+    pub fn stop(&self) -> Result<(), HostError> {
+        // Root is held by the caller; no instance can be executing user work.
+        // SAFETY: these COM interfaces are confined to their initialized thread.
+        unsafe {
+            if let Some(task) = self.task()?
+                && task.GetInstances(0)?.Count()? != 0
+            {
+                task.Stop(0)?;
+                let deadline = Instant::now() + Duration::from_secs(45);
+                while task.GetInstances(0)?.Count()? != 0 {
+                    if Instant::now() >= deadline {
+                        return Err("scheduled task did not stop".into());
+                    }
+                    std::thread::sleep(Duration::from_millis(25));
+                }
+            }
+        }
+        Ok(())
+    }
+
+    pub fn remove(&self) -> Result<(), HostError> {
+        let Some(task) = self.task()? else {
+            return Ok(());
+        };
+        // SAFETY: the owned task stays on this COM thread. Disabling first
+        // prevents login/restart triggers from racing the final drain/delete.
+        unsafe {
+            task.SetEnabled(VARIANT_BOOL(0))?;
+            if task.State()? != TASK_STATE_DISABLED {
+                task.Stop(0)?;
+                let deadline = Instant::now() + Duration::from_secs(45);
+                while task.State()? != TASK_STATE_DISABLED {
+                    if Instant::now() >= deadline {
+                        return Err("scheduled task did not finish disabling".into());
+                    }
+                    std::thread::sleep(Duration::from_millis(25));
+                }
+            }
+            self.folder.DeleteTask(&BSTR::from(&self.name), 0)?;
+        }
+        Ok(())
+    }
+
+    fn task(&self) -> Result<Option<IRegisteredTask>, HostError> {
+        // SAFETY: retrieval and ownership validation share this COM apartment.
+        unsafe {
+            match self.folder.GetTask(&BSTR::from(&self.name)) {
+                Ok(task) => {
+                    let mut description = BSTR::new();
+                    task.Definition()?
+                        .RegistrationInfo()?
+                        .Description(&mut description)?;
+                    if description != self.name.as_str() {
+                        return Err("scheduled task ownership marker differs".into());
+                    }
+                    Ok(Some(task))
+                }
+                Err(error) if error.code().0 as u32 == 0x80070002 => Ok(None),
+                Err(error) => Err(error.into()),
+            }
+        }
+    }
+
     pub fn start(&self) -> Result<(), HostError> {
         // SAFETY: registration has completed on this same COM thread.
         unsafe {
diff --git a/crates/cli/src/deployment/store.rs b/crates/cli/src/deployment/store.rs
index 426eca7cc9..c67eb9e280 100644
--- a/crates/cli/src/deployment/store.rs
+++ b/crates/cli/src/deployment/store.rs
@@ -127,6 +127,75 @@ pub(super) async fn open_writer(
     .await?)
 }
 
+pub(super) enum Change {
+    Revoke,
+    Reinstall(Deployment),
+}
+
+/// Revocation and reinstallation are authority changes, not code updates.
+/// The connection retains Root through actual COMMIT/rollback and close.
+pub(super) async fn change(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    owner: Arc<RootOwner>,
+    expected: Deployment,
+    change: Change,
+) -> Result<Deployment, HostError> {
+    use super::Admission;
+    if owner.root_id() != expected.root_id || owner.canonical_path() != expected.root_path {
+        return Err("deployment change does not own the expected State Root".into());
+    }
+    let target = match change {
+        Change::Revoke => {
+            expected.require_active()?;
+            if expected.config_revision >= 9_007_199_254_740_991 {
+                return Err("deployment revision is exhausted".into());
+            }
+            let mut target = expected.clone();
+            target.config_revision += 1;
+            target.admission = Admission::Revoked;
+            target
+        }
+        Change::Reinstall(target) => {
+            if expected.admission != Admission::Revoked
+                || target.admission != Admission::Active
+                || target.root_id != expected.root_id
+                || target.root_path != expected.root_path
+                || target.deployment_id == expected.deployment_id
+                || target.config_revision != 1
+            {
+                return Err("invalid deployment reinstallation".into());
+            }
+            target
+        }
+    };
+    let connection = open_writer(directory, lease, Some(owner)).await?;
+    let result = connection
+        .run(move |connection| {
+            Box::pin(async move {
+                let mut transaction = connection.begin().await?;
+                let (active, _) = super::updates::load(&mut transaction).await?;
+                if active != expected {
+                    return Err(StoreError::InvalidTransition("deployment changed".into()));
+                }
+                sqlx::query("UPDATE deployment SET configuration = ? WHERE singleton = 1")
+                    .bind(serde_json::to_string(&target)?)
+                    .execute(&mut *transaction)
+                    .await?;
+                sqlx::query("DELETE FROM deployment_update WHERE singleton = 1")
+                    .execute(&mut *transaction)
+                    .await?;
+                transaction.commit().await?;
+                Ok(target)
+            })
+        })
+        .await;
+    let closed = connection.close().await;
+    let target = result?;
+    closed?;
+    Ok(target)
+}
+
 fn private_file(path: &Path) -> Result<File, HostError> {
     #[cfg(unix)]
     let file = {
diff --git a/crates/cli/src/deployment/update.rs b/crates/cli/src/deployment/update.rs
index 944eb466d2..989fb26421 100644
--- a/crates/cli/src/deployment/update.rs
+++ b/crates/cli/src/deployment/update.rs
@@ -63,6 +63,7 @@ impl Update {
         };
         let root = root::resolve(&current.root_path)?;
         current.validate(&root, &directory)?;
+        current.require_active()?;
         if current.root_id != self.root_id.0 || current.deployment_id != self.expected_deployment_id
         {
             return Err("deployment identity or revision changed".into());
@@ -142,14 +143,21 @@ impl Update {
 }
 
 /// A prepared receipt is not proof of release: only acquiring RootOwner is.
-async fn retire(deployment: &Deployment) -> Result<Option<RootOwner>, HostError> {
+pub(super) async fn retire(deployment: &Deployment) -> Result<Option<RootOwner>, HostError> {
     tokio::time::timeout(Duration::from_secs(30), async {
         loop {
             match RootOwner::open(
                 &deployment.root_path,
                 &RootNamespaces::for_current_account()?,
             ) {
-                Ok(owner) => return Ok(Some(owner)),
+                Ok(owner) => {
+                    if owner.root_id() != deployment.root_id
+                        || owner.canonical_path() != deployment.root_path
+                    {
+                        return Err("State Root changed during retirement".into());
+                    }
+                    return Ok(Some(owner));
+                }
                 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
                 Err(error) => return Err(error.into()),
             }
diff --git a/crates/cli/src/deployment/updates.rs b/crates/cli/src/deployment/updates.rs
index 27f194c826..e5f767a852 100644
--- a/crates/cli/src/deployment/updates.rs
+++ b/crates/cli/src/deployment/updates.rs
@@ -122,7 +122,7 @@ fn validate_target(current: &Deployment, target: &Deployment) -> Result<(), Stor
     Ok(())
 }
 
-async fn load(
+pub(super) async fn load(
     connection: &mut SqliteConnection,
 ) -> Result<(Deployment, Option<Deployment>), StoreError> {
     let (active, pending): (String, Option<String>) = sqlx::query_as(
@@ -171,6 +171,7 @@ mod tests {
             sha256: "a".repeat(64),
             mode: Mode::OnDemand,
             websocket: "127.0.0.1:0".parse().unwrap(),
+            admission: super::super::Admission::Active,
         };
         store::install(&directory, lease.clone(), owner, current.clone())
             .await
@@ -246,7 +247,57 @@ mod tests {
         commit(&directory, lease.clone(), owner, current, target.clone())
             .await
             .unwrap();
-        assert_eq!(read(&directory, lease).await.unwrap(), (target, None));
+        // Uninstall cancels pending intent atomically, but retains a tombstone.
+        // Only explicit reinstallation can grant a fresh deployment identity.
+        let mut pending = target.clone();
+        pending.config_revision += 1;
+        pending.sha256 = "d".repeat(64);
+        pending.executable = package::path(&directory, &pending.sha256);
+        prepare(&directory, lease.clone(), target.clone(), pending)
+            .await
+            .unwrap();
+        let owner = Arc::new(RootOwner::open(&root_path, &namespaces).unwrap());
+        let revoked = store::change(
+            &directory,
+            lease.clone(),
+            owner.clone(),
+            target.clone(),
+            store::Change::Revoke,
+        )
+        .await
+        .unwrap();
+        assert_eq!(revoked.config_revision, 3);
+        assert!(revoked.require_active().is_err());
+        assert_eq!(
+            read(&directory, lease.clone()).await.unwrap(),
+            (revoked.clone(), None)
+        );
+        // A stale operator cannot overwrite the tombstone after a lost response.
+        assert!(
+            store::change(
+                &directory,
+                lease.clone(),
+                owner.clone(),
+                target.clone(),
+                store::Change::Revoke
+            )
+            .await
+            .is_err()
+        );
+        let mut reinstalled = target;
+        reinstalled.deployment_id = uuid::Uuid::new_v4();
+        reinstalled.config_revision = 1;
+        let active = store::change(
+            &directory,
+            lease.clone(),
+            owner.clone(),
+            revoked,
+            store::Change::Reinstall(reinstalled.clone()),
+        )
+        .await
+        .unwrap();
+        assert_eq!(read(&directory, lease).await.unwrap(), (active, None));
+        drop(owner);
         drop(RootOwner::open(&root_path, &namespaces).unwrap());
     }
 }
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index 41441ffaa9..0dd0292660 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -64,6 +64,8 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         assert_eq!(installed["rootId"], fixture.root_id);
         assert_eq!(installed["configRevision"], 1);
 
+        let launch_root = fixture.root.clone();
+        let launch_root_id = fixture.root_id.clone();
         let command = |executable: &str, candidate: bool| {
             let mut command = Command::new(executable);
             command
@@ -76,11 +78,11 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
                     },
                     "--root",
                 ])
-                .arg(&fixture.root);
+                .arg(&launch_root);
             if candidate {
                 command.args([
                     "--expected-root-id",
-                    &fixture.root_id,
+                    &launch_root_id,
                     "--startup-attempt-id",
                     &uuid::Uuid::new_v4().to_string(),
                     "--owner-stdin",
@@ -233,6 +235,63 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         let reused = activate.output().unwrap();
         assert!(reused.status.success(), "{reused:?}");
         assert_eq!(decode_activation(&reused.stdout), frame);
+        let control = |action: &str| {
+            let mut command = Command::new(env!("CARGO_BIN_EXE_maka"));
+            command.args([
+                "host",
+                action,
+                "--root-id",
+                &fixture.root_id,
+                "--expected-deployment-id",
+                installed["deploymentId"].as_str().unwrap(),
+                "--expected-revision",
+                "1",
+            ]);
+            command
+        };
+        let restarted = control("restart").output().unwrap();
+        assert!(restarted.status.success(), "{restarted:?}");
+        let restarted: Value = serde_json::from_slice(&restarted.stdout).unwrap();
+        assert_eq!(restarted["kind"], "ready");
+        assert_eq!(restarted["deployment"], installed);
+        assert_ne!(restarted["host"]["hostEpoch"], frame["hostEpoch"]);
+        let stopped = control("stop").output().unwrap();
+        assert!(stopped.status.success(), "{stopped:?}");
+        assert_eq!(
+            serde_json::from_slice::<Value>(&stopped.stdout).unwrap()["kind"],
+            "stopped"
+        );
+        drop(RootOwner::open(&fixture.root, &namespaces).unwrap());
+        let uninstalled = control("uninstall").output().unwrap();
+        assert!(uninstalled.status.success(), "{uninstalled:?}");
+        let uninstalled: Value = serde_json::from_slice(&uninstalled.stdout).unwrap();
+        assert_eq!(uninstalled["kind"], "unregistered");
+        assert_eq!(uninstalled["deployment"]["admission"], "revoked");
+        assert_eq!(uninstalled["deployment"]["configRevision"], 2);
+        assert_eq!(uninstalled["cleanup"]["kind"], "complete");
+        let repeated = control("uninstall").output().unwrap();
+        assert!(repeated.status.success(), "{repeated:?}");
+        assert_eq!(
+            serde_json::from_slice::<Value>(&repeated.stdout).unwrap(),
+            uninstalled
+        );
+        assert!(!activate.output().unwrap().status.success());
+        let revoked = command(executable, true).output().unwrap();
+        assert!(!revoked.status.success());
+        assert!(String::from_utf8_lossy(&revoked.stderr).contains("uninstalled"));
+        assert!(fixture.root.join("runtime-rust.sqlite").is_file());
+        let reinstalled = install.output().unwrap();
+        assert!(reinstalled.status.success(), "{reinstalled:?}");
+        let reinstalled: Value = serde_json::from_slice(&reinstalled.stdout).unwrap();
+        assert_ne!(reinstalled["deploymentId"], installed["deploymentId"]);
+        assert_eq!(reinstalled["configRevision"], 1);
+        assert_eq!(reinstalled["rootId"], fixture.root_id);
+        let ready = activate.output().unwrap();
+        assert!(ready.status.success(), "{ready:?}");
+        let ready = decode_activation(&ready.stdout);
+        assert_eq!(ready["deploymentId"], reinstalled["deploymentId"]);
+        assert!(!control("stop").output().unwrap().status.success());
+        assert_eq!(decode_activation(&activate.output().unwrap().stdout), ready);
         fixture.retire_registered();
     }
 }
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 4d8d9d918a..ef866ac4c8 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -78,6 +78,12 @@ automatically rolled back, even if startup fails. Supervised activation replaces
 the service definition only while holding the Root. Unattended release selection
 and uninterrupted listener handoff are not yet connected.
 
+`host stop`, `host restart` and `host uninstall` take the same identity arguments.
+Stop and restart preserve pending updates. Uninstall revokes startup before removing
+the service; it retains Root data, packages and a deployment tombstone. Retry the
+same uninstall if `cleanup.kind` is `pending`. Explicit installation grants a new
+deployment identity after the old service has been removed.
+
 The `maka` command also provides `host candidate` for Desktop-owned startup,
 `code --log <file>` for a JavaScript cell read from stdin, and
 `inspect --log <file>` for committed execution facts.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 6350e03dca..2853a2cecd 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -65,6 +65,10 @@ Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 
 目标一旦提交,即使启动失败也不自动回退。Supervised 激活仅在持有 Root 时替换服务定义。
 无人值守版本选择和不中断监听的交接尚未接通。
 
+`host stop`、`host restart`、`host uninstall` 使用相同的身份参数。
+停止和重启保留待更新目标。卸载先撤销启动资格,再注销服务;Root 数据、代码包及部署撤销记录均保留。
+若 `cleanup.kind` 为 `pending`,重试相同卸载命令;显式安装会在旧服务清理完成后授予新的部署身份。
+
 `maka` 命令还提供 Desktop 启动用的 `host candidate`、从 stdin 读取 JavaScript cell 的
 `code --log <file>`,以及查看已提交执行事实的 `inspect --log <file>`。
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 10/29: feat(host): coordinate durable handoff and recover frozen execution

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 855cfda7b03e3f807e339820a733da5a8ed0596a
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 16:14:05 2026 +0800

    feat(host): coordinate durable handoff and recover frozen execution
    
    Generated-by: OpenAI Codex
---
 crates/agent/src/handoff.rs                        |   4 +
 crates/agent/src/lib.rs                            |   4 +-
 crates/client-capability/src/broker.rs             |  51 ++++-
 crates/client-capability/src/broker/state.rs       |   9 +
 .../client-capability/tests/journal_admission.rs   |  24 ++
 crates/event-log/src/handoff.rs                    |  42 ++++
 crates/event-log/src/lib.rs                        |   1 +
 .../event-log/tests/continuation/handoff_cancel.rs |  14 ++
 crates/runtime-host/src/execution.rs               |  25 ++
 crates/runtime-host/src/execution/handoff.rs       | 166 ++++++++++++++
 .../runtime-host/src/execution/handoff/prepare.rs  | 253 +++++++++++++++++++++
 crates/runtime-host/src/execution/launch.rs        |   3 +-
 .../src/execution/prepare/environment.rs           |   2 +-
 crates/runtime-host/src/execution/provider.rs      |  30 ++-
 .../runtime-host/src/execution/provider/options.rs |  17 +-
 crates/runtime-host/src/execution/successor.rs     |   3 +-
 crates/runtime-host/src/execution/workhub.rs       |   2 +-
 .../runtime-host/src/execution/workhub/profile.rs  |  23 +-
 crates/runtime-host/src/server.rs                  |  22 +-
 crates/runtime-host/src/server/capabilities.rs     |   5 +-
 crates/runtime-host/src/server/connection.rs       |  13 +-
 crates/runtime-host/src/server/diagnostics.rs      |   3 +
 crates/runtime-host/src/server/dispatch.rs         |   1 +
 crates/runtime-host/src/server/handshake.rs        |  11 +-
 crates/runtime-host/src/server/interactions.rs     |   2 +
 crates/runtime-host/src/server/retirement.rs       | 211 ++++++++++++++---
 crates/runtime-host/src/server/workhub.rs          |  59 ++++-
 crates/runtime-host/tests/integration/handoff.rs   | 250 ++++++++++++++++++++
 .../runtime-host/tests/integration/retirement.rs   |  13 ++
 .../runtime-host/tests/integration/support/peer.rs |  11 +-
 docs/rust-runtime.md                               |   7 +-
 docs/rust-runtime.zh-CN.md                         |   5 +-
 32 files changed, 1199 insertions(+), 87 deletions(-)

diff --git a/crates/agent/src/handoff.rs b/crates/agent/src/handoff.rs
index 45256964db..1b8fe0b613 100644
--- a/crates/agent/src/handoff.rs
+++ b/crates/agent/src/handoff.rs
@@ -62,6 +62,10 @@ pub struct PendingSeal {
 }
 
 impl HandoffGate {
+    pub fn root_run_id(&self) -> &str {
+        &self.root_run_id
+    }
+
     pub(crate) fn new(source: maka_runtime::event::Invocation, root_run_id: String) -> Self {
         Self {
             state: watch::channel(State::Running).0,
diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs
index 6a1a5436b8..3984ca09aa 100644
--- a/crates/agent/src/lib.rs
+++ b/crates/agent/src/lib.rs
@@ -149,7 +149,9 @@ impl Engine {
         input: &RunInput,
         cancellation: &CancellationToken,
     ) -> Result<(), RunError> {
-        let RunWork::Continuation { source, tools, .. } = &input.work else {
+        let (RunWork::Continuation { source, tools, .. } | RunWork::Handoff { source, tools, .. }) =
+            &input.work
+        else {
             return Err(RunError::InvalidInput("not a continuation request".into()));
         };
         self.0
diff --git a/crates/client-capability/src/broker.rs b/crates/client-capability/src/broker.rs
index 0c62cb339e..5ed6de2879 100644
--- a/crates/client-capability/src/broker.rs
+++ b/crates/client-capability/src/broker.rs
@@ -30,7 +30,7 @@ use crate::Registration;
 pub use call::{AcceptedCall, PendingCall};
 use maka_protocol::capability::decode_host_frame;
 use maka_runtime::capability::{ClientFrame, HostFrame};
-use state::{Active, Inner, Invocation, Stage};
+use state::{Active, Inner, Invocation, Stage, ToolInvocation};
 use std::{
     sync::{Arc, Mutex},
     time::Duration,
@@ -91,6 +91,37 @@ impl Default for Broker {
 }
 
 impl Broker {
+    /// Authorizes a callback from an already admitted tool, including calls
+    /// pinned to a publication that has since been replaced or withdrawn.
+    pub fn admitted_tool(
+        &self,
+        connection: Uuid,
+        session: &str,
+        turn: &str,
+        tool_call: &str,
+        server: &str,
+        tool_name: &str,
+    ) -> bool {
+        let state = self.inner.active.lock().unwrap_or_else(|e| e.into_inner());
+        !self.inner.draining.is_cancelled()
+            && state.calls.values().any(|call| {
+                let endpoint = call.registration.endpoint();
+                call.registration.connection_id() == connection
+                    && call.stage.admitted()
+                    && call.pending_terminal.is_none()
+                    && !call.cancellation.is_cancelled()
+                    && !endpoint.closed().is_cancelled()
+                    && !endpoint.invocations().is_cancelled()
+                    && call.tool.as_ref().is_some_and(|tool| {
+                        tool.session_id == session
+                            && tool.turn_id == turn
+                            && tool.tool_call_id == tool_call
+                            && tool.server_id == server
+                            && tool.tool_name == tool_name
+                    })
+            })
+    }
+
     fn prepare(
         &self,
         registration: Arc<Registration>,
@@ -103,6 +134,23 @@ impl Broker {
         }
         let invocation_id = Uuid::new_v4().to_string();
         let frame = frame(invocation_id.clone());
+        let tool = match &frame {
+            HostFrame::Call {
+                session_id,
+                turn_id,
+                tool_call_id,
+                server_id,
+                tool_name,
+                ..
+            } => Some(ToolInvocation {
+                session_id: session_id.clone(),
+                turn_id: turn_id.clone(),
+                tool_call_id: tool_call_id.clone(),
+                server_id: server_id.clone(),
+                tool_name: tool_name.clone(),
+            }),
+            _ => None,
+        };
         let encoded =
             serde_json::to_value(&frame).map_err(|_| CallError::Invalid("JSON encoding"))?;
         decode_host_frame(&encoded).map_err(|_| CallError::Invalid("outbound wire boundary"))?;
@@ -135,6 +183,7 @@ impl Broker {
             invocation_id.clone(),
             Invocation {
                 registration,
+                tool,
                 stage: Stage::Dispatched(accepted_tx),
                 result: result_tx,
                 timeout,
diff --git a/crates/client-capability/src/broker/state.rs b/crates/client-capability/src/broker/state.rs
index ebfcd84f09..f9f5337e11 100644
--- a/crates/client-capability/src/broker/state.rs
+++ b/crates/client-capability/src/broker/state.rs
@@ -48,6 +48,7 @@ impl Stage {
 }
 pub(super) struct Invocation {
     pub registration: Arc<Registration>,
+    pub tool: Option<ToolInvocation>,
     pub stage: Stage,
     pub result: oneshot::Sender<Result<CallResult, CallError>>,
     pub timeout: Duration,
@@ -57,6 +58,14 @@ pub(super) struct Invocation {
     pub form_handler: Option<Arc<dyn FormHandler>>,
     pub pending_terminal: Option<(Result<CallResult, CallError>, bool)>,
 }
+
+pub(super) struct ToolInvocation {
+    pub session_id: String,
+    pub turn_id: String,
+    pub tool_call_id: String,
+    pub server_id: String,
+    pub tool_name: String,
+}
 #[derive(Default)]
 pub(super) struct Active {
     pub calls: HashMap<String, Invocation>,
diff --git a/crates/client-capability/tests/journal_admission.rs b/crates/client-capability/tests/journal_admission.rs
index df06664560..1d7a0bcd4a 100644
--- a/crates/client-capability/tests/journal_admission.rs
+++ b/crates/client-capability/tests/journal_admission.rs
@@ -142,6 +142,10 @@ async fn run(boundary: Boundary) {
         )
         .unwrap();
     let accepted = pending.accepted().await.unwrap();
+    let session = invocation.session_id.clone();
+    let turn = invocation.turn_id.clone();
+    let admitted = || broker.admitted_tool(connection, &session, &turn, "call", "server", "effect");
+    assert!(!admitted(), "acceptance is not permission for callbacks");
     assert_eq!(
         log.prefix(8, 16384).await.unwrap().events.len(),
         1,
@@ -181,6 +185,25 @@ async fn run(boundary: Boundary) {
         assert!(
             matches!(outbound.recv().await.unwrap(),HostFrame::Admitted{invocation_id} if invocation_id==id)
         );
+        registry.unregister(connection, "r").unwrap();
+        assert!(admitted(), "callbacks retain the admitted publication");
+        assert!(!broker.admitted_tool(Uuid::new_v4(), &session, &turn, "call", "server", "effect"));
+        assert!(!broker.admitted_tool(
+            connection,
+            &session,
+            "other-turn",
+            "call",
+            "server",
+            "effect"
+        ));
+        assert!(!broker.admitted_tool(
+            connection,
+            &session,
+            &turn,
+            "other-call",
+            "server",
+            "effect"
+        ));
         let prefix = log.prefix(8, 16384).await.unwrap();
         assert!(
             matches!(&prefix.events.last().unwrap().event.fact,Fact::ToolDispatched{operation_id,..} if operation_id=="operation")
@@ -204,6 +227,7 @@ async fn run(boundary: Boundary) {
         }
     }
     let result = execution.await.unwrap();
+    assert!(!admitted(), "settlement or cancellation revokes callbacks");
     match boundary {
         Boundary::Success => {
             assert!(result.is_ok());
diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
index f3bdf50ef1..399458c4ed 100644
--- a/crates/event-log/src/handoff.rs
+++ b/crates/event-log/src/handoff.rs
@@ -27,7 +27,49 @@ mod owner;
 pub(crate) use cancel::apply as cancel;
 pub(crate) use owner::read as owner;
 
+/// Bounded discovery metadata. Only a canonical successor opening claims work.
+pub struct PendingHandoff {
+    pub sequence: u64,
+    pub invocation: maka_runtime::event::Invocation,
+    pub host_epoch: String,
+}
+
 impl EventLog {
+    pub async fn pending_handoffs(&self, after: u64) -> Result<Vec<PendingHandoff>, StoreError> {
+        self.validate_root()?;
+        let after = i64::try_from(after).map_err(|_| invalid("invalid handoff cursor"))?;
+        self.connection
+            .run(move |tx| {
+                Box::pin(async move {
+                    let rows: Vec<(i64, String, String)> = sqlx::query_as(
+                        "SELECT pause.sequence, json_extract(pause.event_json,'$.invocation'),
+                   json_extract(pause.event_json,'$.fact.outcome.pause.intent.host_epoch')
+                 FROM runtime_events pause
+                 WHERE pause.sequence > ? AND pause.kind='invocation_ended'
+                   AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
+                   AND NOT EXISTS (SELECT 1 FROM runtime_events successor
+                     WHERE successor.kind='invocation_opened'
+                     AND successor.invocation_id=json_extract(pause.event_json,
+                       '$.fact.outcome.pause.intent.successor_invocation_id'))
+                 ORDER BY pause.sequence LIMIT 64",
+                    )
+                    .bind(after)
+                    .fetch_all(tx)
+                    .await?;
+                    rows.into_iter()
+                        .map(|(sequence, invocation, host_epoch)| {
+                            Ok(PendingHandoff {
+                                sequence: sequence as u64,
+                                invocation: serde_json::from_str(&invocation)?,
+                                host_epoch,
+                            })
+                        })
+                        .collect()
+                })
+            })
+            .await
+    }
+
     /// Ordinary queued work cannot cross an unclaimed cooperative seal.
     pub async fn has_pending_handoff(&self, session: &str) -> Result<bool, StoreError> {
         self.validate_root()?;
diff --git a/crates/event-log/src/lib.rs b/crates/event-log/src/lib.rs
index 0d5c21e529..d5108de4e2 100644
--- a/crates/event-log/src/lib.rs
+++ b/crates/event-log/src/lib.rs
@@ -27,6 +27,7 @@ pub mod connection;
 pub mod context;
 mod continuation;
 mod handoff;
+pub use handoff::PendingHandoff;
 pub mod interactions;
 pub mod message_admissions;
 mod message_identity;
diff --git a/crates/event-log/tests/continuation/handoff_cancel.rs b/crates/event-log/tests/continuation/handoff_cancel.rs
index 509c40d3db..b783d20b7c 100644
--- a/crates/event-log/tests/continuation/handoff_cancel.rs
+++ b/crates/event-log/tests/continuation/handoff_cancel.rs
@@ -61,6 +61,16 @@ async fn paused_cancellation_claim_is_atomic_idempotent_and_never_cancels_an_exi
             ),
         )
         .await;
+        let pending = log.pending_handoffs(0).await.unwrap();
+        assert_eq!(pending.len(), 1);
+        assert_eq!(pending[0].invocation, source.invocation);
+        assert_eq!(pending[0].host_epoch, pause.intent.host_epoch);
+        assert!(
+            log.pending_handoffs(pending[0].sequence)
+                .await
+                .unwrap()
+                .is_empty()
+        );
         let frozen = log
             .run_prefix("session", "source", None, 100, 65536)
             .await
@@ -162,6 +172,10 @@ async fn paused_cancellation_claim_is_atomic_idempotent_and_never_cancels_an_exi
                 "cancel requires neither model request nor tool dispatch"
             );
         }
+        assert!(
+            log.pending_handoffs(0).await.unwrap().is_empty(),
+            "the successor opening is the only handoff claim authority"
+        );
         assert_eq!(
             log.run_prefix("session", "source", None, 100, 65536)
                 .await
diff --git a/crates/runtime-host/src/execution.rs b/crates/runtime-host/src/execution.rs
index a66cf27f9f..e5dc67b490 100644
--- a/crates/runtime-host/src/execution.rs
+++ b/crates/runtime-host/src/execution.rs
@@ -21,6 +21,8 @@
 mod admission;
 mod archive;
 mod compact;
+mod handoff;
+pub(crate) use handoff::CooperativeRun;
 mod interrupt;
 mod launch;
 mod message;
@@ -69,6 +71,7 @@ pub(crate) struct Executions {
     interactions: Arc<crate::server::interactions::Interactions>,
     active: Mutex<HashMap<String, ActiveRun>>,
     workers: TaskTracker,
+    handoff_wake: tokio::sync::Notify,
     shutdown: CancellationToken,
 }
 
@@ -78,6 +81,7 @@ struct ActiveRun {
     tool_names: Arc<std::collections::HashSet<String>>,
     cancellation: maka_agent::RunCancellation,
     completed: CancellationToken,
+    handoff: Option<maka_agent::HandoffGate>,
 }
 
 pub(crate) struct ExecutionPaths {
@@ -118,6 +122,7 @@ impl Executions {
             writes: Arc::new(maka_fs_tools::WriteCoordinator::default()),
             active: Mutex::new(HashMap::new()),
             workers,
+            handoff_wake: tokio::sync::Notify::new(),
             shutdown,
         })
     }
@@ -125,6 +130,26 @@ impl Executions {
     pub(crate) fn active_count(&self) -> usize {
         self.active.lock().unwrap().len()
     }
+    pub(crate) fn accepting(&self) -> bool {
+        !self.shutdown.is_cancelled()
+            && *self
+                .interactions
+                .retirement
+                .lock()
+                .unwrap_or_else(|e| e.into_inner())
+                == crate::server::retirement::Phase::Ready
+    }
+    /// Existing admitted commands may complete derived work while the Host
+    /// prepares a handoff. Fresh queue/recovery admissions still require Ready.
+    pub(crate) fn retiring(&self) -> bool {
+        self.shutdown.is_cancelled()
+            || *self
+                .interactions
+                .retirement
+                .lock()
+                .unwrap_or_else(|e| e.into_inner())
+                == crate::server::retirement::Phase::Retiring
+    }
     pub(crate) fn has_active_session(&self, session: &str) -> bool {
         self.active
             .lock()
diff --git a/crates/runtime-host/src/execution/handoff.rs b/crates/runtime-host/src/execution/handoff.rs
new file mode 100644
index 0000000000..3bcc8c67f7
--- /dev/null
+++ b/crates/runtime-host/src/execution/handoff.rs
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Executions, Result, failure, internal, provider};
+use maka_event_log::turns::InvocationState;
+use maka_protocol::OperationErrorCode as Code;
+use maka_runtime::event::InvocationOutcome;
+use std::{sync::Arc, time::Duration};
+use tokio_util::sync::CancellationToken;
+
+mod prepare;
+
+pub(crate) struct CooperativeRun {
+    pub invocation: maka_runtime::event::Invocation,
+    pub gate: maka_agent::HandoffGate,
+    pub completed: CancellationToken,
+}
+
+impl Executions {
+    /// Caller owns admission, so this set cannot gain another execution.
+    pub(crate) fn cooperative_runs(&self) -> Option<Vec<CooperativeRun>> {
+        self.active
+            .lock()
+            .unwrap()
+            .values()
+            .map(|run| {
+                Some(CooperativeRun {
+                    invocation: run.invocation.clone(),
+                    gate: run.handoff.clone()?,
+                    completed: run.completed.clone(),
+                })
+            })
+            .collect()
+    }
+    /// Notifications coalesce; canonical openings remain the sole claim authority.
+    pub(crate) fn request_handoff_recovery(&self) {
+        self.handoff_wake.notify_one();
+    }
+
+    pub(crate) fn start_handoff_recovery(self: &Arc<Self>, host_epoch: String) {
+        let executions = self.clone();
+        self.workers.spawn(async move {
+            let mut delay = Duration::from_millis(250);
+            let mut pending = false;
+            loop {
+                tokio::select! {
+                    biased;
+                    _ = executions.shutdown.cancelled() => break,
+                    _ = executions.handoff_wake.notified() => { delay = Duration::from_millis(250); }
+                    _ = tokio::time::sleep(delay), if pending => {
+                        delay = (delay * 2).min(Duration::from_secs(30));
+                    }
+                }
+                if executions.shutdown.is_cancelled() { break; }
+                match executions.recover_handoffs(&host_epoch).await {
+                    Ok(remaining) => pending = remaining,
+                    Err(error) => {
+                        executions.begin_drain();
+                        eprintln!("handoff recovery failed: {}", error.message);
+                        break;
+                    }
+                }
+                // Also restores ownership of accepted queues after a reversible
+                // preparation fence stopped their previous physical workers.
+                if let Err(error) = executions.recover_messages().await {
+                    executions.begin_drain();
+                    eprintln!("pending message recovery failed: {}", error.message);
+                    break;
+                }
+            }
+        });
+        self.request_handoff_recovery();
+    }
+
+    async fn recover_handoffs(self: &Arc<Self>, host_epoch: &str) -> Result<bool> {
+        let mut after = 0;
+        let mut remaining = false;
+        loop {
+            let page = self.log.pending_handoffs(after).await.map_err(internal)?;
+            if page.is_empty() {
+                return Ok(remaining);
+            }
+            for pending in page {
+                after = pending.sequence;
+                if !self.accepting() {
+                    return Ok(true);
+                }
+                // The retiring Host must never reclaim its own sealed workers.
+                if pending.host_epoch == host_epoch
+                    || self.has_active_session(&pending.invocation.session_id)
+                {
+                    continue;
+                }
+                let candidate = match self.prepare_handoff(&pending).await {
+                    Ok(candidate) => candidate,
+                    Err(error) if parked(&error) => {
+                        remaining = true;
+                        continue;
+                    }
+                    Err(error) => return Err(error),
+                };
+                let _admission = self.lock_admission().await;
+                if !self.accepting() {
+                    return Ok(true);
+                }
+                if self.has_active_session(&pending.invocation.session_id) {
+                    continue;
+                }
+                let owner = self
+                    .log
+                    .handoff_owner(&pending.invocation)
+                    .await
+                    .map_err(internal)?;
+                if owner.invocation != candidate.source.invocation
+                    || !matches!(owner.state, InvocationState::Ended {
+                        outcome: InvocationOutcome::HandoffPaused { ref pause }, ..
+                    } if *pause == candidate.pause)
+                {
+                    continue;
+                }
+                let session = self
+                    .log
+                    .get_session::<crate::session::SessionConfiguration>(
+                        &pending.invocation.session_id,
+                    )
+                    .await
+                    .map_err(internal)?;
+                if !session.is_some_and(|session| !session.archived) {
+                    remaining = true;
+                    continue;
+                }
+                match candidate.start(self).await {
+                    Ok(started) => remaining |= !started,
+                    Err(error) if parked(&error) => remaining = true,
+                    Err(error) => return Err(error),
+                }
+            }
+        }
+    }
+}
+
+fn parked(error: &maka_protocol::OperationError) -> bool {
+    matches!(
+        error.code,
+        Code::OperationUnavailable
+            | Code::OperationConflict
+            | Code::SessionArchived
+            | Code::NotFound
+    )
+}
diff --git a/crates/runtime-host/src/execution/handoff/prepare.rs b/crates/runtime-host/src/execution/handoff/prepare.rs
new file mode 100644
index 0000000000..80d89bf396
--- /dev/null
+++ b/crates/runtime-host/src/execution/handoff/prepare.rs
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Executions, Result, failure, internal, provider};
+use maka_agent::{RunInput, RunWork};
+use maka_client_capability::RestoredBindings;
+use maka_event_log::PendingHandoff;
+use maka_protocol::OperationErrorCode as Code;
+use maka_runtime::{
+    continuation::{MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, RunBoundary},
+    event::{Fact, InvocationOutcome},
+    execution::InvocationConfiguration,
+    handoff::HandoffPause,
+    workhub::COORDINATION_SESSION_ID,
+};
+use std::{path::Path, sync::Arc};
+
+pub(super) struct PreparedHandoff {
+    pub source: RunBoundary,
+    pub pause: HandoffPause,
+    configuration: InvocationConfiguration,
+    bindings: RestoredBindings,
+    tools: maka_tools::ToolCatalog,
+    skills: Option<Arc<super::super::skills::FrozenSkills>>,
+    directory: maka_fs_tools::workspace::directory::PublishedDirectory,
+}
+
+impl Executions {
+    pub(super) async fn prepare_handoff(
+        &self,
+        pending: &PendingHandoff,
+    ) -> Result<PreparedHandoff> {
+        let source = &pending.invocation;
+        let prefix = self
+            .log
+            .run_prefix(
+                &source.session_id,
+                &source.run_id,
+                None,
+                MAX_SOURCE_EVENTS,
+                MAX_SOURCE_BYTES,
+            )
+            .await
+            .map_err(internal)?
+            .ok_or_else(|| unavailable("Handoff source is unavailable"))?;
+        let Fact::InvocationEnded {
+            outcome: InvocationOutcome::HandoffPaused { pause },
+        } = &prefix
+            .events
+            .last()
+            .ok_or_else(|| unavailable("Handoff source is empty"))?
+            .event
+            .fact
+        else {
+            return Err(unavailable("Handoff source is not sealed"));
+        };
+        if prefix.invocation != *source || pause.intent.host_epoch != pending.host_epoch {
+            return Err(unavailable("Handoff identity changed"));
+        }
+        let pause = pause.clone();
+        let Fact::InvocationOpened {
+            configuration: Some(configuration),
+            ..
+        } = &prefix.events.first().expect("nonempty source").event.fact
+        else {
+            return Err(unavailable("Handoff has no admitted configuration"));
+        };
+        let configuration = configuration.clone();
+        let boundary = RunBoundary {
+            invocation: prefix.invocation,
+            high_water: prefix.high_water,
+            digest: prefix.digest,
+        };
+        drop(prefix.events);
+        let proof = configuration
+            .tool_composition
+            .as_ref()
+            .ok_or_else(|| unavailable("Handoff has no admitted Host composition"))?;
+        let record = self
+            .log
+            .get_session::<crate::session::SessionConfiguration>(&source.session_id)
+            .await
+            .map_err(internal)?
+            .filter(|record| !record.archived)
+            .ok_or_else(|| unavailable("Handoff Session is unavailable"))?;
+        let (bindings, snapshot) = self
+            .capabilities
+            .registry
+            .lock()
+            .unwrap_or_else(|e| e.into_inner())
+            .restore_bindings(&source.session_id, &proof.clients)
+            .map_err(|error| unavailable(&error.to_string()))?;
+        let mut additional = maka_tools::ClientTools::new(
+            snapshot,
+            self.capabilities.registry.clone(),
+            self.capabilities.broker.clone(),
+            configuration.cwd.clone(),
+            self.interactions.clone(),
+        )
+        .registrations();
+        let (tools, skills) = if source.session_id == COORDINATION_SESSION_ID {
+            if proof.skills_digest.is_some() {
+                return Err(unavailable("WorkHub handoff has an incompatible profile"));
+            }
+            (
+                super::super::workhub::profile::catalog(self, additional)?,
+                None,
+            )
+        } else {
+            additional.push(self.interactions.question_tool());
+            let skills = self
+                .load_skills(&configuration.cwd, Default::default())
+                .await?;
+            let native = self.native_tools(&configuration.cwd, record.configuration.tool_profile);
+            let mode = configuration.permission_mode;
+            let expected = proof
+                .skills_digest
+                .clone()
+                .ok_or_else(|| unavailable("Handoff has no frozen Skills evidence"))?;
+            let (tools, skills) = tokio::task::spawn_blocking(move || {
+                let (tools, skills) =
+                    super::super::tools::catalog(native, mode, additional, skills)?;
+                if skills.catalog().fingerprint().map_err(internal)? != expected {
+                    return Err(unavailable("Handoff Skills changed"));
+                }
+                Ok((tools, skills))
+            })
+            .await
+            .map_err(internal)??;
+            (tools, Some(skills))
+        };
+        if tools.digest() != pause.execution.tools.catalog_digest {
+            return Err(unavailable("Handoff tool catalog changed"));
+        }
+        let cwd = configuration.cwd.clone();
+        let expected = configuration.workspace_identity.clone();
+        let directory = tokio::task::spawn_blocking(move || {
+            let identity = maka_fs_tools::workspace::read_identity(Path::new(&cwd))
+                .map_err(|error| unavailable(&error.to_string()))?;
+            if expected.as_ref() != Some(&identity) {
+                return Err(unavailable("Handoff workspace identity changed"));
+            }
+            maka_fs_tools::workspace::directory::PublishedDirectory::open(Path::new(&cwd))
+                .map_err(|error| unavailable(&error.to_string()))
+        })
+        .await
+        .map_err(internal)??;
+        Ok(PreparedHandoff {
+            source: boundary,
+            pause,
+            configuration,
+            bindings,
+            tools,
+            skills,
+            directory,
+        })
+    }
+}
+
+impl PreparedHandoff {
+    /// Caller has rechecked the canonical owner under execution admission.
+    pub(super) async fn start(self, executions: &Arc<Executions>) -> Result<bool> {
+        self.directory
+            .validate(Path::new(&self.configuration.cwd))
+            .map_err(|error| unavailable(&error.to_string()))?;
+        if let Some(skills) = &self.skills
+            && executions
+                .configuration
+                .skill_preferences()
+                .await
+                .ok()
+                .map(|p| p.revision)
+                != skills.preference_revision
+        {
+            return Ok(false);
+        }
+        let target = self
+            .configuration
+            .model
+            .as_ref()
+            .ok_or_else(|| unavailable("Handoff has no admitted model"))?;
+        let observed = provider::observe_binding(
+            &executions.configuration,
+            &self.source.invocation.session_id,
+            target,
+            self.configuration.thinking_level,
+        )
+        .await?;
+        let run = RunInput {
+            invocation: self.pause.intent.successor(&self.source.invocation),
+            request_fingerprint: None,
+            provider: observed.config.clone(),
+            provider_options: self.pause.execution.provider_options.clone(),
+            main_output_limit: self.pause.execution.main_output_limit,
+            supports_vision: self.pause.execution.supports_vision,
+            context: self.pause.execution.context.clone(),
+            configuration: self.configuration,
+            work: RunWork::Handoff {
+                source: self.source,
+                pause: Box::new(self.pause),
+                tools: self.tools,
+            },
+        };
+        executions
+            .engine
+            .check_continuation(&run, &executions.shutdown)
+            .await
+            .map_err(super::super::execution_error)?;
+        if !executions
+            .capabilities
+            .registry
+            .lock()
+            .unwrap_or_else(|e| e.into_inner())
+            .commit_restored_bindings(self.bindings)
+            .map_err(|error| unavailable(&error.to_string()))?
+        {
+            return Ok(false);
+        }
+        observed.admit(&executions.oauth)?;
+        let running = executions
+            .engine
+            .start(run, executions.shutdown.child_token())
+            .await
+            .map_err(|error| {
+                if super::super::requires_drain(&error) {
+                    executions.begin_drain();
+                }
+                super::super::execution_error(error)
+            })?;
+        executions.track(running);
+        Ok(true)
+    }
+}
+
+fn unavailable(message: &str) -> maka_protocol::OperationError {
+    failure(Code::OperationUnavailable, message)
+}
diff --git a/crates/runtime-host/src/execution/launch.rs b/crates/runtime-host/src/execution/launch.rs
index 3c2877e436..016be5babd 100644
--- a/crates/runtime-host/src/execution/launch.rs
+++ b/crates/runtime-host/src/execution/launch.rs
@@ -33,7 +33,7 @@ impl Executions {
     ) -> Result<TurnSnapshot> {
         let invocation = input.invocation.clone();
         let cancellation = self.shutdown.child_token();
-        if cancellation.is_cancelled() {
+        if self.retiring() {
             return Err(failure(Code::HostDraining, "Host is draining"));
         }
         let running = self
@@ -62,6 +62,7 @@ impl Executions {
                 tool_names: running.tool_names().clone(),
                 cancellation: running.cancellation(),
                 completed: tokio_util::sync::CancellationToken::new(),
+                handoff: running.handoff().cloned(),
             },
         );
         self.workers.spawn(self.clone().drive(running));
diff --git a/crates/runtime-host/src/execution/prepare/environment.rs b/crates/runtime-host/src/execution/prepare/environment.rs
index 57c70a18b4..c97852c248 100644
--- a/crates/runtime-host/src/execution/prepare/environment.rs
+++ b/crates/runtime-host/src/execution/prepare/environment.rs
@@ -187,7 +187,7 @@ impl Environment {
         {
             return Ok(None);
         }
-        if executions.shutdown.is_cancelled() {
+        if executions.retiring() {
             return Err(failure(Code::HostDraining, "Host is draining"));
         }
         self.directory
diff --git a/crates/runtime-host/src/execution/provider.rs b/crates/runtime-host/src/execution/provider.rs
index cb0c1654ab..039f8d0a22 100644
--- a/crates/runtime-host/src/execution/provider.rs
+++ b/crates/runtime-host/src/execution/provider.rs
@@ -42,6 +42,15 @@ pub(super) struct PreparedProvider {
     binding: Option<Arc<auth::Binding>>,
 }
 
+impl PreparedProvider {
+    pub(super) fn admit(self, oauth: &crate::oauth::Authority) -> Result<Self, OperationError> {
+        if let Some(binding) = &self.binding {
+            binding.admit(oauth)?;
+        }
+        Ok(self)
+    }
+}
+
 fn unavailable(message: impl Into<String>) -> OperationError {
     OperationError {
         code: OperationErrorCode::OperationUnavailable,
@@ -55,11 +64,7 @@ pub(super) async fn resolve(
     session_id: &str,
     session: &SessionConfiguration,
 ) -> Result<PreparedProvider, OperationError> {
-    let prepared = observe(config, session_id, session).await?;
-    if let Some(binding) = &prepared.binding {
-        binding.admit(oauth)?;
-    }
-    Ok(prepared)
+    observe(config, session_id, session).await?.admit(oauth)
 }
 
 /// Read-only configuration and credential identity, without execution authority.
@@ -67,6 +72,16 @@ pub(super) async fn observe(
     config: &Arc<ConfigurationStore>,
     session_id: &str,
     session: &SessionConfiguration,
+) -> Result<PreparedProvider, OperationError> {
+    observe_binding(config, session_id, &session.model, session.thinking_level).await
+}
+
+/// The caller supplies the admitted model identity, never a replacement Session.
+pub(super) async fn observe_binding(
+    config: &Arc<ConfigurationStore>,
+    session_id: &str,
+    target: &maka_runtime::execution::ModelBinding,
+    thinking_level: Option<maka_runtime::execution::ThinkingLevel>,
 ) -> Result<PreparedProvider, OperationError> {
     let network = config
         .network_configuration()
@@ -82,7 +97,6 @@ pub(super) async fn observe(
         },
         message: error.to_string().chars().take(1024).collect(),
     })?;
-    let target = &session.model;
     let row = catalog
         .connections
         .iter()
@@ -106,14 +120,14 @@ pub(super) async fn observe(
         .iter()
         .find(|model| model.id == target.model && model.can_use_as_chat_default)
         .ok_or_else(|| unavailable("Session model is not available for chat"))?;
-    if let Some(level) = session.thinking_level
+    if let Some(level) = thinking_level
         && !model.thinking_levels.contains(&level)
     {
         return Err(unavailable("Session thinking level is no longer supported"));
     }
     let route = provider_route::resolve(row, facts, &target.model)?;
     route.check_execution()?;
-    let options = options::resolve(row, facts, session, &route)?;
+    let options = options::resolve(row, facts, &target.model, thinking_level, &route)?;
     let main_output_limit = output::resolve(row, facts, &target.model, route.wire, &options)?;
     let endpoint = row.base_url.as_deref().unwrap_or(&facts.base_url);
     let expected = ConnectionCredentialTarget {
diff --git a/crates/runtime-host/src/execution/provider/options.rs b/crates/runtime-host/src/execution/provider/options.rs
index 5ac22da5df..2d2b5f6730 100644
--- a/crates/runtime-host/src/execution/provider/options.rs
+++ b/crates/runtime-host/src/execution/provider/options.rs
@@ -19,7 +19,6 @@
 
 use super::{Wire, unavailable};
 use crate::provider_route::Route;
-use crate::session::SessionConfiguration;
 use maka_config::model_catalog::{ProviderFacts, ThinkingOffBehavior};
 use maka_model::ProviderKind;
 use maka_protocol::OperationError;
@@ -35,13 +34,13 @@ mod relay;
 pub(super) fn resolve(
     row: &ConnectionCatalogEntry,
     facts: &ProviderFacts,
-    session: &SessionConfiguration,
+    model: &str,
+    thinking_level: Option<ThinkingLevel>,
     route: &Route,
 ) -> Result<Value, OperationError> {
     let wire = route.wire;
-    let model = &session.model.model;
     let known = facts.models.get(model);
-    let stored = row.models.iter().find(|item| item.id == *model);
+    let stored = row.models.iter().find(|item| item.id == model);
     let parallel = row
         .model_overrides
         .as_ref()
@@ -59,16 +58,16 @@ pub(super) fn resolve(
                 .and_then(|caps| caps.parallel_tool_calls)
         });
     if row.provider_type == "anthropic" {
-        return Ok(anthropic(facts, model, session.thinking_level));
+        return Ok(anthropic(facts, model, thinking_level));
     }
     if row.provider_type == "openai-responses-compatible" && wire == Wire::OpenaiResponses {
-        return relay::responses(row, model, session.thinking_level, parallel.unwrap_or(true));
+        return relay::responses(row, model, thinking_level, parallel.unwrap_or(true));
     }
     if let ProviderKind::OpenaiCompatible { name } = &route.kind {
-        return compatible::chat(name, model, session.thinking_level, parallel);
+        return compatible::chat(name, model, thinking_level, parallel);
     }
     if !matches!(row.provider_type.as_str(), "openai" | "openai-codex") {
-        if session.thinking_level.is_some() || parallel == Some(false) {
+        if thinking_level.is_some() || parallel == Some(false) {
             return Err(unavailable(
                 "Thinking or parallel-call policy is not supported for this provider",
             ));
@@ -77,7 +76,7 @@ pub(super) fn resolve(
     }
     Ok(openai(
         wire,
-        session.thinking_level,
+        thinking_level,
         default_medium(facts, model),
         parallel.unwrap_or(true),
     ))
diff --git a/crates/runtime-host/src/execution/successor.rs b/crates/runtime-host/src/execution/successor.rs
index 877f89ec96..085067604f 100644
--- a/crates/runtime-host/src/execution/successor.rs
+++ b/crates/runtime-host/src/execution/successor.rs
@@ -85,6 +85,7 @@ impl Executions {
                             invocation: next.invocation().clone(),
                             tool_names: next.tool_names().clone(),
                             cancellation: next.cancellation(),
+                            handoff: next.handoff().cloned(),
                             completed: CancellationToken::new(),
                         },
                     );
@@ -108,7 +109,7 @@ impl Executions {
     ) -> Result<Option<RunningInvocation>> {
         let mut environment = None;
         loop {
-            if self.shutdown.is_cancelled() {
+            if !self.accepting() {
                 return Ok(None);
             }
             // The previous worker retains cleanup ownership while preparation
diff --git a/crates/runtime-host/src/execution/workhub.rs b/crates/runtime-host/src/execution/workhub.rs
index faa97633d5..a1baf98ff6 100644
--- a/crates/runtime-host/src/execution/workhub.rs
+++ b/crates/runtime-host/src/execution/workhub.rs
@@ -30,7 +30,7 @@ use maka_runtime::{artifact::content_digest, event::Invocation, workhub::COORDIN
 use std::sync::Arc;
 use uuid::Uuid;
 
-mod profile;
+pub(super) mod profile;
 
 impl Executions {
     pub(crate) fn workhub_target(&self, session: &str) -> Option<Invocation> {
diff --git a/crates/runtime-host/src/execution/workhub/profile.rs b/crates/runtime-host/src/execution/workhub/profile.rs
index 9820a33d36..c280aecb53 100644
--- a/crates/runtime-host/src/execution/workhub/profile.rs
+++ b/crates/runtime-host/src/execution/workhub/profile.rs
@@ -38,7 +38,7 @@ pub(super) fn tools(
     session: &SessionConfiguration,
     connection_id: Uuid,
 ) -> Result<(ToolCatalog, maka_runtime::execution::ToolComposition)> {
-    let (mut tools, clients) = executions
+    let (tools, clients) = executions
         .capabilities
         .bind_required_tools(
             COORDINATION_SESSION_ID,
@@ -59,6 +59,19 @@ pub(super) fn tools(
                 &error.to_string(),
             )
         })?;
+    Ok((
+        catalog(executions, tools)?,
+        maka_runtime::execution::ToolComposition {
+            clients,
+            skills_digest: None,
+        },
+    ))
+}
+
+pub(in crate::execution) fn catalog(
+    executions: &Executions,
+    mut tools: Vec<ToolRegistration>,
+) -> Result<ToolCatalog> {
     tools.retain(|tool| CLIENT_TOOLS.contains(&tool.definition.name.as_str()));
     tools.push(executions.interactions.question_tool());
     tools.push(ToolRegistration {
@@ -71,13 +84,7 @@ pub(super) fn tools(
         nesting: ToolNesting::Nestable,
         semantics: ToolSemantics::Parallel,
     });
-    Ok((
-        ToolCatalog::new(tools).map_err(|e| failure(Code::OperationUnavailable, &e.to_string()))?,
-        maka_runtime::execution::ToolComposition {
-            clients,
-            skills_digest: None,
-        },
-    ))
+    ToolCatalog::new(tools).map_err(|e| failure(Code::OperationUnavailable, &e.to_string()))
 }
 
 pub(super) fn prompt() -> SystemPrompt {
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index 37a1f7e103..a524d67452 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -43,7 +43,7 @@ mod outbound;
 mod projects;
 mod registration;
 mod resources;
-mod retirement;
+pub(crate) mod retirement;
 mod sessions;
 mod skills;
 mod subscriptions;
@@ -91,7 +91,7 @@ impl Default for HostOptions {
 
 pub struct Host {
     options: HostOptions,
-    handshake_gate: Mutex<bool>,
+    retirement: Arc<Mutex<retirement::Phase>>,
     accepted_connections: AtomicUsize,
     started: std::time::Instant,
     accepted_connection: AtomicBool,
@@ -123,6 +123,15 @@ pub struct Host {
     diagnostic_log: Mutex<diagnostics::Log>,
 }
 
+impl Drop for Host {
+    fn drop(&mut self) {
+        // Recovery starts before listener setup. Losing the last Host owner
+        // must cancel it even when no listener ever reached its shutdown path.
+        // Workers retain root authority until their cleanup completes.
+        self.draining.cancel();
+    }
+}
+
 impl Host {
     pub async fn open(root: RootOwner) -> Result<Arc<Self>, HostError> {
         Self::open_with_global_instructions(root, None).await
@@ -205,7 +214,7 @@ impl Host {
         )?);
         let host = Arc::new(Self {
             options,
-            handshake_gate: Mutex::new(false),
+            retirement: interactions.retirement.clone(),
             accepted_connections: AtomicUsize::new(0),
             started: std::time::Instant::now(),
             accepted_connection: AtomicBool::new(false),
@@ -249,6 +258,7 @@ impl Host {
             return Err(error.message.into());
         }
         startup_guard.disarm();
+        host.executions.start_handoff_recovery(host.epoch.clone());
         host.record_diagnostic(format_args!("Host ready: epoch {}", host.epoch));
         Ok(host)
     }
@@ -303,10 +313,8 @@ impl Host {
 
     fn lifecycle(&self) -> Lifecycle {
         if self.draining.is_cancelled()
-            || *self
-                .handshake_gate
-                .lock()
-                .unwrap_or_else(|e| e.into_inner())
+            || *self.retirement.lock().unwrap_or_else(|e| e.into_inner())
+                == retirement::Phase::Retiring
         {
             Lifecycle::Draining
         } else {
diff --git a/crates/runtime-host/src/server/capabilities.rs b/crates/runtime-host/src/server/capabilities.rs
index 00987027cc..c267085edf 100644
--- a/crates/runtime-host/src/server/capabilities.rs
+++ b/crates/runtime-host/src/server/capabilities.rs
@@ -249,7 +249,10 @@ pub(super) async fn execute(
         }
     };
     Ok(match result {
-        Ok(output) => Outcome::success(decode_output(operation, &serde_json::to_value(output)?)?),
+        Ok(output) => {
+            host.executions.request_handoff_recovery();
+            Outcome::success(decode_output(operation, &serde_json::to_value(output)?)?)
+        }
         Err(error) => Outcome::failure(OperationError {
             code: match error {
                 maka_client_capability::Error::Draining => Code::HostDraining,
diff --git a/crates/runtime-host/src/server/connection.rs b/crates/runtime-host/src/server/connection.rs
index 050f1c3388..b646aa22b2 100644
--- a/crates/runtime-host/src/server/connection.rs
+++ b/crates/runtime-host/src/server/connection.rs
@@ -276,10 +276,8 @@ impl Host {
                     .then(|| self.commands.token()),
             });
             in_flight.insert(request.request_id.clone(), request.operation);
-            let draining = {
-                let retiring = self.handshake_gate.lock().unwrap_or_else(|e| e.into_inner());
-                *retiring || self.draining.is_cancelled()
-            };
+            let phase = *self.retirement.lock().unwrap_or_else(|e| e.into_inner());
+            let draining = phase == super::retirement::Phase::Retiring || self.draining.is_cancelled();
             if draining {
                 residency.take();
             }
@@ -293,6 +291,13 @@ impl Host {
                     code: OperationErrorCode::HostDraining,
                     message: "Host is draining".into(),
                 })
+            } else if phase == super::retirement::Phase::Preparing
+                && !super::retirement::allows_preparing(request.operation)
+            {
+                Outcome::failure(OperationError {
+                    code: OperationErrorCode::HostDraining,
+                    message: "Host is preparing a cooperative handoff".into(),
+                })
             } else if !implemented {
                 Outcome::failure(OperationError {
                     code: request.operation.unavailable_error(),
diff --git a/crates/runtime-host/src/server/diagnostics.rs b/crates/runtime-host/src/server/diagnostics.rs
index 7f203b31ba..05c5215f15 100644
--- a/crates/runtime-host/src/server/diagnostics.rs
+++ b/crates/runtime-host/src/server/diagnostics.rs
@@ -30,6 +30,9 @@ pub(super) struct Activity {
 }
 
 impl Activity {
+    pub(super) fn blocks_cooperation(&self, own_commands: usize) -> bool {
+        self.connections > 1 || self.commands > own_commands || self.shells != 0 || self.oauth != 0
+    }
     pub(super) fn blocks_retirement(&self, own_commands: usize) -> bool {
         self.connections > 1 || self.commands > own_commands || self.resident_count() != 0
     }
diff --git a/crates/runtime-host/src/server/dispatch.rs b/crates/runtime-host/src/server/dispatch.rs
index aa6e7c885b..75e754c8be 100644
--- a/crates/runtime-host/src/server/dispatch.rs
+++ b/crates/runtime-host/src/server/dispatch.rs
@@ -213,6 +213,7 @@ impl Host {
                         }
                     }
                     Change::Configuration => {
+                        self.executions.request_handoff_recovery();
                         let revision = self.change_revision.fetch_add(1, Ordering::SeqCst) + 1;
                         let _ = self
                             .changes
diff --git a/crates/runtime-host/src/server/handshake.rs b/crates/runtime-host/src/server/handshake.rs
index e574dbae8e..710c7fcdbf 100644
--- a/crates/runtime-host/src/server/handshake.rs
+++ b/crates/runtime-host/src/server/handshake.rs
@@ -41,16 +41,13 @@ impl Host {
         authority: &Authority,
         connection_id: Uuid,
     ) -> Result<Admission<'_>, HostError> {
-        let mut retiring = self
-            .handshake_gate
-            .lock()
-            .unwrap_or_else(|e| e.into_inner());
+        let mut retiring = self.retirement.lock().unwrap_or_else(|e| e.into_inner());
         let draining = || HostHandshake::Draining {
             host_epoch: self.epoch.clone(),
             composition_id: COMPOSITION_ID.into(),
             composition_revision: "3".into(),
         };
-        if *retiring || self.draining.is_cancelled() {
+        if *retiring != super::retirement::Phase::Ready || self.draining.is_cancelled() {
             return Ok((draining(), None, None));
         }
         let generation_mismatch =
@@ -69,7 +66,7 @@ impl Host {
             && settled
             && self.connections.load(Ordering::SeqCst) == 1
         {
-            *retiring = true;
+            *retiring = super::retirement::Phase::Retiring;
             // Close admission now, but let the owner flush its draining reply before
             // stopping the listener. Errors/drop still finish the reserved retirement.
             return Ok((draining(), None, Some(self.draining.clone().drop_guard())));
@@ -116,7 +113,7 @@ impl Host {
                 composition_id: COMPOSITION_ID.into(),
                 composition_revision: "3".into(),
                 state: Lifecycle::Ready,
-                cooperative_handoff: None,
+                cooperative_handoff: Some(true),
             },
             Some(ConnectionCount(&self.accepted_connections)),
             None,
diff --git a/crates/runtime-host/src/server/interactions.rs b/crates/runtime-host/src/server/interactions.rs
index b48e10261f..4b571b7a48 100644
--- a/crates/runtime-host/src/server/interactions.rs
+++ b/crates/runtime-host/src/server/interactions.rs
@@ -40,6 +40,7 @@ use tokio_util::sync::CancellationToken;
 pub(crate) struct Interactions {
     log: Arc<EventLog>,
     admission: Arc<tokio::sync::Mutex<()>>,
+    pub(crate) retirement: Arc<std::sync::Mutex<super::retirement::Phase>>,
     shutdown: CancellationToken,
     epoch: String,
     catalog: Arc<super::catalog_feed::CatalogFeed>,
@@ -99,6 +100,7 @@ impl Interactions {
         Self {
             log,
             admission: Arc::new(tokio::sync::Mutex::new(())),
+            retirement: Arc::default(),
             shutdown,
             epoch,
             catalog,
diff --git a/crates/runtime-host/src/server/retirement.rs b/crates/runtime-host/src/server/retirement.rs
index 03e65927e5..8c4dbdccd6 100644
--- a/crates/runtime-host/src/server/retirement.rs
+++ b/crates/runtime-host/src/server/retirement.rs
@@ -19,40 +19,199 @@
 
 use super::Host;
 use maka_protocol::{
-    OperationError, OperationErrorCode,
+    OperationError, OperationErrorCode as Code,
     host::{RetirementInput, RetirementResult},
 };
+use std::{collections::BTreeSet, time::Duration};
+
+#[derive(Clone, Copy, Default, PartialEq, Eq)]
+pub(crate) enum Phase {
+    #[default]
+    Ready,
+    Preparing,
+    Retiring,
+}
+
+/// Before the first commit, dropping the request releases its scheduling fence.
+/// Afterwards cleanup and root release must finish even if the requester leaves.
+struct Preparation<'a> {
+    host: &'a Host,
+    committed: bool,
+}
+impl Drop for Preparation<'_> {
+    fn drop(&mut self) {
+        *self
+            .host
+            .retirement
+            .lock()
+            .unwrap_or_else(|e| e.into_inner()) = if self.committed {
+            Phase::Retiring
+        } else {
+            Phase::Ready
+        };
+        if self.committed {
+            self.host.draining.cancel();
+        } else {
+            self.host.executions.request_handoff_recovery();
+        }
+    }
+}
 
 impl Host {
     pub(super) async fn prepare_retirement(
         &self,
         input: RetirementInput,
     ) -> Result<RetirementResult, OperationError> {
-        let _admission = self.executions.lock_admission().await;
-        let mut retiring = self
-            .handshake_gate
-            .lock()
-            .unwrap_or_else(|e| e.into_inner());
-        if input.expected_host_epoch != self.epoch || *retiring || self.draining.is_cancelled() {
-            return Err(OperationError {
-                code: OperationErrorCode::OperationConflict,
-                message: "Host lifetime changed or retirement already began".into(),
-            });
-        }
-        if !input.allow_interrupt_active_tasks && self.activity().blocks_retirement(1) {
-            // Until durable step handoff is installed, this Host does not advertise
-            // it and never interprets permission to cooperate as permission to kill.
-            return Ok(RetirementResult::ActiveTasks);
+        let admission = self.executions.lock_admission().await;
+        let preparation = {
+            let mut phase = self.retirement.lock().unwrap_or_else(|e| e.into_inner());
+            if input.expected_host_epoch != self.epoch
+                || *phase != Phase::Ready
+                || self.draining.is_cancelled()
+            {
+                return Err(failure(
+                    Code::OperationConflict,
+                    "Host lifetime changed or retirement already began",
+                ));
+            }
+            let activity = self.activity();
+            if input.allow_interrupt_active_tasks || !activity.blocks_retirement(1) {
+                *phase = Phase::Retiring;
+                self.draining.cancel();
+                return Ok(prepared());
+            }
+            if input.allow_cooperative_handoff != Some(true) || activity.blocks_cooperation(1) {
+                return Ok(RetirementResult::ActiveTasks);
+            }
+            *phase = Phase::Preparing;
+            Preparation {
+                host: self,
+                committed: false,
+            }
+        };
+        drop(admission);
+        self.cooperate(preparation).await
+    }
+
+    async fn cooperate(
+        &self,
+        mut preparation: Preparation<'_>,
+    ) -> Result<RetirementResult, OperationError> {
+        let handoff_id = uuid::Uuid::new_v4().to_string();
+        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
+        let mut held: Vec<(crate::execution::CooperativeRun, maka_agent::HeldHandoff)> = Vec::new();
+        let mut reserved = BTreeSet::new();
+        loop {
+            let admission = self.executions.lock_admission().await;
+            if self.draining.is_cancelled() || self.activity().blocks_cooperation(1) {
+                return Ok(RetirementResult::ActiveTasks);
+            }
+            let Some(runs) = self.executions.cooperative_runs() else {
+                return Ok(RetirementResult::ActiveTasks);
+            };
+            let mut waiting = Vec::new();
+            for run in runs {
+                if reserved.contains(&run.invocation.run_id) {
+                    continue;
+                }
+                let intent = maka_runtime::handoff::HandoffIntent {
+                    handoff_id: handoff_id.clone(),
+                    host_epoch: self.epoch.clone(),
+                    root_run_id: run.gate.root_run_id().into(),
+                    successor_run_id: uuid::Uuid::new_v4().to_string(),
+                    successor_invocation_id: uuid::Uuid::new_v4().to_string(),
+                    claim_id: uuid::Uuid::new_v4().to_string(),
+                };
+                let Ok(reservation) = run.gate.reserve(intent) else {
+                    return Ok(RetirementResult::ActiveTasks);
+                };
+                reserved.insert(run.invocation.run_id.clone());
+                waiting.push(async move {
+                    match reservation.ready().await {
+                        Some(held) => Ok(Some((run, held))),
+                        None if run.completed.is_cancelled() => Ok(None),
+                        None => Err(()),
+                    }
+                });
+            }
+            if waiting.is_empty() {
+                let mut seals = Vec::new();
+                let mut cleanup = Vec::new();
+                {
+                    // Pair the last activity check with the connection admission
+                    // fence. A callback either retains command residency here or
+                    // observes Retiring before it can enter the dispatcher.
+                    let mut phase = self.retirement.lock().unwrap_or_else(|e| e.into_inner());
+                    if self.draining.is_cancelled() || self.activity().blocks_cooperation(1) {
+                        return Ok(RetirementResult::ActiveTasks);
+                    }
+                    // All admitted owners are held or have completed. Commit is
+                    // synchronous under admission; cancellation is the only race.
+                    for (run, held) in held {
+                        if let Some(seal) = held.commit() {
+                            preparation.committed = true;
+                            seals.push(seal);
+                        }
+                        cleanup.push(run.completed);
+                    }
+                    // Even an empty set can retire: all work completed during preparation.
+                    preparation.committed = true;
+                    *phase = Phase::Retiring;
+                }
+                drop(admission);
+                let sealed =
+                    futures_util::future::join_all(seals.into_iter().map(|seal| seal.wait())).await;
+                futures_util::future::join_all(cleanup.iter().map(|done| done.cancelled())).await;
+                if sealed.iter().any(Option::is_none) {
+                    return Err(failure(
+                        Code::HostDraining,
+                        "Cooperative seal was not confirmed; Host is draining for recovery",
+                    ));
+                }
+                self.record_diagnostic("Host cooperative retirement sealed; releasing owned work");
+                return Ok(prepared());
+            }
+            drop(admission);
+            match tokio::time::timeout_at(deadline, futures_util::future::try_join_all(waiting))
+                .await
+            {
+                Ok(Ok(ready)) => held.extend(ready.into_iter().flatten()),
+                _ => return Ok(RetirementResult::ActiveTasks),
+            }
         }
-        *retiring = true;
-        self.draining.cancel();
-        drop(retiring);
-        drop(_admission);
-        self.record_diagnostic("Host retirement committed; draining owned work");
-        // The accepted request token protects this reply until flushed/abandoned.
-        // Root release still waits for execution, native I/O and storage cleanup.
-        Ok(RetirementResult::Prepared {
-            pid: std::num::NonZeroU32::new(std::process::id()).expect("process PID"),
-        })
+    }
+}
+
+fn prepared() -> RetirementResult {
+    RetirementResult::Prepared {
+        pid: std::num::NonZeroU32::new(std::process::id()).expect("process PID"),
+    }
+}
+
+pub(super) fn allows_preparing(operation: maka_protocol::Operation) -> bool {
+    use maka_protocol::{Operation::*, operation::OperationMode};
+    operation.mode() == OperationMode::Query
+        || matches!(
+            operation,
+            InteractionAnswer
+                | TurnStop
+                | TurnInterrupt
+                | RuntimeResourceStop
+                | RuntimeResourceControllerAcquire
+                | RuntimeResourceControllerControl
+                | RuntimeResourceControllerRelease
+                | OauthLoginCancel
+                | SubscriptionOpen
+                | SubscriptionClose
+                | SubscriptionPtyInterestSet
+                | SessionTranscriptOverlayRelease
+                | WorkhubCoordinationActFromTurn
+                | WorkhubCoordinationSelectAndDelegate
+        )
+}
+fn failure(code: Code, message: &str) -> OperationError {
+    OperationError {
+        code,
+        message: message.into(),
     }
 }
diff --git a/crates/runtime-host/src/server/workhub.rs b/crates/runtime-host/src/server/workhub.rs
index e69b6f5fbd..b47347ae44 100644
--- a/crates/runtime-host/src/server/workhub.rs
+++ b/crates/runtime-host/src/server/workhub.rs
@@ -71,14 +71,34 @@ pub(super) async fn execute(
         }),
         Operation::WorkhubCoordinationQuery => query(host).await.and_then(serialize),
         Operation::WorkhubCoordinationActFromTurn => {
-            action::act(host, workhub::decode_act(value)?, connection_id)
+            let input = workhub::decode_act(value)?;
+            if let Err(error) = admit_callback(
+                host,
+                connection_id,
+                &input.turn_id,
+                input.action_id.as_str(),
+            )
+            .await
+            {
+                return Ok(Outcome::failure(error));
+            }
+            action::act(host, input, connection_id)
                 .await
                 .and_then(serialize)
         }
         Operation::WorkhubCoordinationSelectAndDelegate => {
-            selection::select(host, workhub::decode_selection(value)?)
-                .await
-                .and_then(serialize)
+            let input = workhub::decode_selection(value)?;
+            if let Err(error) = admit_callback(
+                host,
+                connection_id,
+                &input.turn_id,
+                input.action_id.as_str(),
+            )
+            .await
+            {
+                return Ok(Outcome::failure(error));
+            }
+            selection::select(host, input).await.and_then(serialize)
         }
         Operation::WorkhubCoordinationCandidates => candidates::query(host)
             .await
@@ -290,6 +310,37 @@ async fn resolve(host: &Host) -> Result<(), OperationError> {
     validate(&record)
 }
 
+/// Ordinary RPC residency pins this check through the eventual reply. During
+/// preparation only the exact already-admitted client tool may create work.
+async fn admit_callback(
+    host: &Host,
+    connection: uuid::Uuid,
+    turn: &str,
+    action: &str,
+) -> Result<(), OperationError> {
+    let _admission = host.executions.lock_admission().await;
+    let phase = *host.retirement.lock().unwrap_or_else(|e| e.into_inner());
+    match phase {
+        super::retirement::Phase::Ready if !host.draining.is_cancelled() => Ok(()),
+        super::retirement::Phase::Preparing
+            if host.capabilities.broker.admitted_tool(
+                connection,
+                COORDINATION_SESSION_ID,
+                turn,
+                action,
+                "desktop_workhub",
+                "tasks",
+            ) =>
+        {
+            Ok(())
+        }
+        _ => Err(failure(
+            Code::HostDraining,
+            "Host is not admitting new WorkHub work",
+        )),
+    }
+}
+
 fn failure(code: Code, message: impl Into<String>) -> OperationError {
     OperationError {
         code,
diff --git a/crates/runtime-host/tests/integration/handoff.rs b/crates/runtime-host/tests/integration/handoff.rs
index 2367e149f3..3f853e38b6 100644
--- a/crates/runtime-host/tests/integration/handoff.rs
+++ b/crates/runtime-host/tests/integration/handoff.rs
@@ -33,6 +33,256 @@ use serde_json::json;
 use std::{num::NonZeroU16, time::Duration};
 use tokio_util::sync::CancellationToken;
 
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn cooperative_retirement_recovers_frozen_step_without_repeating_effects() {
+    let fixture = ClientFixture::new("maka-cooperative-");
+    let (provider, mut requests) = super::support::message_recovery::Provider::controlled().await;
+    let model = super::support::message_recovery::configure(&fixture, &provider.base_url).await;
+    let cwd = fixture.workspace.to_string_lossy().into_owned();
+    let configuration = PreparedSession::new(
+        serde_json::from_value(json!({
+            "sessionId":"session", "workspace":{"kind":"host_path", "path":cwd},
+            "modelTarget":{"kind":"default"}
+        }))
+        .unwrap(),
+    )
+    .unwrap()
+    .bind(
+        WorkspaceProjection {
+            target: WorkspaceTarget::HostPath { path: cwd.clone() },
+            host_cwd: cwd,
+        },
+        model,
+        PermissionMode::Bypass,
+        ToolMode::Direct,
+    );
+    let log = fixture.log().await;
+    log.create_session("session", "fixture", &configuration, 1)
+        .await
+        .unwrap();
+    log.close().await.unwrap();
+
+    #[cfg(unix)]
+    let endpoint = fixture.workspace.parent().unwrap().join("cooperative.sock");
+    #[cfg(windows)]
+    let endpoint = std::path::PathBuf::from(format!(
+        r"\\.\pipe\maka-cooperative-{}",
+        uuid::Uuid::new_v4()
+    ));
+    let host = Host::open(fixture.owner()).await.unwrap();
+    let server = tokio::spawn(
+        LocalListener::bind(&endpoint)
+            .unwrap()
+            .serve(host.clone(), CancellationToken::new()),
+    );
+    let (mut peer, hello) = Peer::handshake(host.clone(), "cooperative").await;
+    assert_eq!(hello["cooperativeHandoff"], true, "{hello}");
+    let publication = json!({"registrationId":"before-upgrade", "offers":[{
+        "offerId":"desktop", "version":"1", "affinity":"session", "hostPathAccess":"none",
+        "label":"Desktop", "tools":[{"serverId":"desktop", "name":"inspect",
+            "inputSchema":{"type":"object"}}]
+    }]});
+    let registered = peer
+        .rpc("client.capability.replace", publication.clone())
+        .await;
+    assert_eq!(registered["ok"], true, "{registered}");
+    let started = peer
+        .rpc(
+            "turn.start",
+            json!({"sessionId":"session", "turnId":"turn",
+        "content":{"text":"write once, then finish"}, "maxSteps":3}),
+        )
+        .await;
+    assert_eq!(started["result"]["kind"], "started", "{started}");
+    let public_run = started["result"]["turn"]["runId"].clone();
+    let first = tokio::time::timeout(Duration::from_secs(5), requests.recv())
+        .await
+        .unwrap()
+        .unwrap();
+    // No safe boundary exists while this request is still outstanding. The
+    // bounded preparation must release its ticket without ending the Run.
+    let rolled_back = peer
+        .rpc(
+            "host.upgrade.prepare",
+            json!({
+                "expectedHostEpoch":hello["hostEpoch"], "allowInterruptActiveTasks":false,
+                "allowCooperativeHandoff":true,
+            }),
+        )
+        .await;
+    assert_eq!(
+        rolled_back["result"]["kind"], "active_tasks",
+        "{rolled_back}"
+    );
+    let probe = Peer::new(host.clone(), "rollback-probe").await;
+    probe.close().await;
+    assert_eq!(
+        provider.requests.lock().unwrap().len(),
+        1,
+        "rollback must not retry the model"
+    );
+    let retire = |mut peer: Peer| {
+        let epoch = hello["hostEpoch"].clone();
+        tokio::spawn(async move {
+            let receipt = peer
+                .rpc(
+                    "host.upgrade.prepare",
+                    json!({
+                        "expectedHostEpoch":epoch, "allowInterruptActiveTasks":false,
+                        "allowCooperativeHandoff":true,
+                    }),
+                )
+                .await;
+            (receipt, peer)
+        })
+    };
+    let mut retiring = retire(peer);
+    // Hello admission observes the fence without adding an ordinary command
+    // that would itself make cooperative preparation busy.
+    tokio::time::timeout(Duration::from_secs(5), async {
+        loop {
+            let (probe, hello) = Peer::handshake(host.clone(), "fence-probe").await;
+            probe.close().await;
+            if hello["kind"] == "draining" {
+                break;
+            }
+            if retiring.is_finished() {
+                // A hello that won the Ready race was another live client.
+                // Retry only after closing it, preserving interruption consent.
+                let (receipt, peer) = (&mut retiring).await.unwrap();
+                assert_eq!(receipt["result"]["kind"], "active_tasks", "{receipt}");
+                retiring = retire(peer);
+            }
+            tokio::time::sleep(Duration::from_millis(10)).await;
+        }
+    })
+    .await
+    .unwrap();
+    first
+        .reply
+        .send(json!({"index":0,"delta":{"tool_calls":[{
+        "index":0,"id":"write-once","type":"function","function":{
+            "name":"Write","arguments":json!({"path":"effect.txt","content":"once"}).to_string()
+        }
+    }]},"finish_reason":"tool_calls"}))
+        .unwrap();
+    let (receipt, peer) = retiring.await.unwrap();
+    peer.close().await;
+    assert_eq!(receipt["result"]["kind"], "prepared", "{receipt}");
+    tokio::time::timeout(Duration::from_secs(10), server)
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    drop(host);
+    assert_eq!(
+        std::fs::read_to_string(fixture.workspace.join("effect.txt")).unwrap(),
+        "once"
+    );
+    assert!(
+        requests.try_recv().is_err(),
+        "retiring Host must not start another model step"
+    );
+    let log = fixture.log().await;
+    let pending = log.pending_handoffs(0).await.unwrap();
+    assert_eq!(pending.len(), 1);
+    let source = pending[0].invocation.clone();
+    assert_eq!(public_run, source.run_id);
+    let prefix = log.prefix(100, 1024 * 1024).await.unwrap();
+    assert!(matches!(
+        prefix.events.last().unwrap().event.fact,
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::HandoffPaused { .. }
+        }
+    ));
+    log.close().await.unwrap();
+
+    let host = Host::open(fixture.owner()).await.unwrap();
+    let cancel = CancellationToken::new();
+    let server = tokio::spawn(
+        LocalListener::bind(&endpoint)
+            .unwrap()
+            .serve(host.clone(), cancel.clone()),
+    );
+    // Ready does not depend on a disconnected client. An identical offer from
+    // another client cannot substitute for the original admitted owner.
+    let mut wrong = Peer::new(host.clone(), "another-desktop").await;
+    let registered = wrong
+        .rpc("client.capability.replace", publication.clone())
+        .await;
+    assert_eq!(registered["ok"], true, "{registered}");
+    assert!(
+        tokio::time::timeout(Duration::from_millis(500), requests.recv())
+            .await
+            .is_err(),
+        "successor ran without its original capability owner"
+    );
+    wrong.close().await;
+    let mut peer = Peer::new(host.clone(), "cooperative").await;
+    let mut publication = publication;
+    publication["registrationId"] = json!("after-upgrade");
+    let registered = peer.rpc("client.capability.replace", publication).await;
+    assert_eq!(registered["ok"], true, "{registered}");
+    let second = tokio::time::timeout(Duration::from_secs(10), requests.recv())
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        second.body["messages"]
+            .as_array()
+            .unwrap()
+            .iter()
+            .filter(|message| message["role"] == "tool" && message["tool_call_id"] == "write-once")
+            .count(),
+        1,
+        "successor must inherit the settled result"
+    );
+    second
+        .reply
+        .send(json!({"index":0,"delta":{"content":"finished"},"finish_reason":"stop"}))
+        .unwrap();
+    tokio::time::timeout(Duration::from_secs(5), async {
+        loop {
+            let turn = peer
+                .rpc("turn.query", json!({"sessionId":"session","turnId":"turn"}))
+                .await;
+            assert_eq!(turn["result"]["runId"], public_run, "{turn}");
+            assert_ne!(turn["result"]["status"], "failed", "{turn}");
+            if turn["result"]["status"] == "completed" {
+                break;
+            }
+            tokio::time::sleep(Duration::from_millis(10)).await;
+        }
+    })
+    .await
+    .unwrap();
+    peer.close().await;
+    cancel.cancel();
+    tokio::time::timeout(Duration::from_secs(10), server)
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    drop(host);
+    let log = fixture.log().await;
+    assert!(log.pending_handoffs(0).await.unwrap().is_empty());
+    let prefix = log.prefix(100, 1024 * 1024).await.unwrap();
+    assert_eq!(
+        prefix
+            .events
+            .iter()
+            .filter(|event| matches!(&event.event.fact,
+        Fact::ToolDispatched { name, .. } if name == "Write"))
+            .count(),
+        1
+    );
+    let owner = log.handoff_owner(&source).await.unwrap();
+    assert_ne!(owner.invocation.run_id, source.run_id);
+    assert_eq!(owner.invocation.turn_id, source.turn_id);
+    log.close().await.unwrap();
+    assert_eq!(provider.requests.lock().unwrap().len(), 2);
+}
+
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn stop_sealed_turn_uses_public_identity_without_provider_and_survives_restart() {
     for queued in [false, true] {
diff --git a/crates/runtime-host/tests/integration/retirement.rs b/crates/runtime-host/tests/integration/retirement.rs
index 2c4562a5aa..a171788cac 100644
--- a/crates/runtime-host/tests/integration/retirement.rs
+++ b/crates/runtime-host/tests/integration/retirement.rs
@@ -89,6 +89,19 @@ async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_aba
         };
         let root = directory.path().join("root");
         let owner = RootOwner::create(&root, &namespaces).unwrap();
+        // Listener startup can fail after Host recovery has started. Dropping
+        // that unused Host must release its idle worker and root authority.
+        drop(Host::open(owner).await.unwrap());
+        let owner = tokio::time::timeout(Duration::from_secs(5), async {
+            loop {
+                if let Ok(owner) = RootOwner::open(&root, &namespaces) {
+                    break owner;
+                }
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+        })
+        .await
+        .expect("unused Host retained the root");
         let host = Host::open(owner).await.unwrap();
         #[cfg(unix)]
         let endpoint = directory.path().join("h.sock");
diff --git a/crates/runtime-host/tests/integration/support/peer.rs b/crates/runtime-host/tests/integration/support/peer.rs
index 9fd75423a5..11bf9e0bb3 100644
--- a/crates/runtime-host/tests/integration/support/peer.rs
+++ b/crates/runtime-host/tests/integration/support/peer.rs
@@ -47,6 +47,11 @@ pub struct Peer {
 }
 impl Peer {
     pub async fn new(host: Arc<Host>, id: &str) -> Self {
+        let (peer, hello) = Self::handshake(host, id).await;
+        assert_eq!(hello["state"], "ready", "{hello}");
+        peer
+    }
+    pub async fn handshake(host: Arc<Host>, id: &str) -> (Self, Value) {
         let (send, reader) = mpsc::unbounded_channel();
         let (writer, receive) = mpsc::unbounded_channel();
         let task = tokio::spawn(async move {
@@ -66,11 +71,11 @@ impl Peer {
             "compatibilityEpoch":maka_protocol::COMPATIBILITY_EPOCH,"compositionId":"maka.interactive"}),
             )
             .unwrap();
-        assert_eq!(peer.frame().await["state"], "ready");
-        peer
+        let hello = peer.frame().await;
+        (peer, hello)
     }
     async fn frame(&mut self) -> Value {
-        tokio::time::timeout(Duration::from_secs(5), self.receive.recv())
+        tokio::time::timeout(Duration::from_secs(15), self.receive.recv())
             .await
             .unwrap()
             .expect("Host closed before response")
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 5adc1f12e6..6beb718fc8 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -73,8 +73,11 @@ Do not run untrusted code or point test instances at existing user data.
   Sealed work can be cancelled without loading a provider or repeating effects.
 - Host diagnostics and retirement share active-work accounting, including pending
   OAuth authorization. Retirement targets an exact Host epoch and retains authority
-  through response flush and resource cleanup. Cooperative step handoff is not yet
-  advertised. The legacy `nodeVersion` field reports `not applicable (Rust)`.
+  through response flush and resource cleanup. Cooperative handoff seals settled
+  steps and resumes their frozen composition after restart; missing client owners
+  leave work paused. Preparation can be withdrawn before sealing and never grants
+  permission to interrupt other connected clients, PTYs or OAuth flows.
+  The legacy `nodeVersion` field reports `not applicable (Rust)`.
 - Manual resume checks a sealed source without claiming it, then atomically opens
   a new continuation. Replay follows the selected lineage, excluding later branches
   and unfinished response fragments. Unknown effects block admission; repeating
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 1b31aeb5c6..628bbfb17d 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -64,8 +64,9 @@ Windows 使用私有 named pipe。可选 `--websocket 127.0.0.1:0` 监听需要
   身份保持独立。协作续接保留对外 Run 身份;恢复凭证与清理仍绑定精确物理 Run。
   已封口的任务可直接取消,无需加载 provider,也不重做副作用。
 - Host 诊断与退出共用活动工作统计,包含等待授权的 OAuth 登录。退出绑定精确 Host epoch,
-  回复发送与资源清理完成前不释放 authority。尚未公布协作步骤交接能力;旧协议字段
-  `nodeVersion` 如实返回 `not applicable (Rust)`。
+  回复发送与资源清理完成前不释放 authority。协作交接在步骤结算后封口,重启按冻结组合续接;
+  缺少原客户端所有者时保持暂停。封口前可撤销准备,协作许可不授予中断其它客户端连接、PTY
+  或 OAuth 流程的权限。旧协议字段 `nodeVersion` 如实返回 `not applicable (Rust)`。
 - 手动 resume 先只读检查已封口的源,再原子开启续跑。模型只回放选定谱系,不混入后来的旁支或
   未完成的响应片段。未知副作用阻止准入;重复已接受的请求返回原 Turn,重启后仍然如此。
 - 类型化模型列表贯通发现、存储和目录投影,连接自有声明单独保存;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 28/29: fix(host): distinguish service residency from ephemeral ownership

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 50158c161d6bb1de966faa1a15d1aaacc1d1b815
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 00:37:51 2026 +0800

    fix(host): distinguish service residency from ephemeral ownership
    
    Generated-by: OpenAI Codex
---
 crates/cli/src/candidate.rs                    |  1 +
 crates/cli/src/host_client.rs                  |  8 ++++++
 crates/cli/src/serve.rs                        |  1 +
 crates/cli/tests/integration/candidate.rs      |  8 ++++++
 crates/cli/tests/integration/deployment.rs     |  8 ++++++
 crates/runtime-host/src/server.rs              | 11 ++++++++
 crates/runtime-host/src/server/handshake.rs    |  7 ++---
 crates/runtime-host/src/server/registration.rs |  2 +-
 tests/fixtures/client-activation-entry.mjs     | 37 +++++++++++++++++++++++---
 9 files changed, 76 insertions(+), 7 deletions(-)

diff --git a/crates/cli/src/candidate.rs b/crates/cli/src/candidate.rs
index 33b99e8246..c5295828ce 100644
--- a/crates/cli/src/candidate.rs
+++ b/crates/cli/src/candidate.rs
@@ -80,6 +80,7 @@ impl Candidate {
             root,
             super::serve::global_instructions()?,
             HostOptions {
+                lifecycle_mode: maka_runtime_host::server::LifecycleMode::Ephemeral,
                 skill_home: super::serve::home_directory()?,
                 project_directory_roots: deployment
                     .as_ref()
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
index db871c1201..66a6e064a9 100644
--- a/crates/cli/src/host_client.rs
+++ b/crates/cli/src/host_client.rs
@@ -46,6 +46,7 @@ pub(super) type Stream = tokio::net::windows::named_pipe::NamedPipeClient;
 #[serde(rename_all = "camelCase")]
 struct Discovery {
     root_id: String,
+    generation: Option<String>,
     host_epoch: String,
     endpoint: PathBuf,
     pid: NonZeroU32,
@@ -76,6 +77,13 @@ impl HostClient {
         let root = root.to_owned();
         tokio::time::timeout(Duration::from_secs(5), async move {
             let discovery = tokio::task::spawn_blocking(move || read_discovery(&root)).await??;
+            // Desktop generations do not control a service's lifetime. Native
+            // operators still require the exact admitted deployment revision;
+            // the live Root/epoch handshake below binds this discovery record.
+            if generation.is_some_and(|expected| discovery.generation.as_deref() != Some(expected))
+            {
+                return Err("Host discovery belongs to another deployment revision".into());
+            }
             let stream = open_stream(&discovery.endpoint).await?;
             let (mut reader, mut writer) =
                 maka_transport::ndjson::split(stream, CancellationToken::new());
diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs
index 380d84dde8..e3426adc57 100644
--- a/crates/cli/src/serve.rs
+++ b/crates/cli/src/serve.rs
@@ -70,6 +70,7 @@ pub(super) async fn run(
         owner,
         global_instructions()?,
         maka_runtime_host::server::HostOptions {
+            lifecycle_mode: maka_runtime_host::server::LifecycleMode::Service,
             skill_home: home_directory()?,
             project_directory_roots: deployment
                 .as_ref()
diff --git a/crates/cli/tests/integration/candidate.rs b/crates/cli/tests/integration/candidate.rs
index 819f82a998..468bbd64ee 100644
--- a/crates/cli/tests/integration/candidate.rs
+++ b/crates/cli/tests/integration/candidate.rs
@@ -75,6 +75,14 @@ fn candidate_preserves_root_authority_and_drains_on_owner_loss_or_released_idle(
         let registration = fixture.wait_for_registration();
         assert_eq!(registration["pid"], fixture.child.as_ref().unwrap().id());
         assert_eq!(registration["rootId"], fixture.root_id);
+        assert_eq!(
+            registration["lifecycleMode"],
+            if matches!(end, End::Standalone) {
+                "service"
+            } else {
+                "ephemeral"
+            }
+        );
         if !matches!(end, End::Standalone) {
             assert_eq!(registration["generation"], "native-cli-test");
         }
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index 36ea4e57a3..e0833e87c1 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -180,6 +180,10 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
                 .unwrap(),
         );
         let registration = fixture.wait_for_registration();
+        assert_eq!(
+            registration["lifecycleMode"],
+            if candidate { "ephemeral" } else { "service" }
+        );
         assert_eq!(
             registration["generation"],
             format!("{}:1", installed["deploymentId"].as_str().unwrap())
@@ -299,6 +303,10 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
             .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/client.mjs"))
             .arg("--activation-frame")
             .arg(String::from_utf8(activated.stdout).unwrap())
+            .args([
+                "--lifecycle-mode",
+                if candidate { "ephemeral" } else { "service" },
+            ])
             .arg("--root")
             .arg(&fixture.root)
             .output()
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index b87db56d20..fc96afb63b 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -71,7 +71,17 @@ pub use operations::Operations as HostOperations;
 pub use projects::DirectoryRootSpec;
 pub use registration::Registration;
 
+/// Who owns process lifetime, independent of its deployment revision.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LifecycleMode {
+    #[default]
+    Ephemeral,
+    Service,
+}
+
 pub struct HostOptions {
+    pub lifecycle_mode: LifecycleMode,
     pub project_directory_roots: Option<Vec<DirectoryRootSpec>>,
     /// Explicit user skill root; library embedders do not inspect ambient home.
     pub skill_home: Option<std::path::PathBuf>,
@@ -82,6 +92,7 @@ pub struct HostOptions {
 impl Default for HostOptions {
     fn default() -> Self {
         Self {
+            lifecycle_mode: LifecycleMode::Ephemeral,
             project_directory_roots: None,
             skill_home: None,
             generation: None,
diff --git a/crates/runtime-host/src/server/handshake.rs b/crates/runtime-host/src/server/handshake.rs
index 710c7fcdbf..cfb650fc71 100644
--- a/crates/runtime-host/src/server/handshake.rs
+++ b/crates/runtime-host/src/server/handshake.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{ConnectionCount, Host, HostError, authority::Authority};
+use super::{ConnectionCount, Host, HostError, LifecycleMode, authority::Authority};
 use maka_protocol::{
     COMPATIBILITY_EPOCH, COMPOSITION_ID,
     handshake::{ClientHello, HostHandshake, Lifecycle, ProtocolRange, Replacement},
@@ -50,8 +50,9 @@ impl Host {
         if *retiring != super::retirement::Phase::Ready || self.draining.is_cancelled() {
             return Ok((draining(), None, None));
         }
+        let ephemeral = self.options.lifecycle_mode == LifecycleMode::Ephemeral;
         let generation_mismatch =
-            hello.generation.is_some() && hello.generation != self.options.generation;
+            ephemeral && hello.generation.is_some() && hello.generation != self.options.generation;
         let local_owner = matches!(authority, Authority::LocalOwner);
         let settled = self.accepted_connections.load(Ordering::SeqCst) == 0
             && self.requests.is_empty()
@@ -88,7 +89,7 @@ impl Host {
                     composition_id: COMPOSITION_ID.into(),
                     composition_revision: "3".into(),
                     state: Lifecycle::Ready,
-                    replacement: if settled {
+                    replacement: if ephemeral && settled {
                         Replacement::WaitForIdleExit
                     } else {
                         Replacement::BlockedByResidency
diff --git a/crates/runtime-host/src/server/registration.rs b/crates/runtime-host/src/server/registration.rs
index 4446b73c5d..f9b9ced75f 100644
--- a/crates/runtime-host/src/server/registration.rs
+++ b/crates/runtime-host/src/server/registration.rs
@@ -48,7 +48,7 @@ impl Host {
             "kind":"maka-runtime-host", "schemaVersion":1, "rootId":self.root_id(),
             "hostEpoch":self.epoch, "endpoint":endpoint, "protocolMin":0, "protocolMax":0,
             "compatibilityEpoch":COMPATIBILITY_EPOCH, "compositionId":COMPOSITION_ID,
-            "compositionRevision":"3", "lifecycleMode":"ephemeral", "state":"ready",
+            "compositionRevision":"3", "lifecycleMode":self.options.lifecycle_mode, "state":"ready",
             "pid":std::process::id(),
             "createdAt":time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?,
         });
diff --git a/tests/fixtures/client-activation-entry.mjs b/tests/fixtures/client-activation-entry.mjs
index b34c938b08..7e4e075934 100644
--- a/tests/fixtures/client-activation-entry.mjs
+++ b/tests/fixtures/client-activation-entry.mjs
@@ -25,6 +25,7 @@ import { decodeRuntimeHostActivationFrame } from '../../packages/runtime-host/sr
 const { values } = parseArgs({
   options: {
     'activation-frame': { type: 'string' },
+    'lifecycle-mode': { type: 'string' },
     root: { type: 'string' },
   },
 });
@@ -38,14 +39,44 @@ const input = {
 };
 const connection = await connectExistingRuntimeHost(input);
 assert.equal(connection.kind, 'connected', JSON.stringify(connection));
+assert.equal(connection.registration.lifecycleMode, values['lifecycle-mode']);
 try {
   assert.equal(connection.connection.rootId, frame.rootId);
   assert.equal(connection.connection.hostEpoch, frame.hostEpoch);
   const diagnostics = await connection.connection.request('host.diagnostics.query', {});
   assert.equal(diagnostics.pid, frame.pid);
-  const stale = await connectExistingRuntimeHost({ ...input, generation: 'stale-deployment' });
-  assert.equal(stale.kind, 'upgrade_required');
-  assert.equal(stale.handshake.generation, input.generation, 'live handshake rejected stale code');
+  const stale = await connectExistingRuntimeHost({
+    ...input,
+    generation: 'stale-deployment',
+    ...(values['lifecycle-mode'] === 'service' ? { takeoverHostEpoch: frame.hostEpoch } : {}),
+  });
+  if (values['lifecycle-mode'] === 'service') {
+    assert.equal(stale.kind, 'connected', 'Desktop generation cannot take over a service');
+    try {
+      assert.equal(stale.connection.hostEpoch, frame.hostEpoch);
+    } finally {
+      await stale.connection.close();
+    }
+    await connection.connection.close();
+    const deadline = Date.now() + 3000;
+    for (;;) {
+      const incompatible = await connectExistingRuntimeHost({
+        ...input,
+        protocol: { min: 1, max: 1 },
+      });
+      assert.equal(incompatible.kind, 'incompatible');
+      assert.equal(incompatible.handshake.replacement, 'blocked_by_residency');
+      if (incompatible.handshake.activity?.connections === 0) break;
+      assert(Date.now() < deadline, 'previous service clients did not finish closing');
+    }
+  } else {
+    assert.equal(stale.kind, 'upgrade_required');
+    assert.equal(
+      stale.handshake.generation,
+      input.generation,
+      'live handshake rejected stale code',
+    );
+  }
 } finally {
   await connection.connection.close();
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 02/29: feat(agent): seal cooperative handoffs at settled step boundaries

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 939919e96ee8abc7db10cae93d8f1da25e35a804
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 10:30:58 2026 +0800

    feat(agent): seal cooperative handoffs at settled step boundaries
---
 crates/agent/src/handoff.rs                        | 300 +++++++++++++++++++++
 crates/agent/src/lib.rs                            |  13 +-
 crates/agent/src/runner.rs                         |  15 +-
 crates/agent/src/running.rs                        |   7 +
 crates/agent/src/steps.rs                          |  18 +-
 crates/agent/tests/integration/agent_loop.rs       |  19 +-
 crates/agent/tests/integration/handoff.rs          | 129 +++++++++
 crates/agent/tests/integration/main.rs             |   2 +
 .../agent/tests/integration/support/agent_loop.rs  |  21 +-
 crates/event-log/src/append.rs                     |   1 +
 crates/event-log/src/archive/candidates.rs         |   2 +-
 crates/event-log/src/context/safety.rs             |   2 +-
 crates/event-log/src/handoff.rs                    |  91 +++++++
 crates/event-log/src/lib.rs                        |   1 +
 crates/event-log/src/sessions/execution.rs         |   1 +
 crates/event-log/tests/continuation.rs             |   2 +
 crates/event-log/tests/continuation/handoff.rs     | 145 ++++++++++
 crates/presentation/src/lib.rs                     |  23 +-
 crates/presentation/src/message.rs                 |   7 +-
 crates/presentation/tests/projection.rs            |  43 ++-
 crates/runtime-host/src/execution/resume.rs        |   8 +
 crates/runtime-host/src/execution/snapshot.rs      |   1 +
 .../runtime-host/src/server/sessions/projection.rs |   6 +-
 crates/runtime/src/event.rs                        |   6 +
 crates/runtime/src/handoff.rs                      |  86 ++++++
 crates/runtime/src/lib.rs                          |   1 +
 26 files changed, 902 insertions(+), 48 deletions(-)

diff --git a/crates/agent/src/handoff.rs b/crates/agent/src/handoff.rs
new file mode 100644
index 0000000000..456b7c7ef1
--- /dev/null
+++ b/crates/agent/src/handoff.rs
@@ -0,0 +1,300 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_runtime::handoff::{HandoffIntent, HandoffPause};
+use std::{num::NonZeroU16, sync::Arc};
+use tokio::sync::watch;
+use tokio_util::sync::CancellationToken;
+
+#[derive(Clone, Debug)]
+enum State {
+    Running,
+    Requested(Arc<HandoffIntent>),
+    Held {
+        pause: HandoffPause,
+        cancellation: CancellationToken,
+        ticket: Arc<HandoffIntent>,
+    },
+    Committed(HandoffPause),
+    Sealed(HandoffPause),
+    Closed,
+}
+
+/// Scheduling only. No gate state grants durable continuation authority.
+#[derive(Clone)]
+pub struct HandoffGate {
+    state: watch::Sender<State>,
+    source: Arc<maka_runtime::event::Invocation>,
+    root_run_id: String,
+}
+
+/// Dropping preparation releases the same Run, without a terminal fact.
+pub struct HandoffReservation {
+    gate: HandoffGate,
+    ticket: Arc<HandoffIntent>,
+}
+
+/// Only a currently held, fully settled step can request a durable seal.
+pub struct HeldHandoff {
+    reservation: HandoffReservation,
+    pause: HandoffPause,
+}
+
+pub struct PendingSeal {
+    gate: HandoffGate,
+    pause: HandoffPause,
+}
+
+impl HandoffGate {
+    pub(crate) fn new(source: maka_runtime::event::Invocation, root_run_id: String) -> Self {
+        Self {
+            state: watch::channel(State::Running).0,
+            source: Arc::new(source),
+            root_run_id,
+        }
+    }
+
+    pub fn reserve(&self, intent: HandoffIntent) -> Result<HandoffReservation, crate::RunError> {
+        intent
+            .validate(&self.source)
+            .map_err(|e| crate::RunError::InvalidInput(e.into()))?;
+        if intent.root_run_id != self.root_run_id {
+            return Err(crate::RunError::InvalidInput(
+                "handoff changes the logical Run".into(),
+            ));
+        }
+        let ticket = Arc::new(intent);
+        self.state
+            .send_if_modified(|state| {
+                if !matches!(state, State::Running) {
+                    return false;
+                }
+                *state = State::Requested(ticket.clone());
+                true
+            })
+            .then(|| HandoffReservation {
+                gate: self.clone(),
+                ticket,
+            })
+            .ok_or(crate::RunError::Busy)
+    }
+
+    pub(crate) async fn boundary(
+        &self,
+        remaining_steps: NonZeroU16,
+        cancellation: &CancellationToken,
+    ) -> Option<HandoffPause> {
+        let mut changes = self.state.subscribe();
+        let mut ticket = None;
+        self.state.send_if_modified(|state| {
+            let State::Requested(intent) = state else {
+                return false;
+            };
+            ticket = Some(intent.clone());
+            *state = State::Held {
+                pause: HandoffPause {
+                    intent: intent.as_ref().clone(),
+                    remaining_steps,
+                },
+                cancellation: cancellation.clone(),
+                ticket: intent.clone(),
+            };
+            true
+        });
+        let ticket = ticket?;
+        loop {
+            let state = changes.borrow_and_update().clone();
+            match state {
+                State::Committed(pause) if pause.intent == *ticket => return Some(pause),
+                State::Held {
+                    ticket: current, ..
+                } if Arc::ptr_eq(&current, &ticket) => {}
+                _ => return None,
+            }
+            tokio::select! {
+                _ = changes.changed() => {},
+                _ = cancellation.cancelled() => self.cancel(&ticket),
+            }
+        }
+    }
+
+    fn cancel(&self, ticket: &Arc<HandoffIntent>) {
+        self.state.send_if_modified(|state| {
+            let matches = match state {
+                State::Requested(current)
+                | State::Held {
+                    ticket: current, ..
+                } => Arc::ptr_eq(current, ticket),
+                _ => false,
+            };
+            if matches {
+                *state = State::Running;
+            }
+            matches
+        });
+    }
+
+    /// Called only after the canonical pause append is acknowledged.
+    pub(crate) fn sealed(&self, pause: &HandoffPause) {
+        self.state.send_if_modified(|state| {
+            if !matches!(state, State::Committed(expected) if expected == pause) {
+                return false;
+            }
+            *state = State::Sealed(pause.clone());
+            true
+        });
+    }
+
+    pub(crate) fn close(&self) {
+        self.state.send_if_modified(|state| {
+            if matches!(state, State::Sealed(_) | State::Closed) {
+                return false;
+            }
+            *state = State::Closed;
+            true
+        });
+    }
+}
+
+impl HandoffReservation {
+    pub async fn ready(self) -> Option<HeldHandoff> {
+        let mut changes = self.gate.state.subscribe();
+        loop {
+            let state = changes.borrow_and_update().clone();
+            match state {
+                State::Held { pause, ticket, .. } if Arc::ptr_eq(&ticket, &self.ticket) => {
+                    return Some(HeldHandoff {
+                        reservation: self,
+                        pause,
+                    });
+                }
+                State::Requested(ticket) if Arc::ptr_eq(&ticket, &self.ticket) => {}
+                _ => return None,
+            }
+            changes.changed().await.ok()?;
+        }
+    }
+}
+
+impl Drop for HandoffReservation {
+    fn drop(&mut self) {
+        self.gate.cancel(&self.ticket);
+    }
+}
+
+impl HeldHandoff {
+    pub fn preview(&self) -> &HandoffPause {
+        &self.pause
+    }
+
+    /// Irreversible scheduling decision, not yet a durable receipt.
+    pub fn commit(self) -> Option<PendingSeal> {
+        let gate = &self.reservation.gate;
+        gate.state
+            .send_if_modified(|state| {
+            if !matches!(state, State::Held { ticket, cancellation, .. } if Arc::ptr_eq(ticket, &self.reservation.ticket) && !cancellation.is_cancelled()) {
+                    return false;
+                }
+                *state = State::Committed(self.pause.clone());
+                true
+            })
+            .then(|| PendingSeal {
+                gate: gate.clone(),
+                pause: self.pause.clone(),
+            })
+    }
+}
+
+impl PendingSeal {
+    /// None means the worker exited without confirming the requested seal.
+    /// The owner must reconcile canonical facts, never resume the old worker.
+    pub async fn wait(self) -> Option<HandoffPause> {
+        let mut changes = self.gate.state.subscribe();
+        loop {
+            let state = changes.borrow_and_update().clone();
+            match state {
+                State::Sealed(pause) if pause == self.pause => return Some(pause),
+                State::Committed(pause) if pause == self.pause => {}
+                _ => return None,
+            }
+            changes.changed().await.ok()?;
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn cancellation_prevents_commit_and_worker_exit_never_forges_a_seal() {
+        let source = maka_runtime::event::Invocation {
+            session_id: "session".into(),
+            turn_id: "turn".into(),
+            run_id: "run".into(),
+            invocation_id: "invocation".into(),
+        };
+        let intent = HandoffIntent {
+            handoff_id: "handoff".into(),
+            host_epoch: "host".into(),
+            root_run_id: source.run_id.clone(),
+            successor_run_id: "successor-run".into(),
+            successor_invocation_id: "successor-invocation".into(),
+            claim_id: "claim".into(),
+        };
+        let gate = HandoffGate::new(source, "run".into());
+        let cancellation = CancellationToken::new();
+        let reservation = gate.reserve(intent.clone()).unwrap();
+        let (result, ()) = tokio::join!(
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation),
+            async {
+                let held = reservation.ready().await.unwrap();
+                cancellation.cancel();
+                assert!(held.commit().is_none());
+            },
+        );
+        assert!(result.is_none());
+
+        let cancellation = CancellationToken::new();
+        let reservation = gate.reserve(intent.clone()).unwrap();
+        let (result, stale) = tokio::join!(
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation),
+            async {
+                let held = reservation.ready().await.unwrap();
+                cancellation.cancel();
+                held
+            },
+        );
+        assert!(result.is_none());
+        let cancellation = CancellationToken::new();
+        let reservation = gate.reserve(intent).unwrap();
+        drop(stale); // The same durable intent does not reuse a scheduling owner.
+        let (result, pending) = tokio::join!(
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation),
+            async { reservation.ready().await.unwrap().commit().unwrap() },
+        );
+        assert!(result.is_some());
+        gate.close();
+        assert!(
+            pending.wait().await.is_none(),
+            "commit is not an acknowledged append"
+        );
+        assert!(gate.reserve(result.unwrap().intent).is_err());
+    }
+}
diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs
index 284186377a..3e567210ac 100644
--- a/crates/agent/src/lib.rs
+++ b/crates/agent/src/lib.rs
@@ -20,6 +20,8 @@
 mod auto_context;
 mod compact;
 mod continuation;
+mod handoff;
+pub use handoff::{HandoffGate, HandoffReservation, HeldHandoff, PendingSeal};
 mod history;
 mod model_attempt;
 pub use history::project as project_model_history;
@@ -250,9 +252,12 @@ impl Engine {
         {
             return Err(RunError::Busy);
         }
+        let handoff = (!matches!(input.work, RunWork::ContextCompact))
+            .then(|| HandoffGate::new(input.invocation.clone(), input.invocation.run_id.clone()));
         let admission = Admission {
             inner: self.0.clone(),
             session_id,
+            handoff: handoff.clone(),
         };
         let cancellation = cancellation.child_token();
         let cancel_on_drop = cancellation.clone().drop_guard();
@@ -267,9 +272,10 @@ impl Engine {
         });
         let worker_cancellation = cancellation.clone();
         let (admitted, ready) = tokio::sync::oneshot::channel();
+        let worker_handoff = handoff.clone();
         let worker = self.0.workers.spawn(async move {
             let _admission = admission;
-            runner::run(inner, input, worker_cancellation, admitted).await
+            runner::run(inner, input, worker_cancellation, admitted, worker_handoff).await
         });
         if ready.await.is_err() {
             return match worker.await {
@@ -285,6 +291,7 @@ impl Engine {
             invocation,
             tool_names,
             cancellation,
+            handoff,
             worker,
         ))
     }
@@ -293,10 +300,14 @@ impl Engine {
 struct Admission {
     inner: Arc<Inner>,
     session_id: String,
+    handoff: Option<HandoffGate>,
 }
 
 impl Drop for Admission {
     fn drop(&mut self) {
+        if let Some(handoff) = &self.handoff {
+            handoff.close();
+        }
         self.inner.active.lock().unwrap().remove(&self.session_id);
     }
 }
diff --git a/crates/agent/src/runner.rs b/crates/agent/src/runner.rs
index c09260c4a4..56b21ef975 100644
--- a/crates/agent/src/runner.rs
+++ b/crates/agent/src/runner.rs
@@ -31,6 +31,7 @@ pub async fn run(
     input: RunInput,
     cancellation_owner: crate::RunCancellation,
     admitted: tokio::sync::oneshot::Sender<()>,
+    handoff: Option<crate::HandoffGate>,
 ) -> Result<Invocation, RunError> {
     let cancellation = cancellation_owner.token().clone();
     if cancellation.is_cancelled() {
@@ -106,9 +107,10 @@ pub async fn run(
                 *max_steps,
                 &cancellation,
                 continuation_base,
+                handoff.as_ref().expect("model Runs own a handoff gate"),
             )
             .await
-            .map(|()| (InvocationOutcome::Completed, None)),
+            .map(|outcome| (outcome, None)),
             RunWork::ContextCompact => compact::run(
                 &inner,
                 &input,
@@ -126,7 +128,9 @@ pub async fn run(
         }
     }
     .await;
-    let result = if result.is_ok() && cancellation.is_cancelled() {
+    let result = if matches!(&result, Ok((outcome, _)) if !matches!(outcome, InvocationOutcome::HandoffPaused { .. }))
+        && cancellation.is_cancelled()
+    {
         Err(RunError::Cancelled)
     } else {
         result
@@ -172,7 +176,14 @@ pub async fn run(
             return Err(error.into());
         }
     } else {
+        let pause = match &outcome {
+            InvocationOutcome::HandoffPaused { pause } => Some(pause.clone()),
+            _ => None,
+        };
         append(&inner, &input.invocation, Fact::InvocationEnded { outcome }).await?;
+        if let Some(pause) = pause {
+            handoff.as_ref().expect("model Run handoff").sealed(&pause);
+        }
     }
     result?;
     Ok(input.invocation)
diff --git a/crates/agent/src/running.rs b/crates/agent/src/running.rs
index 381a1feb10..a047c8c896 100644
--- a/crates/agent/src/running.rs
+++ b/crates/agent/src/running.rs
@@ -29,6 +29,7 @@ pub struct RunningInvocation {
     invocation: Invocation,
     tool_names: Arc<HashSet<String>>,
     cancellation: RunCancellation,
+    handoff: Option<crate::HandoffGate>,
     worker: JoinHandle<Result<Invocation, RunError>>,
 }
 
@@ -37,12 +38,14 @@ impl RunningInvocation {
         invocation: Invocation,
         tool_names: Arc<HashSet<String>>,
         cancellation: RunCancellation,
+        handoff: Option<crate::HandoffGate>,
         worker: JoinHandle<Result<Invocation, RunError>>,
     ) -> Self {
         Self {
             invocation,
             tool_names,
             cancellation,
+            handoff,
             worker,
         }
     }
@@ -65,6 +68,10 @@ impl RunningInvocation {
         self.cancellation.clone()
     }
 
+    pub fn handoff(&self) -> Option<&crate::HandoffGate> {
+        self.handoff.as_ref()
+    }
+
     pub async fn wait(mut self) -> Result<Invocation, RunError> {
         (&mut self.worker)
             .await
diff --git a/crates/agent/src/steps.rs b/crates/agent/src/steps.rs
index 8a4fc04134..590df12e61 100644
--- a/crates/agent/src/steps.rs
+++ b/crates/agent/src/steps.rs
@@ -31,7 +31,8 @@ pub(super) async fn run(
     max_steps: usize,
     cancellation: &CancellationToken,
     continuation_base: Option<u64>,
-) -> Result<(), RunError> {
+    handoff: &crate::HandoffGate,
+) -> Result<maka_runtime::event::InvocationOutcome, RunError> {
     let lane = maka_model::ResponsesLane::default();
     let tools = RunTools::new(
         inner.log.clone(),
@@ -47,6 +48,19 @@ pub(super) async fn run(
             return Err(RunError::Cancelled);
         }
         inner.log.commit_pending_steering(&input.invocation).await?;
+        if let Some(pause) = handoff
+            .boundary(
+                std::num::NonZeroU16::new((max_steps - step) as u16)
+                    .expect("validated step budget"),
+                cancellation,
+            )
+            .await
+        {
+            return Ok(maka_runtime::event::InvocationOutcome::HandoffPaused { pause });
+        }
+        if cancellation.is_cancelled() {
+            return Err(RunError::Cancelled);
+        }
         let mut source = inner
             .log
             .read_model_context(
@@ -133,7 +147,7 @@ pub(super) async fn run(
             .filter(|call| !call.provider_executed)
             .collect();
         if local_calls.is_empty() {
-            return Ok(());
+            return Ok(maka_runtime::event::InvocationOutcome::Completed);
         }
         let mut step_tools = request_tools.into_step(&step_id);
         for call in &local_calls {
diff --git a/crates/agent/tests/integration/agent_loop.rs b/crates/agent/tests/integration/agent_loop.rs
index 343ea25807..2ab74d327f 100644
--- a/crates/agent/tests/integration/agent_loop.rs
+++ b/crates/agent/tests/integration/agent_loop.rs
@@ -27,28 +27,15 @@ use maka_js_runtime::{CellLimits, CodeExecutor};
 use maka_model::ModelExecutor;
 use maka_runtime::event::{Fact, TerminalStatus};
 use serde_json::{Value, json};
-use tokio::io::AsyncWriteExt;
-use tokio::net::{TcpListener, TcpStream};
+use tokio::net::TcpListener;
 use tokio::sync::Notify;
 use tokio_util::sync::CancellationToken;
 
-#[path = "support/agent_loop.rs"]
-mod fixture;
-use fixture::{Effect, input};
+use crate::support::agent_loop as fixture;
+use fixture::{Effect, input, respond};
 
 use crate::support::http;
 use http::read_request;
-async fn respond(socket: &mut TcpStream, tool: bool) {
-    let delta = if tool {
-        json!({"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\"value\":42}"}}]})
-    } else {
-        json!({"content":"done"})
-    };
-    let first = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":delta,"finish_reason":null}]});
-    let last = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":if tool {"tool_calls"} else {"stop"}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}});
-    let body = format!("data: {first}\n\ndata: {last}\n\ndata: [DONE]\n\n");
-    socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
-}
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn loop_commits_before_effects_replays_history_after_reopen_and_rejects_duplicate_admission()
diff --git a/crates/agent/tests/integration/handoff.rs b/crates/agent/tests/integration/handoff.rs
new file mode 100644
index 0000000000..13b5393a52
--- /dev/null
+++ b/crates/agent/tests/integration/handoff.rs
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::support::{
+    agent_loop::{Effect, input, respond},
+    http::read_request,
+};
+use maka_agent::Engine;
+use maka_event_log::EventLog;
+use maka_js_runtime::{CellLimits, CodeExecutor};
+use maka_model::ModelExecutor;
+use maka_runtime::{
+    event::{Fact, InvocationOutcome, TerminalStatus},
+    handoff::HandoffIntent,
+};
+use std::{
+    sync::{
+        Arc,
+        atomic::{AtomicUsize, Ordering},
+    },
+    time::Duration,
+};
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio_util::sync::CancellationToken;
+
+fn intent(id: &str) -> HandoffIntent {
+    HandoffIntent {
+        handoff_id: id.into(),
+        host_epoch: "host".into(),
+        root_run_id: "run-first".into(),
+        successor_run_id: format!("run-{id}"),
+        successor_invocation_id: format!("invocation-{id}"),
+        claim_id: format!("claim-{id}"),
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reopen() {
+    tokio::time::timeout(Duration::from_secs(20), async {
+        let directory = tempfile::tempdir().unwrap();
+        let path = directory.path().join("events.sqlite");
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let base = format!("http://{}/v1", listener.local_addr().unwrap());
+        let (first_seen, first) = oneshot::channel();
+        let (release_first, released_first) = oneshot::channel();
+        let (second_seen, second) = oneshot::channel();
+        let (release_second, released_second) = oneshot::channel();
+        let server = tokio::spawn(async move {
+            let mut requests = Vec::new();
+            for (seen, release) in [(first_seen, released_first), (second_seen, released_second)] {
+                let (mut socket, _) = listener.accept().await.unwrap();
+                requests.push(read_request(&mut socket).await);
+                seen.send(()).unwrap();
+                release.await.unwrap();
+                respond(&mut socket, true).await;
+            }
+            requests
+        });
+        let log = Arc::new(EventLog::open(&path).await.unwrap());
+        let engine = Engine::new(log.clone(), ModelExecutor::new(1, Duration::from_secs(5)).unwrap(),
+            CodeExecutor::new(1, CellLimits::default()).unwrap());
+        let count = Arc::new(AtomicUsize::new(0));
+        let effect = Arc::new(Effect { log: log.clone(), count: count.clone() });
+        let running = engine.start(input(&base, "first", effect), CancellationToken::new()).await.unwrap();
+        first.await.unwrap();
+        let gate = running.handoff().unwrap().clone();
+        let reservation = gate.reserve(intent("cancelled")).unwrap();
+        let ready = reservation.ready();
+        tokio::pin!(ready);
+        assert!(tokio::time::timeout(Duration::from_millis(30), &mut ready).await.is_err());
+        assert_eq!(count.load(Ordering::SeqCst), 0, "model request has not returned a tool");
+        release_first.send(()).unwrap();
+        let held = ready.await.unwrap();
+        assert_eq!(held.preview().remaining_steps.get(), 2);
+        assert_eq!(count.load(Ordering::SeqCst), 1);
+        let prefix = log.prefix(100, 128 * 1024).await.unwrap();
+        assert!(prefix.events.iter().any(|event| matches!(event.event.fact, Fact::ToolSettled { .. })));
+        assert_eq!(prefix.project_invocation("invocation-first").terminal, None);
+        drop(held);
+        second.await.unwrap();
+        let next = gate.reserve(intent("committed")).unwrap();
+        release_second.send(()).unwrap();
+        let held = next.ready().await.unwrap();
+        assert_eq!(held.preview().remaining_steps.get(), 1);
+        let expected = held.preview().clone();
+        let receipt = held.commit().unwrap().wait().await.unwrap();
+        assert_eq!(receipt, expected);
+        running.wait().await.unwrap();
+        engine.drain().await;
+        assert_eq!(count.load(Ordering::SeqCst), 2, "neither rollback nor seal repeats effects");
+        let requests = server.await.unwrap();
+        assert_eq!(requests.len(), 2, "no model request follows the committed hold");
+        assert!(requests[1]["messages"].as_array().unwrap().iter().any(|m| m["role"] == "tool"));
+        let before = log.prefix(100, 128 * 1024).await.unwrap();
+        assert_eq!(before.events.iter().filter(|e| matches!(e.event.fact, Fact::InvocationOpened { .. })).count(), 1);
+        assert!(matches!(&before.events.last().unwrap().event.fact,
+            Fact::InvocationEnded { outcome: InvocationOutcome::HandoffPaused { pause } } if pause == &expected));
+        let effect = Arc::new(Effect { log: log.clone(), count: count.clone() });
+        assert!(engine.start(input(&base, "unrelated", effect), CancellationToken::new()).await.is_err(),
+            "physical sealing does not release the logical Turn to a different admission");
+        assert_eq!(log.prefix(100, 128 * 1024).await.unwrap().digest, before.digest);
+        log.shutdown().await.unwrap();
+        drop(engine);
+        drop(log);
+        let reopened = EventLog::open(&path).await.unwrap();
+        assert_eq!(maka_agent::recovery::recover(&reopened).await.unwrap(), 0);
+        let after = reopened.prefix(100, 128 * 1024).await.unwrap();
+        assert_eq!(before.digest, after.digest);
+        assert_eq!(after.project_invocation("invocation-first").terminal, Some(TerminalStatus::Paused));
+        reopened.shutdown().await.unwrap();
+    }).await.unwrap();
+}
diff --git a/crates/agent/tests/integration/main.rs b/crates/agent/tests/integration/main.rs
index 8731ef4cd1..c858dea6b3 100644
--- a/crates/agent/tests/integration/main.rs
+++ b/crates/agent/tests/integration/main.rs
@@ -27,6 +27,7 @@ mod context_overflow;
 mod context_stop;
 mod dynamic_tools;
 mod frozen_projection;
+mod handoff;
 mod image_projection;
 mod lifetime;
 mod model_interruption;
@@ -36,6 +37,7 @@ mod startup_recovery;
 mod startup_recovery_limits;
 
 mod support {
+    pub(crate) mod agent_loop;
     pub(crate) mod code_mode;
     pub(crate) mod code_mode_lifetime;
     pub(crate) mod context;
diff --git a/crates/agent/tests/integration/support/agent_loop.rs b/crates/agent/tests/integration/support/agent_loop.rs
index 24d7a49816..6c92b75dc1 100644
--- a/crates/agent/tests/integration/support/agent_loop.rs
+++ b/crates/agent/tests/integration/support/agent_loop.rs
@@ -39,7 +39,7 @@ use std::{
 };
 use tokio_util::sync::CancellationToken;
 
-pub(super) struct Effect {
+pub(crate) struct Effect {
     pub log: Arc<EventLog>,
     pub count: Arc<AtomicUsize>,
 }
@@ -72,7 +72,7 @@ impl ToolExecutor for Effect {
     }
 }
 
-pub(super) fn input(base: &str, suffix: &str, effect: Arc<Effect>) -> RunInput {
+pub(crate) fn input(base: &str, suffix: &str, effect: Arc<Effect>) -> RunInput {
     RunInput {
         main_output_limit: None,
         context: None,
@@ -132,7 +132,7 @@ fn source(
         submitted_intent: None,
     }
 }
-pub(super) async fn admit_root(log: &EventLog, input: &mut RunInput) {
+pub(crate) async fn admit_root(log: &EventLog, input: &mut RunInput) {
     log.create_session("session", "create", &json!({}), 1)
         .await
         .unwrap();
@@ -162,7 +162,7 @@ pub(super) async fn admit_root(log: &EventLog, input: &mut RunInput) {
     .await
     .unwrap();
 }
-pub(super) async fn enqueue(log: &EventLog, invocation: Invocation) {
+pub(crate) async fn enqueue(log: &EventLog, invocation: Invocation) {
     log.admit_message(
         maka_event_log::message_admissions::PendingMessageAdmission {
             steering_invocation: None,
@@ -179,3 +179,16 @@ pub(super) async fn enqueue(log: &EventLog, invocation: Invocation) {
     .await
     .unwrap();
 }
+
+pub(crate) async fn respond(socket: &mut tokio::net::TcpStream, tool: bool) {
+    use tokio::io::AsyncWriteExt;
+    let delta = if tool {
+        json!({"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\"value\":42}"}}]})
+    } else {
+        json!({"content":"done"})
+    };
+    let first = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":delta,"finish_reason":null}]});
+    let last = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":if tool {"tool_calls"} else {"stop"}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}});
+    let body = format!("data: {first}\n\ndata: {last}\n\ndata: [DONE]\n\n");
+    socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
+}
diff --git a/crates/event-log/src/append.rs b/crates/event-log/src/append.rs
index c3af86b7e0..de1804b4d5 100644
--- a/crates/event-log/src/append.rs
+++ b/crates/event-log/src/append.rs
@@ -152,6 +152,7 @@ impl EventLog {
         }
         crate::tool_calls::validate(transaction, event).await?;
         crate::continuation::validate(transaction, event).await?;
+        crate::handoff::validate(transaction, event).await?;
         crate::steering::validate_append(transaction, event).await?;
         crate::message_identity::validate(transaction, event).await?;
         crate::message_admissions::consume(transaction, event).await?;
diff --git a/crates/event-log/src/archive/candidates.rs b/crates/event-log/src/archive/candidates.rs
index 8f3c679683..689454094d 100644
--- a/crates/event-log/src/archive/candidates.rs
+++ b/crates/event-log/src/archive/candidates.rs
@@ -48,7 +48,7 @@ impl EventLog {
                     AND json_extract(event_json,'$.invocation.session_id')=? AND NOT EXISTS(SELECT 1 FROM runtime_events t WHERE t.invocation_id=o.invocation_id AND t.kind='invocation_ended'))")
                     .bind(id).bind(&session).fetch_one(&mut *tx).await?;
                 if !live { return Err(StoreError::InvalidTransition("prune source invocation is not live".into())); }
-                safety::prune_boundary(&mut tx, id, i64::MAX as u64).await?;
+                safety::settled_boundary(&mut tx, id, i64::MAX as u64).await?;
             }
             safety::require_safe(&mut tx, &session, current.as_deref()).await?;
             let mut output = PruneCandidates { candidates: Vec::new(), next: None };
diff --git a/crates/event-log/src/context/safety.rs b/crates/event-log/src/context/safety.rs
index 21a9486946..66ef051c78 100644
--- a/crates/event-log/src/context/safety.rs
+++ b/crates/event-log/src/context/safety.rs
@@ -147,7 +147,7 @@ pub(crate) async fn active_boundary(
     execution_boundary(connection, invocation, through, false).await
 }
 
-pub(crate) async fn prune_boundary(
+pub(crate) async fn settled_boundary(
     connection: &mut SqliteConnection,
     invocation: &str,
     through: u64,
diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
new file mode 100644
index 0000000000..e34207c633
--- /dev/null
+++ b/crates/event-log/src/handoff.rs
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::StoreError;
+use maka_runtime::event::{Fact, InvocationInput, InvocationOutcome, RuntimeEvent};
+use sqlx::SqliteConnection;
+
+pub(crate) async fn validate(
+    tx: &mut SqliteConnection,
+    event: &RuntimeEvent,
+) -> Result<(), StoreError> {
+    if matches!(event.fact, Fact::InvocationOpened { .. }) {
+        let reserved: bool = sqlx::query_scalar(
+            "SELECT EXISTS(SELECT 1 FROM runtime_events pause
+             WHERE pause.kind='invocation_ended'
+             AND json_extract(pause.event_json,'$.invocation.session_id')=?1
+             AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
+             AND NOT EXISTS(SELECT 1 FROM runtime_events successor WHERE successor.kind='invocation_opened'
+               AND json_extract(successor.event_json,'$.fact.input.kind')='handoff'
+               AND json_extract(successor.event_json,'$.fact.input.claim.source.invocation.invocation_id')=pause.invocation_id))",
+        ).bind(&event.invocation.session_id).fetch_one(&mut *tx).await?;
+        if reserved {
+            return Err(invalid(
+                "Session is reserved for its sealed handoff successor",
+            ));
+        }
+        return Ok(());
+    }
+    let Fact::InvocationEnded {
+        outcome: InvocationOutcome::HandoffPaused { pause },
+    } = &event.fact
+    else {
+        return Ok(());
+    };
+    pause.validate(&event.invocation).map_err(invalid)?;
+    let opening: String = sqlx::query_scalar(
+        "SELECT event_json FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened'",
+    )
+    .bind(&event.invocation.invocation_id)
+    .fetch_one(&mut *tx)
+    .await?;
+    let opening: RuntimeEvent = serde_json::from_str(&opening)?;
+    if !matches!(
+        &opening.fact,
+        Fact::InvocationOpened {
+            input: InvocationInput::Message { .. } | InvocationInput::Continuation { .. },
+            configuration: Some(_),
+        }
+    ) || pause.intent.root_run_id != event.invocation.run_id
+    {
+        return Err(invalid(
+            "handoff must preserve its admitted logical model Run",
+        ));
+    }
+    // As with pruning before the next step, a failed, effect-free summary is
+    // harmless; an interrupted request never proves a provider effect settled.
+    crate::context::safety::settled_boundary(tx, &event.invocation.invocation_id, i64::MAX as u64)
+        .await?;
+    let occupied: bool = sqlx::query_scalar(
+        "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE kind='invocation_opened'
+         AND (invocation_id=?1 OR json_extract(event_json,'$.invocation.run_id')=?2))",
+    )
+    .bind(&pause.intent.successor_invocation_id)
+    .bind(&pause.intent.successor_run_id)
+    .fetch_one(&mut *tx)
+    .await?;
+    if occupied {
+        return Err(invalid("handoff successor identity already exists"));
+    }
+    Ok(())
+}
+
+fn invalid(message: &str) -> StoreError {
+    StoreError::InvalidTransition(message.into())
+}
diff --git a/crates/event-log/src/lib.rs b/crates/event-log/src/lib.rs
index 063350a7c7..0d5c21e529 100644
--- a/crates/event-log/src/lib.rs
+++ b/crates/event-log/src/lib.rs
@@ -26,6 +26,7 @@ mod catalog_changes;
 pub mod connection;
 pub mod context;
 mod continuation;
+mod handoff;
 pub mod interactions;
 pub mod message_admissions;
 mod message_identity;
diff --git a/crates/event-log/src/sessions/execution.rs b/crates/event-log/src/sessions/execution.rs
index bd404be981..5527a5abe6 100644
--- a/crates/event-log/src/sessions/execution.rs
+++ b/crates/event-log/src/sessions/execution.rs
@@ -87,6 +87,7 @@ pub(super) async fn read(
                     "completed" | "context_compact_finished" => Ok(TerminalStatus::Completed),
                     "failed" => Ok(TerminalStatus::Failed),
                     "cancelled" => Ok(TerminalStatus::Cancelled),
+                    "handoff_paused" => Ok(TerminalStatus::Paused),
                     _ => Err(invalid("unknown stored invocation outcome")),
                 }?,
             })
diff --git a/crates/event-log/tests/continuation.rs b/crates/event-log/tests/continuation.rs
index 47f165a892..07d18e7dc8 100644
--- a/crates/event-log/tests/continuation.rs
+++ b/crates/event-log/tests/continuation.rs
@@ -31,6 +31,8 @@ use maka_runtime::{
 
 #[path = "continuation/fixtures.rs"]
 mod fixtures;
+#[path = "continuation/handoff.rs"]
+mod handoff;
 use fixtures::*;
 
 #[tokio::test]
diff --git a/crates/event-log/tests/continuation/handoff.rs b/crates/event-log/tests/continuation/handoff.rs
new file mode 100644
index 0000000000..06dbd3f76c
--- /dev/null
+++ b/crates/event-log/tests/continuation/handoff.rs
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_runtime::{
+    context::ModelPurpose,
+    event::ModelInterruption,
+    handoff::{HandoffIntent, HandoffPause},
+    model::{ModelEvent, ModelToolCall},
+};
+use std::num::NonZeroU16;
+
+#[tokio::test]
+async fn handoff_rejects_unresolved_provider_effects_but_accepts_effect_free_summary_failure() {
+    for provider_effect in [false, true] {
+        let directory = tempfile::tempdir().unwrap();
+        let log = EventLog::open(&directory.path().join("handoff.sqlite"))
+            .await
+            .unwrap();
+        let prior = opening("prior", None);
+        append(&log, &prior).await;
+        close(&log, &prior).await;
+        let source = opening("source", None);
+        append(&log, &source).await;
+        let context = if provider_effect {
+            log.read_model_context("session", Some("source"), 100, 65536)
+                .await
+                .unwrap()
+        } else {
+            log.context_before_run(&source.invocation, 100, 65536)
+                .await
+                .unwrap()
+        };
+        append(
+            &log,
+            &RuntimeEvent::new(
+                source.invocation.clone(),
+                Fact::ModelRequested {
+                    step_id: "step".into(),
+                    model_id: "test".into(),
+                    purpose: Some(if provider_effect {
+                        ModelPurpose::Main
+                    } else {
+                        ModelPurpose::Summary
+                    }),
+                    context: None,
+                    source_scope: context.source_evidence.scope,
+                    source_high_water: context.source_evidence.high_water,
+                    source_digest: context.source_evidence.digest,
+                    effective_source_digest: Some(context.effective_source_digest),
+                    input_digest: digest('a'),
+                    route_identity: digest('b'),
+                    checkpoint_event_id: None,
+                },
+            ),
+        )
+        .await;
+        let seal = EventWrite::plain(RuntimeEvent::new(
+            source.invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::HandoffPaused {
+                    pause: HandoffPause {
+                        intent: HandoffIntent {
+                            handoff_id: "handoff".into(),
+                            host_epoch: "epoch".into(),
+                            root_run_id: source.invocation.run_id.clone(),
+                            successor_run_id: "successor-run".into(),
+                            successor_invocation_id: "successor-invocation".into(),
+                            claim_id: "claim".into(),
+                        },
+                        remaining_steps: NonZeroU16::new(2).unwrap(),
+                    },
+                },
+            },
+        ))
+        .unwrap();
+        assert!(
+            log.append(&seal).await.is_err(),
+            "an open request cannot be paused"
+        );
+        let observed = if provider_effect {
+            ModelEvent::ToolCall(ModelToolCall {
+                id: "remote".into(),
+                name: "search".into(),
+                input: serde_json::json!({}),
+                provider_options: None,
+                provider_executed: true,
+            })
+        } else {
+            ModelEvent::PartDelta {
+                id: "partial".into(),
+                text: "unfinished summary".into(),
+                provider_options: None,
+            }
+        };
+        append(
+            &log,
+            &RuntimeEvent::new(
+                source.invocation.clone(),
+                Fact::ModelObserved {
+                    step_id: "step".into(),
+                    event: observed,
+                },
+            ),
+        )
+        .await;
+        append(
+            &log,
+            &RuntimeEvent::new(
+                source.invocation.clone(),
+                Fact::ModelInterrupted {
+                    step_id: "step".into(),
+                    status: ModelInterruption::Failed,
+                },
+            ),
+        )
+        .await;
+        let before = log.prefix(100, 65536).await.unwrap();
+        assert_eq!(log.append(&seal).await.is_ok(), !provider_effect);
+        if provider_effect {
+            assert_eq!(
+                log.prefix(100, 65536).await.unwrap().digest,
+                before.digest,
+                "interruption does not settle the observed provider effect"
+            );
+        }
+        log.close().await.unwrap();
+    }
+}
diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs
index 8ba5e3bd4b..3229fa8bda 100644
--- a/crates/presentation/src/lib.rs
+++ b/crates/presentation/src/lib.rs
@@ -256,8 +256,11 @@ impl InvocationView {
                     }
                     Fact::InvocationEnded { outcome } => {
                         if let Some(unfinished) = step.take() {
-                            if matches!(outcome, maka_runtime::event::InvocationOutcome::Completed)
-                            {
+                            if matches!(
+                                outcome,
+                                maka_runtime::event::InvocationOutcome::Completed
+                                    | maka_runtime::event::InvocationOutcome::HandoffPaused { .. }
+                            ) {
                                 return Err(ProjectionError::Invalid(
                                     "completion with unfinished model",
                                 ));
@@ -269,14 +272,14 @@ impl InvocationView {
                                 maka_runtime::event::InvocationOutcome::Failed { .. }
                             )));
                         }
-                        messages.push(Message {
-                            id: event.id.clone(),
-                            turn_id: invocation.turn_id.clone(),
-                            ts,
-                            content: Content::TurnState {
-                                state: TurnState::from_outcome(outcome, ts),
-                            },
-                        });
+                        if let Some(state) = TurnState::from_outcome(outcome, ts) {
+                            messages.push(Message {
+                                id: event.id.clone(),
+                                turn_id: invocation.turn_id.clone(),
+                                ts,
+                                content: Content::TurnState { state },
+                            });
+                        }
                         self.state = State::Ended;
                     }
                     Fact::ToolDispatched { .. }
diff --git a/crates/presentation/src/message.rs b/crates/presentation/src/message.rs
index 44b602c309..3bda740181 100644
--- a/crates/presentation/src/message.rs
+++ b/crates/presentation/src/message.rs
@@ -257,8 +257,9 @@ pub enum TurnState {
     },
 }
 impl TurnState {
-    pub(crate) fn from_outcome(outcome: &InvocationOutcome, timestamp: u64) -> Self {
-        match outcome {
+    pub(crate) fn from_outcome(outcome: &InvocationOutcome, timestamp: u64) -> Option<Self> {
+        Some(match outcome {
+            InvocationOutcome::HandoffPaused { .. } => return None,
             InvocationOutcome::Completed | InvocationOutcome::ContextCompactFinished { .. } => {
                 Self::Completed
             }
@@ -276,6 +277,6 @@ impl TurnState {
                 abort_source: source.clone(),
                 aborted_at: timestamp,
             },
-        }
+        })
     }
 }
diff --git a/crates/presentation/tests/projection.rs b/crates/presentation/tests/projection.rs
index b7bbc52405..561b8ea6a1 100644
--- a/crates/presentation/tests/projection.rs
+++ b/crates/presentation/tests/projection.rs
@@ -87,7 +87,10 @@ fn events(outcome: InvocationOutcome) -> Vec<StoredEvent> {
             },
         },
     ];
-    if outcome == InvocationOutcome::Completed {
+    if matches!(
+        outcome,
+        InvocationOutcome::Completed | InvocationOutcome::HandoffPaused { .. }
+    ) {
         facts.extend([
             Fact::ModelObserved {
                 step_id: "step".into(),
@@ -158,6 +161,19 @@ fn completed_and_interrupted_rows_keep_overlay_identity_time_and_original_decode
             class: "provider".into(),
             message: Some("factual failure".into()),
         },
+        InvocationOutcome::HandoffPaused {
+            pause: maka_runtime::handoff::HandoffPause {
+                intent: maka_runtime::handoff::HandoffIntent {
+                    handoff_id: "handoff".into(),
+                    host_epoch: "host".into(),
+                    root_run_id: "run".into(),
+                    successor_run_id: "successor-run".into(),
+                    successor_invocation_id: "successor-invocation".into(),
+                    claim_id: "claim".into(),
+                },
+                remaining_steps: std::num::NonZeroU16::new(2).unwrap(),
+            },
+        },
     ] {
         let events = events(outcome.clone());
         let mut view = InvocationView::new(1024).unwrap();
@@ -208,7 +224,10 @@ fn completed_and_interrupted_rows_keep_overlay_identity_time_and_original_decode
             rows.iter()
                 .filter(|row| matches!(row.message.content, Content::TokenUsage { .. }))
                 .count(),
-            usize::from(outcome == InvocationOutcome::Completed),
+            usize::from(matches!(
+                outcome,
+                InvocationOutcome::Completed | InvocationOutcome::HandoffPaused { .. }
+            )),
             "no fabricated usage after interruption"
         );
         assert!(
@@ -219,15 +238,25 @@ fn completed_and_interrupted_rows_keep_overlay_identity_time_and_original_decode
             rows.last().unwrap().sequence <= watermark(events.last().unwrap().sequence).unwrap()
         );
         let terminal = serde_json::to_value(&rows.last().unwrap().message).unwrap();
-        assert_eq!(terminal["id"], events.last().unwrap().event.id);
         let expected = match outcome {
             InvocationOutcome::Completed | InvocationOutcome::ContextCompactFinished { .. } => {
-                "completed"
+                Some("completed")
             }
-            InvocationOutcome::Cancelled { .. } => "aborted",
-            InvocationOutcome::Failed { .. } => "failed",
+            InvocationOutcome::Cancelled { .. } => Some("aborted"),
+            InvocationOutcome::Failed { .. } => Some("failed"),
+            InvocationOutcome::HandoffPaused { .. } => None,
         };
-        assert_eq!(terminal["status"], expected);
+        if let Some(expected) = expected {
+            assert_eq!(terminal["id"], events.last().unwrap().event.id);
+            assert_eq!(terminal["status"], expected);
+        } else {
+            assert!(
+                !rows
+                    .iter()
+                    .any(|row| matches!(row.message.content, Content::TurnState { .. })),
+                "physical pause must not publish a logical Turn terminal"
+            );
+        }
         messages.extend(rows.into_iter().map(|row| row.message));
     }
     // A closed part is not a successful request. Only provider-finalized
diff --git a/crates/runtime-host/src/execution/resume.rs b/crates/runtime-host/src/execution/resume.rs
index 985112e70c..277ac8214f 100644
--- a/crates/runtime-host/src/execution/resume.rs
+++ b/crates/runtime-host/src/execution/resume.rs
@@ -305,6 +305,14 @@ impl Executions {
         {
             return Ok(Selection::Parked(Reason::SafetyCheckFailed));
         }
+        if matches!(
+            prefix.events.last().map(|event| &event.event.fact),
+            Some(Fact::InvocationEnded {
+                outcome: maka_runtime::event::InvocationOutcome::HandoffPaused { .. }
+            })
+        ) {
+            return Ok(Selection::Parked(Reason::SafetyCheckFailed));
+        }
         if !matches!(
             prefix.events.first().map(|e| &e.event.fact),
             Some(Fact::InvocationOpened {
diff --git a/crates/runtime-host/src/execution/snapshot.rs b/crates/runtime-host/src/execution/snapshot.rs
index 7027b328a7..4227fc0450 100644
--- a/crates/runtime-host/src/execution/snapshot.rs
+++ b/crates/runtime-host/src/execution/snapshot.rs
@@ -43,6 +43,7 @@ pub(crate) fn project(boundary: TurnBoundary) -> RecordedTurn {
             event_id: terminal_event_id,
             outcome,
         } => match outcome {
+            InvocationOutcome::HandoffPaused { .. } => TurnState::Running(live),
             InvocationOutcome::Completed => TurnState::Completed {
                 terminal_event_id,
                 context_compaction_outcome: None,
diff --git a/crates/runtime-host/src/server/sessions/projection.rs b/crates/runtime-host/src/server/sessions/projection.rs
index 1d4dc913d1..21d8326397 100644
--- a/crates/runtime-host/src/server/sessions/projection.rs
+++ b/crates/runtime-host/src/server/sessions/projection.rs
@@ -36,7 +36,11 @@ pub(crate) fn project(mut record: SessionRecord<SessionConfiguration>) -> Sessio
     };
     let mut running_turn_ids = Vec::new();
     let (status, recorded_at) = match execution.state {
-        SessionExecutionState::Live { recorded_at } => {
+        SessionExecutionState::Live { recorded_at }
+        | SessionExecutionState::Ended {
+            status: TerminalStatus::Paused,
+            recorded_at,
+        } => {
             running_turn_ids.push(execution.turn_id);
             (SessionStatus::Running, recorded_at)
         }
diff --git a/crates/runtime/src/event.rs b/crates/runtime/src/event.rs
index 2cf7682204..8078cd9332 100644
--- a/crates/runtime/src/event.rs
+++ b/crates/runtime/src/event.rs
@@ -38,12 +38,17 @@ pub enum TerminalStatus {
     Completed,
     Failed,
     Cancelled,
+    /// Physical termination only; the logical Turn awaits its sealed successor.
+    Paused,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(tag = "kind", rename_all = "snake_case")]
 pub enum InvocationOutcome {
     Completed,
+    HandoffPaused {
+        pause: crate::handoff::HandoffPause,
+    },
     ContextCompactFinished {
         outcome: crate::context::CompactOutcome,
     },
@@ -62,6 +67,7 @@ impl InvocationOutcome {
             Self::Completed | Self::ContextCompactFinished { .. } => TerminalStatus::Completed,
             Self::Failed { .. } => TerminalStatus::Failed,
             Self::Cancelled { .. } => TerminalStatus::Cancelled,
+            Self::HandoffPaused { .. } => TerminalStatus::Paused,
         }
     }
 }
diff --git a/crates/runtime/src/handoff.rs b/crates/runtime/src/handoff.rs
new file mode 100644
index 0000000000..b5e949fedd
--- /dev/null
+++ b/crates/runtime/src/handoff.rs
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! A physical Run can seal without ending its logical Turn. Only the sealed
+//! facts authorize the named successor; an in-memory reservation does not.
+
+use crate::{event::Invocation, interaction::entity_id};
+use serde::{Deserialize, Serialize};
+use std::num::NonZeroU16;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct HandoffIntent {
+    pub handoff_id: String,
+    pub host_epoch: String,
+    pub root_run_id: String,
+    pub successor_run_id: String,
+    pub successor_invocation_id: String,
+    pub claim_id: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct HandoffPause {
+    pub intent: HandoffIntent,
+    /// Captured by the Engine, never restored from mutable Session policy.
+    pub remaining_steps: NonZeroU16,
+}
+
+impl HandoffIntent {
+    pub fn validate(&self, source: &Invocation) -> Result<(), &'static str> {
+        for id in [
+            &self.handoff_id,
+            &self.host_epoch,
+            &self.root_run_id,
+            &self.successor_run_id,
+            &self.successor_invocation_id,
+            &self.claim_id,
+        ] {
+            entity_id(id)?;
+        }
+        if self.successor_run_id == self.root_run_id
+            || [&source.run_id, &source.invocation_id].contains(&&self.successor_run_id)
+            || [&source.run_id, &source.invocation_id].contains(&&self.successor_invocation_id)
+            || self.successor_run_id == self.successor_invocation_id
+        {
+            return Err("handoff must name a fresh physical Run and invocation");
+        }
+        Ok(())
+    }
+
+    pub fn successor(&self, source: &Invocation) -> Invocation {
+        Invocation {
+            session_id: source.session_id.clone(),
+            turn_id: source.turn_id.clone(),
+            run_id: self.successor_run_id.clone(),
+            invocation_id: self.successor_invocation_id.clone(),
+        }
+    }
+}
+
+impl HandoffPause {
+    pub fn validate(&self, source: &Invocation) -> Result<(), &'static str> {
+        self.intent.validate(source)?;
+        if self.remaining_steps.get() > 256 {
+            return Err("handoff exceeds the Engine step budget");
+        }
+        Ok(())
+    }
+}
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
index 59b29db48c..a9fcdfcd4f 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime/src/lib.rs
@@ -30,6 +30,7 @@ pub mod event;
 mod event_write;
 pub mod execution;
 pub mod executor;
+pub mod handoff;
 pub mod input;
 pub mod interaction;
 pub mod message;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 04/29: feat(agent): resume handoffs with frozen execution state

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 51c6372e44ca63264b9532d542ff8fe183bbe047
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 13:25:06 2026 +0800

    feat(agent): resume handoffs with frozen execution state
    
    Generated-by: Codex
---
 crates/agent/src/continuation.rs                   |  41 +++-
 crates/agent/src/handoff.rs                        |  82 ++++---
 crates/agent/src/lib.rs                            |  37 ++-
 crates/agent/src/model_attempt.rs                  |   5 +-
 crates/agent/src/runner.rs                         |  22 +-
 crates/agent/src/steps.rs                          |  41 +++-
 crates/agent/tests/integration/handoff.rs          |  65 ++++-
 .../agent/tests/integration/handoff_compaction.rs  | 144 +++++++++++
 crates/agent/tests/integration/main.rs             |   1 +
 .../agent/tests/integration/support/agent_loop.rs  |  10 +-
 crates/event-log/src/context/latest_main.rs        |   3 +-
 crates/event-log/src/context/read.rs               |  22 ++
 crates/event-log/src/handoff.rs                    |  30 +--
 crates/event-log/src/handoff/budget.rs             |  94 +++++++
 crates/event-log/tests/continuation/handoff.rs     |  18 ++
 .../event-log/tests/continuation/handoff_budget.rs | 271 +++++++++++++--------
 .../event-log/tests/continuation/handoff_claim.rs  |  27 ++
 crates/presentation/tests/projection.rs            |  13 +
 crates/runtime/src/handoff.rs                      |  10 +
 crates/runtime/src/handoff/execution.rs            |  72 ++++++
 crates/tools/src/availability.rs                   |  24 ++
 crates/tools/src/catalog.rs                        |  23 ++
 crates/tools/src/dispatch.rs                       |  11 +
 crates/tools/tests/integration/catalog.rs          |  37 ++-
 24 files changed, 908 insertions(+), 195 deletions(-)

diff --git a/crates/agent/src/continuation.rs b/crates/agent/src/continuation.rs
index f49387f0c4..e67220a27f 100644
--- a/crates/agent/src/continuation.rs
+++ b/crates/agent/src/continuation.rs
@@ -41,13 +41,24 @@ pub(super) async fn prepare(
     cancellation: &CancellationToken,
 ) -> Result<ContinuationClaim, RunError> {
     let (base, replay) = inspect(inner, input, source, catalog, cancellation).await?;
+    let pause = match &input.work {
+        crate::RunWork::Handoff { pause, .. } => Some(pause),
+        _ => None,
+    };
     let claim = ContinuationClaim {
-        id: uuid::Uuid::new_v4().to_string(),
+        id: pause.map_or_else(
+            || uuid::Uuid::new_v4().to_string(),
+            |pause| pause.intent.claim_id.clone(),
+        ),
         source: source.clone(),
         base,
         replay,
     };
-    claim.validate(&input.invocation).map_err(invalid)?;
+    match pause {
+        Some(pause) => pause.validate_claim(&claim, &input.invocation),
+        None => claim.validate(&input.invocation),
+    }
+    .map_err(invalid)?;
     Ok(claim)
 }
 
@@ -101,6 +112,17 @@ pub(super) async fn inspect(
     {
         return Err(invalid("continuation workspace identity changed"));
     }
+    if let crate::RunWork::Handoff { pause, .. } = &input.work
+        && (configuration != &input.configuration
+            || !matches!(
+                &prefix.events.last().expect("checked nonempty source").event.fact,
+                Fact::InvocationEnded {
+                    outcome: maka_runtime::event::InvocationOutcome::HandoffPaused { pause: sealed }
+                } if sealed == pause.as_ref()
+            ))
+    {
+        return Err(invalid("handoff does not match its sealed source"));
+    }
     let base = match opening {
         InvocationInput::Message { .. } => {
             let base = inner
@@ -127,7 +149,10 @@ pub(super) async fn inspect(
         &context,
         ModelPurpose::Main,
         cancellation,
-        Some(base.high_water),
+        match &input.work {
+            crate::RunWork::Handoff { pause, .. } => pause.execution.replay_base,
+            _ => Some(base.high_water),
+        },
     )
     .await?;
     if !matches!(
@@ -149,10 +174,18 @@ pub(super) async fn inspect(
         input.configuration.tool_mode,
         inner.cells.clone(),
     );
+    if let crate::RunWork::Handoff { pause, .. } = &input.work {
+        tools.restore(&pause.execution.tools)?;
+    }
     let definitions = tools.capture().definitions();
     let mut available: std::collections::HashSet<_> = catalog.names().into_iter().collect();
     available.extend(definitions.iter().map(|definition| definition.name.clone()));
-    for message in &prompt {
+    // A handoff replays settled facts, including rejected calls to unavailable
+    // tools. It never executes them again; the live catalog is checked above.
+    for message in prompt
+        .iter()
+        .filter(|_| !matches!(input.work, crate::RunWork::Handoff { .. }))
+    {
         if let Message::Assistant { content, .. } = message {
             for part in content {
                 if let AssistantPart::ToolCall {
diff --git a/crates/agent/src/handoff.rs b/crates/agent/src/handoff.rs
index c986e52c30..7c0f0ad891 100644
--- a/crates/agent/src/handoff.rs
+++ b/crates/agent/src/handoff.rs
@@ -18,7 +18,7 @@
  */
 
 use maka_runtime::handoff::{HandoffIntent, HandoffPause};
-use std::{num::NonZeroU16, sync::Arc};
+use std::sync::Arc;
 use tokio::sync::watch;
 use tokio_util::sync::CancellationToken;
 
@@ -95,25 +95,23 @@ impl HandoffGate {
             .ok_or(crate::RunError::Busy)
     }
 
-    pub(crate) async fn boundary<F: std::future::Future<Output = bool>>(
+    pub(crate) async fn boundary<F: std::future::Future<Output = Option<HandoffPause>>>(
         &self,
-        remaining_steps: NonZeroU16,
         cancellation: &CancellationToken,
-        preflight: impl FnOnce(HandoffPause) -> F,
+        preflight: impl FnOnce(HandoffIntent) -> F,
     ) -> Option<HandoffPause> {
         let mut changes = self.state.subscribe();
         let ticket = match &*changes.borrow_and_update() {
             State::Requested(ticket) => ticket.clone(),
             _ => return None,
         };
-        let pause = HandoffPause {
-            intent: ticket.as_ref().clone(),
-            remaining_steps,
-        };
-        if !preflight(pause.clone()).await || cancellation.is_cancelled() {
+        let pause = preflight(ticket.as_ref().clone()).await;
+        let Some(pause) =
+            pause.filter(|pause| pause.intent == *ticket && !cancellation.is_cancelled())
+        else {
             self.cancel(&ticket);
             return None;
-        }
+        };
         self.state.send_if_modified(|state| {
             let State::Requested(intent) = state else {
                 return false;
@@ -252,6 +250,27 @@ impl PendingSeal {
 mod tests {
     use super::*;
 
+    fn prepared(intent: HandoffIntent) -> std::future::Ready<Option<HandoffPause>> {
+        use maka_runtime::handoff::{HandoffExecution, HandoffTools};
+        std::future::ready(Some(HandoffPause {
+            intent,
+            remaining_steps: std::num::NonZeroU16::new(2).unwrap(),
+            execution: Box::new(HandoffExecution {
+                route_identity: format!("sha256:{}", "b".repeat(64)),
+                context: None,
+                provider_options: serde_json::json!({}),
+                main_output_limit: None,
+                supports_vision: false,
+                tools: HandoffTools {
+                    catalog_digest: format!("sha256:{}", "c".repeat(64)),
+                    loaded: Default::default(),
+                },
+                compaction_attempted: false,
+                replay_base: None,
+            }),
+        }))
+    }
+
     #[tokio::test]
     async fn cancellation_prevents_commit_and_worker_exit_never_forges_a_seal() {
         let source = maka_runtime::event::Invocation {
@@ -272,9 +291,7 @@ mod tests {
         let cancellation = CancellationToken::new();
         let reservation = gate.reserve(intent.clone()).unwrap();
         let (result, held) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
-                std::future::ready(false)
-            }),
+            gate.boundary(&cancellation, |_| { std::future::ready(None) }),
             reservation.ready(),
         );
         assert!(
@@ -282,40 +299,27 @@ mod tests {
             "failed preflight releases the same Run"
         );
         let reservation = gate.reserve(intent.clone()).unwrap();
-        let (result, ()) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
-                std::future::ready(true)
-            }),
-            async {
-                let held = reservation.ready().await.unwrap();
-                cancellation.cancel();
-                assert!(held.commit().is_none());
-            },
-        );
+        let (result, ()) = tokio::join!(gate.boundary(&cancellation, prepared), async {
+            let held = reservation.ready().await.unwrap();
+            cancellation.cancel();
+            assert!(held.commit().is_none());
+        },);
         assert!(result.is_none());
 
         let cancellation = CancellationToken::new();
         let reservation = gate.reserve(intent.clone()).unwrap();
-        let (result, stale) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
-                std::future::ready(true)
-            }),
-            async {
-                let held = reservation.ready().await.unwrap();
-                cancellation.cancel();
-                held
-            },
-        );
+        let (result, stale) = tokio::join!(gate.boundary(&cancellation, prepared), async {
+            let held = reservation.ready().await.unwrap();
+            cancellation.cancel();
+            held
+        },);
         assert!(result.is_none());
         let cancellation = CancellationToken::new();
         let reservation = gate.reserve(intent).unwrap();
         drop(stale); // The same durable intent does not reuse a scheduling owner.
-        let (result, pending) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
-                std::future::ready(true)
-            }),
-            async { reservation.ready().await.unwrap().commit().unwrap() },
-        );
+        let (result, pending) = tokio::join!(gate.boundary(&cancellation, prepared), async {
+            reservation.ready().await.unwrap().commit().unwrap()
+        },);
         assert!(result.is_some());
         gate.close();
         assert!(
diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs
index 3e567210ac..7003fc2dcf 100644
--- a/crates/agent/src/lib.rs
+++ b/crates/agent/src/lib.rs
@@ -89,6 +89,11 @@ pub struct RunInput {
 }
 
 pub enum RunWork {
+    Handoff {
+        source: maka_runtime::continuation::RunBoundary,
+        pause: Box<maka_runtime::handoff::HandoffPause>,
+        tools: ToolCatalog,
+    },
     Continuation {
         source: maka_runtime::continuation::RunBoundary,
         workhub_resume: Option<maka_runtime::workhub::ResumeOrigin>,
@@ -183,6 +188,23 @@ impl Engine {
                 .map_err(|reason| RunError::InvalidInput(reason.into()))?;
         }
         match &input.work {
+            RunWork::Handoff { source, pause, .. } => {
+                pause
+                    .validate(&source.invocation)
+                    .map_err(|reason| RunError::InvalidInput(reason.into()))?;
+                if input.invocation != pause.intent.successor(&source.invocation)
+                    || input.request_fingerprint.is_some()
+                    || input.context != pause.execution.context
+                    || input.provider_options != pause.execution.provider_options
+                    || input.main_output_limit != pause.execution.main_output_limit
+                    || input.supports_vision != pause.execution.supports_vision
+                    || model_attempt::route_identity(&input)? != pause.execution.route_identity
+                {
+                    return Err(RunError::InvalidInput(
+                        "handoff execution settings changed".into(),
+                    ));
+                }
+            }
             RunWork::Message {
                 message, max_steps, ..
             } if *max_steps == 0 || *max_steps > 256 || message.text_bytes() > 64 * 1024 => {
@@ -252,8 +274,13 @@ impl Engine {
         {
             return Err(RunError::Busy);
         }
-        let handoff = (!matches!(input.work, RunWork::ContextCompact))
-            .then(|| HandoffGate::new(input.invocation.clone(), input.invocation.run_id.clone()));
+        let handoff = (!matches!(input.work, RunWork::ContextCompact)).then(|| {
+            let root = match &input.work {
+                RunWork::Handoff { pause, .. } => &pause.intent.root_run_id,
+                _ => &input.invocation.run_id,
+            };
+            HandoffGate::new(input.invocation.clone(), root.clone())
+        });
         let admission = Admission {
             inner: self.0.clone(),
             session_id,
@@ -265,9 +292,9 @@ impl Engine {
         let inner = self.0.clone();
         let invocation = input.invocation.clone();
         let tool_names = Arc::new(match &input.work {
-            RunWork::Message { tools, .. } | RunWork::Continuation { tools, .. } => {
-                tools.names().into_iter().collect()
-            }
+            RunWork::Message { tools, .. }
+            | RunWork::Continuation { tools, .. }
+            | RunWork::Handoff { tools, .. } => tools.names().into_iter().collect(),
             RunWork::ContextCompact => Default::default(),
         });
         let worker_cancellation = cancellation.clone();
diff --git a/crates/agent/src/model_attempt.rs b/crates/agent/src/model_attempt.rs
index 5a3896e981..c79a585e95 100644
--- a/crates/agent/src/model_attempt.rs
+++ b/crates/agent/src/model_attempt.rs
@@ -156,7 +156,10 @@ pub(super) async fn execute(
             _ = cancellation.cancelled() => return Err(RunError::Cancelled),
             _ = tokio::time::sleep(delay) => {}
         }
-        if continuation_base.is_some() {
+        if matches!(
+            input.work,
+            crate::RunWork::Continuation { .. } | crate::RunWork::Handoff { .. }
+        ) {
             let next = inner
                 .log
                 .read_model_context(
diff --git a/crates/agent/src/runner.rs b/crates/agent/src/runner.rs
index 56b21ef975..b6e0ac76c6 100644
--- a/crates/agent/src/runner.rs
+++ b/crates/agent/src/runner.rs
@@ -49,13 +49,20 @@ pub async fn run(
         Err(error) => return Err(error.into()),
     }
     let claim = match &input.work {
-        RunWork::Continuation { source, tools, .. } => {
+        RunWork::Continuation { source, tools, .. } | RunWork::Handoff { source, tools, .. } => {
             Some(crate::continuation::prepare(&inner, &input, source, tools, &cancellation).await?)
         }
         _ => None,
     };
-    let continuation_base = claim.as_ref().map(|claim| claim.base.high_water);
+    let continuation_base = match &input.work {
+        RunWork::Handoff { pause, .. } => pause.execution.replay_base,
+        _ => claim.as_ref().map(|claim| claim.base.high_water),
+    };
     let opening = match &input.work {
+        RunWork::Handoff { pause, .. } => InvocationInput::Handoff {
+            claim: Box::new(claim.expect("prepared handoff")),
+            pause: pause.clone(),
+        },
         RunWork::Continuation { workhub_resume, .. } => InvocationInput::Continuation {
             claim: Box::new(claim.expect("prepared continuation")),
             workhub_resume: workhub_resume.clone(),
@@ -111,6 +118,17 @@ pub async fn run(
             )
             .await
             .map(|outcome| (outcome, None)),
+            RunWork::Handoff { tools, pause, .. } => steps::run(
+                &inner,
+                &input,
+                tools,
+                usize::from(pause.remaining_steps.get()),
+                &cancellation,
+                continuation_base,
+                handoff.as_ref().expect("model Runs own a handoff gate"),
+            )
+            .await
+            .map(|outcome| (outcome, None)),
             RunWork::ContextCompact => compact::run(
                 &inner,
                 &input,
diff --git a/crates/agent/src/steps.rs b/crates/agent/src/steps.rs
index 8afce8ae82..5ee315a11e 100644
--- a/crates/agent/src/steps.rs
+++ b/crates/agent/src/steps.rs
@@ -42,25 +42,42 @@ pub(super) async fn run(
         inner.cells.clone(),
     );
     let mut attempted = false;
+    // This tracks work after this physical opening, not after the logical root.
+    // A successor may compact the sealed prefix with a PreTurn boundary.
     let mut completed_step = false;
+    if let crate::RunWork::Handoff { pause, .. } = &input.work {
+        tools.restore(&pause.execution.tools)?;
+        attempted = pause.execution.compaction_attempted;
+    }
     for step in 0..max_steps {
         if cancellation.is_cancelled() {
             return Err(RunError::Cancelled);
         }
         inner.log.commit_pending_steering(&input.invocation).await?;
         if let Some(pause) = handoff
-            .boundary(
-                std::num::NonZeroU16::new((max_steps - step) as u16)
-                    .expect("validated step budget"),
-                cancellation,
-                |pause| async move {
-                    inner
-                        .log
-                        .check_handoff(&input.invocation, &pause)
-                        .await
-                        .is_ok()
-                },
-            )
+            .boundary(cancellation, |intent| async {
+                let pause = maka_runtime::handoff::HandoffPause {
+                    intent,
+                    remaining_steps: std::num::NonZeroU16::new((max_steps - step) as u16)
+                        .expect("validated step budget"),
+                    execution: Box::new(maka_runtime::handoff::HandoffExecution {
+                        route_identity: model_attempt::route_identity(input).ok()?,
+                        context: input.context.clone(),
+                        provider_options: input.provider_options.clone(),
+                        main_output_limit: input.main_output_limit,
+                        supports_vision: input.supports_vision,
+                        tools: tools.checkpoint(),
+                        compaction_attempted: attempted,
+                        replay_base: continuation_base,
+                    }),
+                };
+                inner
+                    .log
+                    .check_handoff(&input.invocation, &pause)
+                    .await
+                    .ok()?;
+                Some(pause)
+            })
             .await
         {
             return Ok(maka_runtime::event::InvocationOutcome::HandoffPaused { pause });
diff --git a/crates/agent/tests/integration/handoff.rs b/crates/agent/tests/integration/handoff.rs
index 97b0c5ef95..cfb824ffd3 100644
--- a/crates/agent/tests/integration/handoff.rs
+++ b/crates/agent/tests/integration/handoff.rs
@@ -62,6 +62,7 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         let (release_first, released_first) = oneshot::channel();
         let (second_seen, second) = oneshot::channel();
         let (release_second, released_second) = oneshot::channel();
+        let (third_seen, mut third) = oneshot::channel();
         let server = tokio::spawn(async move {
             let mut requests = Vec::new();
             for (seen, release) in [(first_seen, released_first), (second_seen, released_second)] {
@@ -69,8 +70,13 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
                 requests.push(read_request(&mut socket).await);
                 seen.send(()).unwrap();
                 release.await.unwrap();
-                respond(&mut socket, true).await;
+                crate::support::agent_loop::respond_tool(&mut socket,
+                    Some(if requests.len() == 1 { "echo" } else { "unavailable" })).await;
             }
+            let (mut socket, _) = listener.accept().await.unwrap();
+            requests.push(read_request(&mut socket).await);
+            third_seen.send(()).unwrap();
+            respond(&mut socket, false).await;
             requests
         });
         let log = Arc::new(EventLog::open(&path).await.unwrap());
@@ -81,6 +87,8 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         let mut run = input(&base, "first", effect);
         run.configuration.workspace_identity = Some(maka_runtime::execution::WorkspaceIdentity::from_marker_id(
             "ef751105-55b5-4d65-a364-646281586a17").unwrap());
+        run.main_output_limit = Some(120);
+        let configuration = run.configuration.clone();
         let running = engine.start(run, CancellationToken::new()).await.unwrap();
         first.await.unwrap();
         let gate = running.handoff().unwrap().clone();
@@ -107,10 +115,9 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         assert_eq!(receipt, expected);
         running.wait().await.unwrap();
         engine.drain().await;
-        assert_eq!(count.load(Ordering::SeqCst), 2, "neither rollback nor seal repeats effects");
-        let requests = server.await.unwrap();
-        assert_eq!(requests.len(), 2, "no model request follows the committed hold");
-        assert!(requests[1]["messages"].as_array().unwrap().iter().any(|m| m["role"] == "tool"));
+        assert_eq!(count.load(Ordering::SeqCst), 1, "rejected calls, rollback and seal never repeat effects");
+        assert!(tokio::time::timeout(Duration::from_millis(30), &mut third).await.is_err(),
+            "no model request follows the committed hold");
         let before = log.prefix(100, 128 * 1024).await.unwrap();
         assert_eq!(before.events.iter().filter(|e| matches!(e.event.fact, Fact::InvocationOpened { .. })).count(), 1);
         assert!(matches!(&before.events.last().unwrap().event.fact,
@@ -122,11 +129,57 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
         log.shutdown().await.unwrap();
         drop(engine);
         drop(log);
-        let reopened = EventLog::open(&path).await.unwrap();
+        let reopened = Arc::new(EventLog::open(&path).await.unwrap());
         assert_eq!(maka_agent::recovery::recover(&reopened).await.unwrap(), 0);
         let after = reopened.prefix(100, 128 * 1024).await.unwrap();
         assert_eq!(before.digest, after.digest);
         assert_eq!(after.project_invocation("invocation-first").terminal, Some(TerminalStatus::Paused));
+        let prefix = reopened.run_prefix("session", "run-first", None, 100, 128 * 1024).await.unwrap().unwrap();
+        let source = maka_runtime::continuation::RunBoundary {
+            invocation: prefix.invocation,
+            high_water: prefix.high_water,
+            digest: prefix.digest,
+        };
+        let resumed_engine = Engine::new(reopened.clone(), ModelExecutor::new(1, Duration::from_secs(5)).unwrap(),
+            CodeExecutor::new(1, CellLimits::default()).unwrap());
+        let successor = || {
+            let effect = Arc::new(Effect { log: reopened.clone(), count: count.clone() });
+            let mut run = input(&base, "successor", effect);
+            let maka_agent::RunWork::Message { tools, .. } = run.work else { unreachable!() };
+            run.invocation = expected.intent.successor(&source.invocation);
+            run.configuration = configuration.clone();
+            run.main_output_limit = expected.execution.main_output_limit;
+            run.work = maka_agent::RunWork::Handoff {
+                source: source.clone(), pause: Box::new(expected.clone()), tools,
+            };
+            run
+        };
+        for field in 0..5 {
+            let mut changed = successor();
+            match field {
+                0 => changed.main_output_limit = None,
+                1 => changed.provider_options = serde_json::json!({"changed": true}),
+                2 => changed.provider.model = "changed".into(),
+                3 => changed.configuration.cwd.push_str("/changed"),
+                _ => {
+                    let maka_agent::RunWork::Handoff { tools, .. } = &mut changed.work else { unreachable!() };
+                    *tools = Default::default();
+                },
+            }
+            assert!(resumed_engine.start(changed, CancellationToken::new()).await.is_err());
+            assert_eq!(reopened.prefix(100, 128 * 1024).await.unwrap().digest, after.digest);
+        }
+        resumed_engine.run(successor(), CancellationToken::new()).await.unwrap();
+        resumed_engine.drain().await;
+        let requests = server.await.unwrap();
+        assert_eq!(requests.len(), 3);
+        let messages = requests[2]["messages"].as_array().unwrap();
+        assert_eq!(messages.iter().filter(|m| m["role"] == "user").count(), 1);
+        assert_eq!(messages.iter().filter(|m| m["role"] == "tool").count(), 2);
+        assert_eq!(count.load(Ordering::SeqCst), 1, "settled effects are replayed as facts, never executed");
+        let finished = reopened.prefix(100, 128 * 1024).await.unwrap();
+        assert_eq!(finished.project_invocation("invocation-committed").terminal, Some(TerminalStatus::Completed));
+        assert!(!serde_json::to_string(&finished.events).unwrap().contains("do-not-persist-this-secret"));
         reopened.shutdown().await.unwrap();
     }).await.unwrap();
 }
diff --git a/crates/agent/tests/integration/handoff_compaction.rs b/crates/agent/tests/integration/handoff_compaction.rs
new file mode 100644
index 0000000000..f69f626b55
--- /dev/null
+++ b/crates/agent/tests/integration/handoff_compaction.rs
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::support::{
+    agent_loop::{Effect, input, respond},
+    context as fixture,
+    http::read_request,
+};
+use maka_agent::RunWork;
+use maka_event_log::{EventLog, context::LatestMainContext};
+use maka_runtime::{
+    context::{CheckpointMode, ModelRequestContext},
+    continuation::RunBoundary,
+    event::Fact,
+    execution::{ModelBinding, WorkspaceIdentity},
+    handoff::HandoffIntent,
+};
+use std::{
+    sync::{
+        Arc,
+        atomic::{AtomicUsize, Ordering},
+    },
+    time::Duration,
+};
+use tokio::{io::AsyncWriteExt, net::TcpListener, sync::oneshot};
+use tokio_util::sync::CancellationToken;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn handoff_preserves_conversation_replay_and_compacts_before_its_physical_anchor() {
+    tokio::time::timeout(Duration::from_secs(20), async {
+        let directory = tempfile::tempdir().unwrap();
+        let log = Arc::new(EventLog::open(&directory.path().join("events.sqlite")).await.unwrap());
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let base = format!("http://{}/v1", listener.local_addr().unwrap());
+        let (seen, ready) = oneshot::channel();
+        let (release, released) = oneshot::channel();
+        let server = tokio::spawn(async move {
+            let (mut socket, _) = listener.accept().await.unwrap();
+            read_request(&mut socket).await;
+            let chunk = serde_json::json!({"id":"old","object":"chat.completion.chunk","created":1,"model":"old",
+                "choices":[{"index":0,"delta":{"reasoning_content":"old-route-thinking","content":"old-answer"},"finish_reason":"stop"}],
+                "usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}});
+            let body = format!("data: {chunk}\n\ndata: [DONE]\n\n");
+            socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
+            let (mut socket, _) = listener.accept().await.unwrap();
+            read_request(&mut socket).await;
+            seen.send(()).unwrap();
+            released.await.unwrap();
+            respond(&mut socket, true).await;
+            let (mut socket, _) = listener.accept().await.unwrap();
+            let summary = read_request(&mut socket).await;
+            fixture::respond(&mut socket, fixture::SUMMARY, "stop").await;
+            let (mut socket, _) = listener.accept().await.unwrap();
+            let main = read_request(&mut socket).await;
+            let failure = r#"{"error":{"message":"retry this request","type":"rate_limit_error"}}"#;
+            socket.write_all(format!("HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0.01\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{failure}", failure.len()).as_bytes()).await.unwrap();
+            let (mut socket, _) = listener.accept().await.unwrap();
+            let retried = read_request(&mut socket).await;
+            assert_eq!(retried, main, "physical retries preserve the frozen request surface");
+            respond(&mut socket, false).await;
+            (summary, main)
+        });
+        let engine = fixture::engine(log.clone());
+        let mut prior = fixture::input(&base, "prior", false);
+        prior.provider.kind = maka_model::ProviderKind::OpenaiCompatible { name: "fixture".into() };
+        prior.provider.model = "old".into();
+        prior.configuration.model.as_mut().unwrap().model = "old".into();
+        engine.run(prior, CancellationToken::new()).await.unwrap();
+        let count = Arc::new(AtomicUsize::new(0));
+        let effect = Arc::new(Effect { log: log.clone(), count: count.clone() });
+        let mut first = input(&base, "first", effect.clone());
+        first.configuration.workspace_identity = Some(WorkspaceIdentity::from_marker_id(
+            "ef751105-55b5-4d65-a364-646281586a17").unwrap());
+        first.configuration.model = Some(ModelBinding {
+            connection_id: "connection".into(), connection_slug: "fixture".into(), model: "test".into(),
+        });
+        first.context = Some(ModelRequestContext {
+            provider_id: "openai".into(), context_window: Some(10), declared_window: Some(10),
+        });
+        let configuration = first.configuration.clone();
+        let context = first.context.clone();
+        let source_invocation = first.invocation.clone();
+        let running = engine.start(first, CancellationToken::new()).await.unwrap();
+        ready.await.unwrap();
+        let reservation = running.handoff().unwrap().reserve(HandoffIntent {
+            handoff_id: "compact".into(), host_epoch: "host".into(), root_run_id: "run-first".into(),
+            successor_run_id: "successor-run".into(), successor_invocation_id: "successor-invocation".into(),
+            claim_id: "claim".into(),
+        }).unwrap();
+        release.send(()).unwrap();
+        let pause = reservation.ready().await.unwrap().commit().unwrap().wait().await.unwrap();
+        assert!(!pause.execution.compaction_attempted);
+        assert_eq!(pause.execution.replay_base, None);
+        running.wait().await.unwrap();
+        engine.drain().await;
+        let prefix = log.run_prefix("session", "run-first", None, 100, 128 * 1024).await.unwrap().unwrap();
+        let mut successor = input(&base, "next", effect);
+        let RunWork::Message { tools, .. } = successor.work else { unreachable!() };
+        successor.invocation = pause.intent.successor(&source_invocation);
+        successor.configuration = configuration;
+        successor.context = context;
+        successor.work = RunWork::Handoff {
+            source: RunBoundary { invocation: prefix.invocation, high_water: prefix.high_water, digest: prefix.digest },
+            pause: Box::new(pause), tools,
+        };
+        let resumed = fixture::engine(log.clone());
+        resumed.run(successor, CancellationToken::new()).await.unwrap();
+        resumed.drain().await;
+        let (summary, main) = server.await.unwrap();
+        assert!(summary["messages"].to_string().contains("question first"));
+        assert!(main["messages"].to_string().contains("summary-marker"));
+        assert!(!main["messages"].to_string().contains("old-answer"));
+        assert_eq!(count.load(Ordering::SeqCst), 1);
+        let prefix = log.prefix(100, 256 * 1024).await.unwrap();
+        assert!(prefix.events.iter().any(|e| matches!(&e.event.fact,
+            Fact::ModelCompleted { output, .. } if output.parts.iter().any(|part| matches!(part,
+                maka_runtime::model::ModelPart::Text { text_kind: maka_runtime::model::TextKind::Thinking, .. })))));
+        assert!(prefix.events.iter().any(|e| matches!(&e.event.fact,
+            Fact::ContextCheckpointRecorded { checkpoint } if checkpoint.mode == CheckpointMode::PreTurn)));
+        let latest_sequence = prefix.events.iter().rev().find(|e|
+            e.event.invocation.invocation_id == "successor-invocation"
+            && matches!(e.event.fact, Fact::ModelCompleted { .. })).unwrap().sequence;
+        let source = log.read_model_context("session", None, 100, 256 * 1024).await.unwrap();
+        assert!(matches!(source.latest_main, LatestMainContext::Selected(latest)
+            if latest.sequence == latest_sequence));
+        log.shutdown().await.unwrap();
+    }).await.unwrap();
+}
diff --git a/crates/agent/tests/integration/main.rs b/crates/agent/tests/integration/main.rs
index c858dea6b3..05c30af1f3 100644
--- a/crates/agent/tests/integration/main.rs
+++ b/crates/agent/tests/integration/main.rs
@@ -28,6 +28,7 @@ mod context_stop;
 mod dynamic_tools;
 mod frozen_projection;
 mod handoff;
+mod handoff_compaction;
 mod image_projection;
 mod lifetime;
 mod model_interruption;
diff --git a/crates/agent/tests/integration/support/agent_loop.rs b/crates/agent/tests/integration/support/agent_loop.rs
index 6c92b75dc1..6ba15ef783 100644
--- a/crates/agent/tests/integration/support/agent_loop.rs
+++ b/crates/agent/tests/integration/support/agent_loop.rs
@@ -181,14 +181,18 @@ pub(crate) async fn enqueue(log: &EventLog, invocation: Invocation) {
 }
 
 pub(crate) async fn respond(socket: &mut tokio::net::TcpStream, tool: bool) {
+    respond_tool(socket, tool.then_some("echo")).await;
+}
+
+pub(crate) async fn respond_tool(socket: &mut tokio::net::TcpStream, tool: Option<&str>) {
     use tokio::io::AsyncWriteExt;
-    let delta = if tool {
-        json!({"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\"value\":42}"}}]})
+    let delta = if let Some(name) = tool {
+        json!({"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":name,"arguments":"{\"value\":42}"}}]})
     } else {
         json!({"content":"done"})
     };
     let first = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":delta,"finish_reason":null}]});
-    let last = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":if tool {"tool_calls"} else {"stop"}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}});
+    let last = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":if tool.is_some() {"tool_calls"} else {"stop"}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}});
     let body = format!("data: {first}\n\ndata: {last}\n\ndata: [DONE]\n\n");
     socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
 }
diff --git a/crates/event-log/src/context/latest_main.rs b/crates/event-log/src/context/latest_main.rs
index 33b3ebb936..16512550a8 100644
--- a/crates/event-log/src/context/latest_main.rs
+++ b/crates/event-log/src/context/latest_main.rs
@@ -129,7 +129,8 @@ pub(super) async fn read_selected(
             let purpose: Option<&str> = row.try_get("purpose").ok()?;
             let purpose = match (kind, purpose) {
                 ("message" | "continuation", None | Some("main")) => ModelPurpose::Main,
-                ("message" | "continuation" | "context_compact", Some("summary"))
+                ("handoff", Some("main")) => ModelPurpose::Main,
+                ("message" | "continuation" | "handoff" | "context_compact", Some("summary"))
                 | ("context_compact", None) => ModelPurpose::Summary,
                 _ => return None,
             };
diff --git a/crates/event-log/src/context/read.rs b/crates/event-log/src/context/read.rs
index 3787001cfd..581237655c 100644
--- a/crates/event-log/src/context/read.rs
+++ b/crates/event-log/src/context/read.rs
@@ -104,6 +104,28 @@ impl EventLog {
     }
 }
 
+/// Use the same effective-tail/anchor accounting as the successor's first read.
+pub(crate) async fn check_handoff_capacity(
+    connection: &mut SqliteConnection,
+    opening: &maka_runtime::event::RuntimeEvent,
+    max_events: usize,
+    max_bytes: usize,
+) -> Result<(), StoreError> {
+    let selection = Selection::for_opening(connection, opening).await?;
+    let high = selection.high_water(connection, i64::MAX as u64).await?;
+    materialize_selected(
+        connection,
+        &selection,
+        high,
+        i64::MAX as u64,
+        max_events,
+        max_bytes,
+        super::LatestMainContext::TraceUnavailable,
+    )
+    .await?;
+    Ok(())
+}
+
 pub(super) async fn materialize_selected(
     connection: &mut SqliteConnection,
     selection: &Selection,
diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
index f8ffb5fc7f..d2d8e17e8d 100644
--- a/crates/event-log/src/handoff.rs
+++ b/crates/event-log/src/handoff.rs
@@ -21,6 +21,8 @@ use crate::{EventLog, StoreError};
 use maka_runtime::event::{Fact, InvocationInput, InvocationOutcome, RuntimeEvent};
 use sqlx::{Connection, SqliteConnection};
 
+mod budget;
+
 impl EventLog {
     /// Eligibility only. The source still owns execution until its seal commits.
     pub async fn check_handoff(
@@ -182,6 +184,14 @@ pub(crate) async fn validate(
             ));
         }
     };
+    let replay_base = match input {
+        InvocationInput::Continuation { claim, .. } => Some(claim.base.high_water),
+        InvocationInput::Handoff { pause, .. } => pause.execution.replay_base,
+        _ => None,
+    };
+    if pause.execution.replay_base != replay_base {
+        return Err(invalid("handoff changes its admitted history projection"));
+    }
     if &pause.intent.root_run_id != root {
         return Err(invalid(
             "handoff must preserve its admitted logical model Run",
@@ -192,25 +202,7 @@ pub(crate) async fn validate(
     crate::context::safety::settled_boundary(tx, &event.invocation.invocation_id, i64::MAX as u64)
         .await?;
     crate::interactions::lifecycle::require_closed(tx, &event.invocation).await?;
-    // Use a stable upper bound in both preflight and append: time advancing
-    // while held must not consume the headroom a second time.
-    let mut envelope = event.clone();
-    envelope.recorded_at = std::time::UNIX_EPOCH;
-    let seal_bytes =
-        serde_json::to_vec(&envelope)?.len() + (u64::MAX.ilog10() + u32::MAX.ilog10()) as usize;
-    let source_bytes = maka_runtime::continuation::MAX_SOURCE_BYTES
-        .checked_sub(seal_bytes)
-        .ok_or(StoreError::PrefixTooLarge)?;
-    crate::run_prefix::read(
-        tx,
-        &event.invocation.session_id,
-        &event.invocation.run_id,
-        &event.invocation.invocation_id,
-        i64::MAX,
-        maka_runtime::continuation::MAX_SOURCE_EVENTS - 1,
-        source_bytes,
-    )
-    .await?;
+    budget::check(tx, event, &opening, pause).await?;
     let occupied: bool = sqlx::query_scalar(
         "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE kind='invocation_opened'
          AND (invocation_id=?1 OR json_extract(event_json,'$.invocation.run_id')=?2
diff --git a/crates/event-log/src/handoff/budget.rs b/crates/event-log/src/handoff/budget.rs
new file mode 100644
index 0000000000..73a92af124
--- /dev/null
+++ b/crates/event-log/src/handoff/budget.rs
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::StoreError;
+use maka_runtime::{
+    continuation::{
+        ContinuationClaim, MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, REPLAY_VERSION, ReplayEvidence,
+        RunBoundary, SessionBase,
+    },
+    event::{Fact, InvocationInput, RuntimeEvent},
+    handoff::HandoffPause,
+};
+use sqlx::SqliteConnection;
+
+pub(super) async fn check(
+    tx: &mut SqliteConnection,
+    seal: &RuntimeEvent,
+    opening: &RuntimeEvent,
+    pause: &HandoffPause,
+) -> Result<(), StoreError> {
+    let seal_bytes = envelope_bytes(seal)?;
+    crate::run_prefix::read(
+        tx,
+        &seal.invocation.session_id,
+        &seal.invocation.run_id,
+        &seal.invocation.invocation_id,
+        i64::MAX,
+        MAX_SOURCE_EVENTS - 1,
+        MAX_SOURCE_BYTES
+            .checked_sub(seal_bytes)
+            .ok_or(StoreError::PrefixTooLarge)?,
+    )
+    .await?;
+    let Fact::InvocationOpened { configuration, .. } = &opening.fact else {
+        return Err(super::invalid(
+            "handoff budget requires its canonical opening",
+        ));
+    };
+    // Sizing only: no claim is acquired and no guessed proof is persisted.
+    // Reserve the widest ordinals, fixed-width digests and both new envelopes.
+    let digest = format!("sha256:{}", "0".repeat(64));
+    let successor = RuntimeEvent::new(
+        pause.intent.successor(&seal.invocation),
+        Fact::InvocationOpened {
+            configuration: configuration.clone(),
+            input: InvocationInput::Handoff {
+                pause: Box::new(pause.clone()),
+                claim: Box::new(ContinuationClaim {
+                    id: pause.intent.claim_id.clone(),
+                    source: RunBoundary {
+                        invocation: seal.invocation.clone(),
+                        high_water: u64::MAX,
+                        digest: digest.clone(),
+                    },
+                    base: SessionBase {
+                        high_water: u64::MAX,
+                        digest: digest.clone(),
+                    },
+                    replay: ReplayEvidence {
+                        version: REPLAY_VERSION,
+                        digest,
+                        route_identity: pause.execution.route_identity.clone(),
+                    },
+                }),
+            },
+        },
+    );
+    let bytes = MAX_SOURCE_BYTES
+        .checked_sub(seal_bytes + envelope_bytes(&successor)?)
+        .ok_or(StoreError::PrefixTooLarge)?;
+    crate::context::read::check_handoff_capacity(tx, opening, MAX_SOURCE_EVENTS - 2, bytes).await
+}
+
+fn envelope_bytes(event: &RuntimeEvent) -> Result<usize, StoreError> {
+    let mut envelope = event.clone();
+    envelope.recorded_at = std::time::UNIX_EPOCH;
+    Ok(serde_json::to_vec(&envelope)?.len() + (u64::MAX.ilog10() + u32::MAX.ilog10()) as usize)
+}
diff --git a/crates/event-log/tests/continuation/handoff.rs b/crates/event-log/tests/continuation/handoff.rs
index 06dbd3f76c..c8ebd3efef 100644
--- a/crates/event-log/tests/continuation/handoff.rs
+++ b/crates/event-log/tests/continuation/handoff.rs
@@ -26,6 +26,23 @@ use maka_runtime::{
 };
 use std::num::NonZeroU16;
 
+pub(super) fn execution() -> Box<maka_runtime::handoff::HandoffExecution> {
+    use maka_runtime::handoff::{HandoffExecution, HandoffTools};
+    Box::new(HandoffExecution {
+        route_identity: digest('b'),
+        context: None,
+        provider_options: serde_json::json!({}),
+        main_output_limit: None,
+        supports_vision: false,
+        tools: HandoffTools {
+            catalog_digest: digest('c'),
+            loaded: Default::default(),
+        },
+        compaction_attempted: false,
+        replay_base: None,
+    })
+}
+
 #[tokio::test]
 async fn handoff_rejects_unresolved_provider_effects_but_accepts_effect_free_summary_failure() {
     for provider_effect in [false, true] {
@@ -85,6 +102,7 @@ async fn handoff_rejects_unresolved_provider_effects_but_accepts_effect_free_sum
                             claim_id: "claim".into(),
                         },
                         remaining_steps: NonZeroU16::new(2).unwrap(),
+                        execution: execution(),
                     },
                 },
             },
diff --git a/crates/event-log/tests/continuation/handoff_budget.rs b/crates/event-log/tests/continuation/handoff_budget.rs
index 6b05595248..40f7a813d9 100644
--- a/crates/event-log/tests/continuation/handoff_budget.rs
+++ b/crates/event-log/tests/continuation/handoff_budget.rs
@@ -29,113 +29,180 @@ use std::{
 };
 
 #[tokio::test]
-async fn near_capacity_handoff_keeps_its_seal_budget_when_timestamp_width_changes() {
-    let directory = tempfile::tempdir().unwrap();
-    let log = EventLog::open(&directory.path().join("capacity.sqlite"))
-        .await
-        .unwrap();
-    let source = opening("root", None);
-    append(&log, &source).await;
-    let context = log
-        .read_model_context("session", Some("root"), 100, 65536)
-        .await
-        .unwrap();
-    append(
-        &log,
-        &RuntimeEvent::new(
+async fn handoff_reserves_both_envelopes_before_sealing_a_near_capacity_context() {
+    for reserve_successor in [false, true] {
+        let directory = tempfile::tempdir().unwrap();
+        let log = EventLog::open(&directory.path().join("capacity.sqlite"))
+            .await
+            .unwrap();
+        let source = opening("root", None);
+        append(&log, &source).await;
+        let context = log
+            .read_model_context("session", Some("root"), 100, 65536)
+            .await
+            .unwrap();
+        append(
+            &log,
+            &RuntimeEvent::new(
+                source.invocation.clone(),
+                Fact::ModelRequested {
+                    step_id: "step".into(),
+                    model_id: "test".into(),
+                    purpose: Some(maka_runtime::context::ModelPurpose::Main),
+                    context: None,
+                    source_scope: context.source_evidence.scope,
+                    source_high_water: context.source_evidence.high_water,
+                    source_digest: context.source_evidence.digest,
+                    effective_source_digest: Some(context.effective_source_digest),
+                    input_digest: digest('a'),
+                    route_identity: digest('b'),
+                    checkpoint_event_id: None,
+                },
+            ),
+        )
+        .await;
+        let prefix = log
+            .run_prefix("session", "root", None, 100, MAX_SOURCE_BYTES)
+            .await
+            .unwrap()
+            .unwrap();
+        let existing_bytes: usize = prefix
+            .events
+            .iter()
+            .map(|event| serde_json::to_vec(&event.event).unwrap().len())
+            .sum();
+        let pause = HandoffPause {
+            intent: HandoffIntent {
+                handoff_id: "handoff".into(),
+                host_epoch: "host".into(),
+                root_run_id: "root".into(),
+                successor_run_id: "successor-run".into(),
+                successor_invocation_id: "successor-invocation".into(),
+                claim_id: "claim".into(),
+            },
+            remaining_steps: NonZeroU16::new(2).unwrap(),
+            execution: super::handoff::execution(),
+        };
+        let mut seal = RuntimeEvent::new(
+            source.invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::HandoffPaused {
+                    pause: pause.clone(),
+                },
+            },
+        );
+        let mut widest = serde_json::to_value(&seal).unwrap();
+        widest["recorded_at"] =
+            serde_json::json!({ "secs_since_epoch": u64::MAX, "nanos_since_epoch": u32::MAX });
+        let mut reserved_bytes = serde_json::to_vec(&widest).unwrap().len();
+        let mut future = serde_json::to_value(&source).unwrap();
+        future["invocation"] =
+            serde_json::to_value(pause.intent.successor(&source.invocation)).unwrap();
+        future["recorded_at"] = widest["recorded_at"].clone();
+        future["fact"]["input"] = serde_json::json!({
+            "kind":"handoff", "pause": pause,
+            "claim": {
+                "id":pause.intent.claim_id,
+                "source":{"invocation":source.invocation,"high_water":u64::MAX,"digest":digest('a')},
+                "base":{"high_water":u64::MAX,"digest":digest('a')},
+                "replay":{"version":REPLAY_VERSION,"digest":digest('a'),"route_identity":digest('b')}
+            }
+        });
+        if reserve_successor {
+            reserved_bytes += serde_json::to_vec(&future).unwrap().len();
+        }
+        let mut completed = RuntimeEvent::new(
             source.invocation.clone(),
-            Fact::ModelRequested {
+            Fact::ModelCompleted {
                 step_id: "step".into(),
-                model_id: "test".into(),
-                purpose: Some(maka_runtime::context::ModelPurpose::Main),
-                context: None,
-                source_scope: context.source_evidence.scope,
-                source_high_water: context.source_evidence.high_water,
-                source_digest: context.source_evidence.digest,
-                effective_source_digest: Some(context.effective_source_digest),
-                input_digest: digest('a'),
-                route_identity: digest('b'),
-                checkpoint_event_id: None,
+                output: ModelStep {
+                    parts: vec![ModelPart::Text {
+                        text_kind: TextKind::Text,
+                        text: String::new(),
+                        provider_options: None,
+                    }],
+                    finish_reason: ModelFinishReason::Stop,
+                    usage: ModelUsage::default(),
+                    provider_options: None,
+                    response_id: None,
+                    model: None,
+                    timestamp: None,
+                },
             },
-        ),
-    )
-    .await;
-    let prefix = log
-        .run_prefix("session", "root", None, 100, MAX_SOURCE_BYTES)
-        .await
-        .unwrap()
-        .unwrap();
-    let existing_bytes: usize = prefix
-        .events
-        .iter()
-        .map(|event| serde_json::to_vec(&event.event).unwrap().len())
-        .sum();
-    let pause = HandoffPause {
-        intent: HandoffIntent {
-            handoff_id: "handoff".into(),
-            host_epoch: "host".into(),
-            root_run_id: "root".into(),
-            successor_run_id: "successor-run".into(),
-            successor_invocation_id: "successor-invocation".into(),
-            claim_id: "claim".into(),
-        },
-        remaining_steps: NonZeroU16::new(2).unwrap(),
-    };
-    let mut seal = RuntimeEvent::new(
-        source.invocation.clone(),
-        Fact::InvocationEnded {
-            outcome: InvocationOutcome::HandoffPaused {
-                pause: pause.clone(),
+        );
+        let remaining = MAX_SOURCE_BYTES
+            - reserved_bytes
+            - existing_bytes
+            - serde_json::to_vec(&completed).unwrap().len();
+        let Fact::ModelCompleted { output, .. } = &mut completed.fact else {
+            unreachable!()
+        };
+        let ModelPart::Text { text, .. } = &mut output.parts[0] else {
+            unreachable!()
+        };
+        *text = "x".repeat(remaining);
+        append(&log, &completed).await;
+        if !reserve_successor {
+            assert!(
+                log.check_handoff(&source.invocation, &pause).await.is_err(),
+                "fitting the source seal alone does not make its successor readable"
+            );
+            assert!(log.append(&EventWrite::plain(seal).unwrap()).await.is_err());
+            log.close().await.unwrap();
+            continue;
+        }
+        log.check_handoff(&source.invocation, &pause).await.unwrap();
+        seal.recorded_at = UNIX_EPOCH + Duration::new(100_000_000_000, 999_999_999);
+        append(&log, &seal).await;
+        let sealed = log
+            .run_prefix("session", "root", None, MAX_SOURCE_EVENTS, MAX_SOURCE_BYTES)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(sealed.events.last().unwrap().event, seal);
+        assert_eq!(
+            sealed.high_water, 4,
+            "the pause remains claimable within the exact source budget"
+        );
+        let base = log
+            .context_before_run(&source.invocation, 100, MAX_SOURCE_BYTES)
+            .await
+            .unwrap();
+        let claim = ContinuationClaim {
+            id: pause.intent.claim_id.clone(),
+            source: RunBoundary {
+                invocation: sealed.invocation,
+                high_water: sealed.high_water,
+                digest: sealed.digest,
             },
-        },
-    );
-    let mut widest = serde_json::to_value(&seal).unwrap();
-    widest["recorded_at"] =
-        serde_json::json!({ "secs_since_epoch": u64::MAX, "nanos_since_epoch": u32::MAX });
-    let reserved_bytes = serde_json::to_vec(&widest).unwrap().len();
-    let mut completed = RuntimeEvent::new(
-        source.invocation.clone(),
-        Fact::ModelCompleted {
-            step_id: "step".into(),
-            output: ModelStep {
-                parts: vec![ModelPart::Text {
-                    text_kind: TextKind::Text,
-                    text: String::new(),
-                    provider_options: None,
-                }],
-                finish_reason: ModelFinishReason::Stop,
-                usage: ModelUsage::default(),
-                provider_options: None,
-                response_id: None,
-                model: None,
-                timestamp: None,
+            base: SessionBase {
+                high_water: base.source_evidence.high_water,
+                digest: base.source_evidence.digest,
+            },
+            replay: ReplayEvidence {
+                version: REPLAY_VERSION,
+                digest: digest('a'),
+                route_identity: digest('b'),
             },
-        },
-    );
-    let remaining = MAX_SOURCE_BYTES
-        - reserved_bytes
-        - existing_bytes
-        - serde_json::to_vec(&completed).unwrap().len();
-    let Fact::ModelCompleted { output, .. } = &mut completed.fact else {
-        unreachable!()
-    };
-    let ModelPart::Text { text, .. } = &mut output.parts[0] else {
-        unreachable!()
-    };
-    *text = "x".repeat(remaining);
-    append(&log, &completed).await;
-    log.check_handoff(&source.invocation, &pause).await.unwrap();
-    seal.recorded_at = UNIX_EPOCH + Duration::new(100_000_000_000, 999_999_999);
-    append(&log, &seal).await;
-    let sealed = log
-        .run_prefix("session", "root", None, MAX_SOURCE_EVENTS, MAX_SOURCE_BYTES)
+        };
+        let mut target = opening("unused", None);
+        target.invocation = pause.intent.successor(&source.invocation);
+        let Fact::InvocationOpened { input, .. } = &mut target.fact else {
+            unreachable!()
+        };
+        *input = InvocationInput::Handoff {
+            claim: Box::new(claim),
+            pause: Box::new(pause),
+        };
+        append(&log, &target).await;
+        log.read_model_context(
+            "session",
+            Some(&target.invocation.invocation_id),
+            MAX_SOURCE_EVENTS,
+            MAX_SOURCE_BYTES,
+        )
         .await
-        .unwrap()
         .unwrap();
-    assert_eq!(sealed.events.last().unwrap().event, seal);
-    assert_eq!(
-        sealed.high_water, 4,
-        "the pause remains claimable within the exact source budget"
-    );
-    log.close().await.unwrap();
+        log.close().await.unwrap();
+    }
 }
diff --git a/crates/event-log/tests/continuation/handoff_claim.rs b/crates/event-log/tests/continuation/handoff_claim.rs
index 783d93620d..978dc83e93 100644
--- a/crates/event-log/tests/continuation/handoff_claim.rs
+++ b/crates/event-log/tests/continuation/handoff_claim.rs
@@ -48,6 +48,7 @@ async fn handoff_claim_preserves_turn_and_frozen_ancestry_without_releasing_rese
                 claim_id: format!("claim-{round}"),
             },
             remaining_steps: NonZeroU16::new(3 - round).unwrap(),
+            execution: super::handoff::execution(),
         };
         append(
             &log,
@@ -176,6 +177,31 @@ async fn handoff_claim_preserves_turn_and_frozen_ancestry_without_releasing_rese
     let last = claim(&log, "manual", &source, base).await;
     let manual = opening("manual-turn", Some(last));
     append(&log, &manual).await;
+    let Fact::InvocationOpened {
+        input: InvocationInput::Continuation { claim, .. },
+        ..
+    } = &manual.fact
+    else {
+        unreachable!()
+    };
+    let mut pause = HandoffPause {
+        intent: HandoffIntent {
+            handoff_id: "manual-handoff".into(),
+            host_epoch: "host".into(),
+            root_run_id: manual.invocation.run_id.clone(),
+            successor_run_id: "manual-successor-run".into(),
+            successor_invocation_id: "manual-successor-invocation".into(),
+            claim_id: "manual-successor-claim".into(),
+        },
+        remaining_steps: NonZeroU16::new(2).unwrap(),
+        execution: super::handoff::execution(),
+    };
+    assert!(
+        log.check_handoff(&manual.invocation, &pause).await.is_err(),
+        "a physical handoff cannot discard manual resume's stable-cut policy"
+    );
+    pause.execution.replay_base = Some(claim.base.high_water);
+    log.check_handoff(&manual.invocation, &pause).await.unwrap();
     close(&log, &manual).await;
     let prefix = log
         .run_prefix("session", &manual.invocation.run_id, None, 100, 65536)
@@ -245,6 +271,7 @@ async fn ineligible_handoff_never_seals_or_reserves_a_successor() {
             claim_id: "claim".into(),
         },
         remaining_steps: NonZeroU16::new(3).unwrap(),
+        execution: super::handoff::execution(),
     };
     assert!(
         log.check_handoff(&missing.invocation, &pause)
diff --git a/crates/presentation/tests/projection.rs b/crates/presentation/tests/projection.rs
index 561b8ea6a1..4d7c61b01d 100644
--- a/crates/presentation/tests/projection.rs
+++ b/crates/presentation/tests/projection.rs
@@ -172,6 +172,19 @@ fn completed_and_interrupted_rows_keep_overlay_identity_time_and_original_decode
                     claim_id: "claim".into(),
                 },
                 remaining_steps: std::num::NonZeroU16::new(2).unwrap(),
+                execution: Box::new(maka_runtime::handoff::HandoffExecution {
+                    route_identity: format!("sha256:{}", "b".repeat(64)),
+                    context: None,
+                    provider_options: serde_json::json!({}),
+                    main_output_limit: None,
+                    supports_vision: false,
+                    tools: maka_runtime::handoff::HandoffTools {
+                        catalog_digest: format!("sha256:{}", "c".repeat(64)),
+                        loaded: Default::default(),
+                    },
+                    compaction_attempted: false,
+                    replay_base: None,
+                }),
             },
         },
     ] {
diff --git a/crates/runtime/src/handoff.rs b/crates/runtime/src/handoff.rs
index 44e0033ad6..f4a5d6e6b2 100644
--- a/crates/runtime/src/handoff.rs
+++ b/crates/runtime/src/handoff.rs
@@ -24,6 +24,9 @@ use crate::{event::Invocation, interaction::entity_id};
 use serde::{Deserialize, Serialize};
 use std::num::NonZeroU16;
 
+mod execution;
+pub use execution::{HandoffExecution, HandoffTools};
+
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(deny_unknown_fields)]
 pub struct HandoffIntent {
@@ -41,6 +44,7 @@ pub struct HandoffPause {
     pub intent: HandoffIntent,
     /// Captured by the Engine, never restored from mutable Session policy.
     pub remaining_steps: NonZeroU16,
+    pub execution: Box<HandoffExecution>,
 }
 
 impl HandoffIntent {
@@ -84,6 +88,11 @@ impl HandoffPause {
         claim.validate_boundary(target)?;
         self.validate(&claim.source.invocation)?;
         if claim.id != self.intent.claim_id
+            || claim.replay.route_identity != self.execution.route_identity
+            || self
+                .execution
+                .replay_base
+                .is_some_and(|base| base != claim.base.high_water)
             || *target != self.intent.successor(&claim.source.invocation)
         {
             return Err("handoff must acquire its reserved successor in the same Turn");
@@ -93,6 +102,7 @@ impl HandoffPause {
 
     pub fn validate(&self, source: &Invocation) -> Result<(), &'static str> {
         self.intent.validate(source)?;
+        self.execution.validate()?;
         if self.remaining_steps.get() > 256 {
             return Err("handoff exceeds the Engine step budget");
         }
diff --git a/crates/runtime/src/handoff/execution.rs b/crates/runtime/src/handoff/execution.rs
new file mode 100644
index 0000000000..db3a5adf4c
--- /dev/null
+++ b/crates/runtime/src/handoff/execution.rs
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{archive::valid_projection_digest, context::ModelRequestContext};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::BTreeSet;
+
+/// Continuation settings, not credentials or process-local resources.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct HandoffExecution {
+    pub route_identity: String,
+    pub context: Option<ModelRequestContext>,
+    /// Provider-specific options intentionally retain their extensible JSON shape.
+    pub provider_options: Value,
+    pub main_output_limit: Option<u64>,
+    pub supports_vision: bool,
+    pub tools: HandoffTools,
+    pub compaction_attempted: bool,
+    /// Manual continuation's stable-cut policy, absent for ordinary conversation.
+    /// Physical handoff must not introduce or discard this projection policy.
+    pub replay_base: Option<u64>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct HandoffTools {
+    /// Covers definitions, nesting, execution semantics and discovery mode.
+    /// It does not promise persistence of a handler's executable implementation.
+    pub catalog_digest: String,
+    pub loaded: BTreeSet<String>,
+}
+
+impl HandoffExecution {
+    pub(super) fn validate(&self) -> Result<(), &'static str> {
+        if !valid_projection_digest(&self.route_identity)
+            || !valid_projection_digest(&self.tools.catalog_digest)
+            || self
+                .main_output_limit
+                .is_some_and(|n| n == 0 || n > 10_000_000_000)
+            || self.tools.loaded.len() > 128
+            || self
+                .tools
+                .loaded
+                .iter()
+                .any(|name| name.is_empty() || name.len() > 128)
+        {
+            return Err("invalid handoff execution settings");
+        }
+        if let Some(context) = &self.context {
+            context.validate()?;
+        }
+        Ok(())
+    }
+}
diff --git a/crates/tools/src/availability.rs b/crates/tools/src/availability.rs
index 32be66dcc4..94a6d4b07b 100644
--- a/crates/tools/src/availability.rs
+++ b/crates/tools/src/availability.rs
@@ -97,6 +97,30 @@ impl Availability {
     pub fn clear(&self) {
         self.active.lock().unwrap().clear();
     }
+
+    pub fn checkpoint(&self) -> maka_runtime::handoff::HandoffTools {
+        maka_runtime::handoff::HandoffTools {
+            catalog_digest: self.catalog.digest(),
+            loaded: self.active.lock().unwrap().clone(),
+        }
+    }
+
+    pub fn restore(
+        &self,
+        checkpoint: &maka_runtime::handoff::HandoffTools,
+    ) -> Result<(), ToolError> {
+        if checkpoint.catalog_digest != self.catalog.digest()
+            || (!checkpoint.loaded.is_empty() && !self.enabled())
+            || checkpoint
+                .loaded
+                .iter()
+                .any(|name| direct(name) || !self.catalog.contains(name))
+        {
+            return Err(ToolError::Failed("handoff tool catalog changed".into()));
+        }
+        *self.active.lock().unwrap() = checkpoint.loaded.clone();
+        Ok(())
+    }
     pub fn definition(&self) -> Option<ToolDefinition> {
         if !self.enabled() {
             return None;
diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs
index ba5f759480..cef8b9674a 100644
--- a/crates/tools/src/catalog.rs
+++ b/crates/tools/src/catalog.rs
@@ -157,6 +157,25 @@ impl ToolCatalog {
         }
     }
 
+    /// Stable request-surface identity; process-local handler addresses are excluded.
+    pub fn digest(&self) -> String {
+        let entries: Vec<_> = self
+            .entries
+            .values()
+            .map(|entry| {
+                let r = &entry.registration;
+                (
+                    &r.definition,
+                    r.nesting == ToolNesting::Nestable,
+                    r.semantics == ToolSemantics::ExclusiveStep,
+                )
+            })
+            .collect();
+        maka_runtime::artifact::content_digest(
+            &serde_json::to_vec(&(self.discovery, entries)).expect("tool definitions are JSON"),
+        )
+    }
+
     pub fn semantics(&self, name: &str) -> Result<ToolSemantics, ToolRejection> {
         self.entries
             .get(name)
@@ -180,6 +199,10 @@ impl ToolCatalog {
         self.entries.keys().cloned().collect()
     }
 
+    pub(super) fn contains(&self, name: &str) -> bool {
+        self.entries.contains_key(name)
+    }
+
     pub fn prepare(
         &self,
         name: String,
diff --git a/crates/tools/src/dispatch.rs b/crates/tools/src/dispatch.rs
index af184481a1..807b3672cd 100644
--- a/crates/tools/src/dispatch.rs
+++ b/crates/tools/src/dispatch.rs
@@ -61,6 +61,17 @@ impl RunTools {
         self.availability.clear();
     }
 
+    pub fn checkpoint(&self) -> maka_runtime::handoff::HandoffTools {
+        self.availability.checkpoint()
+    }
+
+    pub fn restore(
+        &self,
+        checkpoint: &maka_runtime::handoff::HandoffTools,
+    ) -> Result<(), ToolError> {
+        self.availability.restore(checkpoint)
+    }
+
     /// Capture once per logical model step, before sending any physical attempt.
     pub fn capture(&self) -> RequestTools<'_> {
         let availability = if self.mode == ToolMode::CodeMode {
diff --git a/crates/tools/tests/integration/catalog.rs b/crates/tools/tests/integration/catalog.rs
index bf190a0475..dfafd3eadd 100644
--- a/crates/tools/tests/integration/catalog.rs
+++ b/crates/tools/tests/integration/catalog.rs
@@ -187,7 +187,7 @@ async fn discovery_reports_schema_limits_without_loading_blocked_tools_or_runnin
         let run = RunTools::new(
             log.clone(),
             invocation.clone(),
-            catalog,
+            catalog.clone(),
             ToolMode::Direct,
             CodeExecutor::new(1, CellLimits::default()).unwrap(),
         );
@@ -224,6 +224,41 @@ async fn discovery_reports_schema_limits_without_loading_blocked_tools_or_runnin
         );
         assert!(result["blocked"]["schemaChars"].as_u64().unwrap() > 33_000);
         let next = run.capture();
+        let checkpoint = run.checkpoint();
+        let restored = RunTools::new(
+            log.clone(),
+            invocation.clone(),
+            catalog.clone(),
+            ToolMode::Direct,
+            CodeExecutor::new(1, CellLimits::default()).unwrap(),
+        );
+        restored.restore(&checkpoint).unwrap();
+        assert_eq!(restored.capture().definitions(), next.definitions());
+        let mut invalid = checkpoint.clone();
+        invalid.loaded.insert("withheld".into());
+        assert!(restored.restore(&invalid).is_err());
+        assert_eq!(
+            restored.checkpoint(),
+            checkpoint,
+            "failed restore preserves the admitted view"
+        );
+        let changed = RunTools::new(
+            log.clone(),
+            invocation.clone(),
+            ToolCatalog::default(),
+            ToolMode::Direct,
+            CodeExecutor::new(1, CellLimits::default()).unwrap(),
+        );
+        assert!(changed.restore(&checkpoint).is_err());
+        drop(changed);
+        restored.clear_loaded();
+        assert!(restored.checkpoint().loaded.is_empty());
+        assert_eq!(
+            run.checkpoint(),
+            checkpoint,
+            "successor state does not share the old cache"
+        );
+        drop(restored);
         run.clear_loaded();
         assert_eq!(
             next.definitions()


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 03/29: feat(event-log): authenticate same-turn handoff successors

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit a32b653af0b43b9979aeef76c70ee77226f1c7d1
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 11:34:20 2026 +0800

    feat(event-log): authenticate same-turn handoff successors
---
 crates/agent/src/continuation.rs                   |  13 +-
 crates/agent/src/handoff.rs                        |  49 +++-
 crates/agent/src/history/projection.rs             |   2 +-
 crates/agent/src/steps.rs                          |   7 +
 crates/agent/tests/integration/handoff.rs          |   5 +-
 .../event-log/migrations/0023_handoff_claims.sql   |  43 +++
 crates/event-log/src/context/boundary.rs           |   8 +-
 crates/event-log/src/context/request.rs            |  16 +-
 crates/event-log/src/context/selection.rs          |  11 +-
 crates/event-log/src/continuation.rs               |  37 ++-
 crates/event-log/src/handoff.rs                    | 205 +++++++++++--
 crates/event-log/src/schema.rs                     |   2 +-
 crates/event-log/tests/continuation.rs             |   4 +
 .../event-log/tests/continuation/handoff_budget.rs | 141 +++++++++
 .../event-log/tests/continuation/handoff_claim.rs  | 326 +++++++++++++++++++++
 crates/event-log/tests/migrations.rs               |  10 +-
 crates/presentation/src/lib.rs                     |   2 +-
 crates/runtime-host/src/execution/snapshot.rs      |   2 +-
 crates/runtime/src/context.rs                      |   9 +-
 crates/runtime/src/continuation.rs                 |  17 +-
 crates/runtime/src/event_write.rs                  |  18 +-
 crates/runtime/src/handoff.rs                      |  15 +
 crates/runtime/src/input.rs                        |  24 ++
 23 files changed, 880 insertions(+), 86 deletions(-)

diff --git a/crates/agent/src/continuation.rs b/crates/agent/src/continuation.rs
index 00ad14811f..f49387f0c4 100644
--- a/crates/agent/src/continuation.rs
+++ b/crates/agent/src/continuation.rs
@@ -21,7 +21,10 @@ use crate::{Inner, RunError, RunInput, model_attempt};
 use maka_model::prompt::{AssistantPart, Message};
 use maka_runtime::{
     context::ModelPurpose,
-    continuation::{ContinuationClaim, REPLAY_VERSION, ReplayEvidence, RunBoundary, SessionBase},
+    continuation::{
+        ContinuationClaim, MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, REPLAY_VERSION, ReplayEvidence,
+        RunBoundary, SessionBase,
+    },
     event::Fact,
     input::InvocationInput,
 };
@@ -64,8 +67,8 @@ pub(super) async fn inspect(
             &source.invocation.session_id,
             &source.invocation.run_id,
             None,
-            10_000,
-            8 * 1024 * 1024,
+            MAX_SOURCE_EVENTS,
+            MAX_SOURCE_BYTES,
         )
         .await?
         .ok_or_else(|| invalid("continuation source is missing"))?;
@@ -109,7 +112,9 @@ pub(super) async fn inspect(
                 digest: base.source_evidence.digest,
             }
         }
-        InvocationInput::Continuation { claim, .. } => claim.base.clone(),
+        InvocationInput::Continuation { claim, .. } | InvocationInput::Handoff { claim, .. } => {
+            claim.base.clone()
+        }
         _ => return Err(invalid("source is not a resumable model Run")),
     };
     let context = inner
diff --git a/crates/agent/src/handoff.rs b/crates/agent/src/handoff.rs
index 456b7c7ef1..c986e52c30 100644
--- a/crates/agent/src/handoff.rs
+++ b/crates/agent/src/handoff.rs
@@ -95,29 +95,39 @@ impl HandoffGate {
             .ok_or(crate::RunError::Busy)
     }
 
-    pub(crate) async fn boundary(
+    pub(crate) async fn boundary<F: std::future::Future<Output = bool>>(
         &self,
         remaining_steps: NonZeroU16,
         cancellation: &CancellationToken,
+        preflight: impl FnOnce(HandoffPause) -> F,
     ) -> Option<HandoffPause> {
         let mut changes = self.state.subscribe();
-        let mut ticket = None;
+        let ticket = match &*changes.borrow_and_update() {
+            State::Requested(ticket) => ticket.clone(),
+            _ => return None,
+        };
+        let pause = HandoffPause {
+            intent: ticket.as_ref().clone(),
+            remaining_steps,
+        };
+        if !preflight(pause.clone()).await || cancellation.is_cancelled() {
+            self.cancel(&ticket);
+            return None;
+        }
         self.state.send_if_modified(|state| {
             let State::Requested(intent) = state else {
                 return false;
             };
-            ticket = Some(intent.clone());
+            if !Arc::ptr_eq(intent, &ticket) {
+                return false;
+            }
             *state = State::Held {
-                pause: HandoffPause {
-                    intent: intent.as_ref().clone(),
-                    remaining_steps,
-                },
+                pause,
                 cancellation: cancellation.clone(),
                 ticket: intent.clone(),
             };
             true
         });
-        let ticket = ticket?;
         loop {
             let state = changes.borrow_and_update().clone();
             match state {
@@ -261,8 +271,21 @@ mod tests {
         let gate = HandoffGate::new(source, "run".into());
         let cancellation = CancellationToken::new();
         let reservation = gate.reserve(intent.clone()).unwrap();
+        let (result, held) = tokio::join!(
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
+                std::future::ready(false)
+            }),
+            reservation.ready(),
+        );
+        assert!(
+            result.is_none() && held.is_none(),
+            "failed preflight releases the same Run"
+        );
+        let reservation = gate.reserve(intent.clone()).unwrap();
         let (result, ()) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation),
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
+                std::future::ready(true)
+            }),
             async {
                 let held = reservation.ready().await.unwrap();
                 cancellation.cancel();
@@ -274,7 +297,9 @@ mod tests {
         let cancellation = CancellationToken::new();
         let reservation = gate.reserve(intent.clone()).unwrap();
         let (result, stale) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation),
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
+                std::future::ready(true)
+            }),
             async {
                 let held = reservation.ready().await.unwrap();
                 cancellation.cancel();
@@ -286,7 +311,9 @@ mod tests {
         let reservation = gate.reserve(intent).unwrap();
         drop(stale); // The same durable intent does not reuse a scheduling owner.
         let (result, pending) = tokio::join!(
-            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation),
+            gate.boundary(NonZeroU16::new(2).unwrap(), &cancellation, |_| {
+                std::future::ready(true)
+            }),
             async { reservation.ready().await.unwrap().commit().unwrap() },
         );
         assert!(result.is_some());
diff --git a/crates/agent/src/history/projection.rs b/crates/agent/src/history/projection.rs
index 8a3da33926..1a7ce6159b 100644
--- a/crates/agent/src/history/projection.rs
+++ b/crates/agent/src/history/projection.rs
@@ -120,7 +120,7 @@ pub(super) fn build<'a>(
         }
         match &stored.event.fact {
             Fact::InvocationOpened { input, .. } => {
-                if matches!(input, InvocationInput::Continuation { .. }) {
+                if input.inherited_claim().is_some() {
                     continue;
                 }
                 let InvocationInput::Message { content, .. } = input else {
diff --git a/crates/agent/src/steps.rs b/crates/agent/src/steps.rs
index 590df12e61..8afce8ae82 100644
--- a/crates/agent/src/steps.rs
+++ b/crates/agent/src/steps.rs
@@ -53,6 +53,13 @@ pub(super) async fn run(
                 std::num::NonZeroU16::new((max_steps - step) as u16)
                     .expect("validated step budget"),
                 cancellation,
+                |pause| async move {
+                    inner
+                        .log
+                        .check_handoff(&input.invocation, &pause)
+                        .await
+                        .is_ok()
+                },
             )
             .await
         {
diff --git a/crates/agent/tests/integration/handoff.rs b/crates/agent/tests/integration/handoff.rs
index 13b5393a52..97b0c5ef95 100644
--- a/crates/agent/tests/integration/handoff.rs
+++ b/crates/agent/tests/integration/handoff.rs
@@ -78,7 +78,10 @@ async fn handoff_waits_for_settlement_rollback_keeps_run_and_seal_survives_reope
             CodeExecutor::new(1, CellLimits::default()).unwrap());
         let count = Arc::new(AtomicUsize::new(0));
         let effect = Arc::new(Effect { log: log.clone(), count: count.clone() });
-        let running = engine.start(input(&base, "first", effect), CancellationToken::new()).await.unwrap();
+        let mut run = input(&base, "first", effect);
+        run.configuration.workspace_identity = Some(maka_runtime::execution::WorkspaceIdentity::from_marker_id(
+            "ef751105-55b5-4d65-a364-646281586a17").unwrap());
+        let running = engine.start(run, CancellationToken::new()).await.unwrap();
         first.await.unwrap();
         let gate = running.handoff().unwrap().clone();
         let reservation = gate.reserve(intent("cancelled")).unwrap();
diff --git a/crates/event-log/migrations/0023_handoff_claims.sql b/crates/event-log/migrations/0023_handoff_claims.sql
new file mode 100644
index 0000000000..9098e88b4a
--- /dev/null
+++ b/crates/event-log/migrations/0023_handoff_claims.sql
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+-- Both continuations acquire the source through their canonical opening.
+DROP INDEX continuation_claim_id;
+DROP INDEX continuation_source_boundary;
+CREATE UNIQUE INDEX continuation_claim_id ON event_log(
+    json_extract(event_json, '$.fact.input.claim.id')
+) WHERE kind = 'invocation_opened' AND json_extract(event_json, '$.fact.input.kind') IN ('continuation','handoff');
+CREATE UNIQUE INDEX continuation_source_boundary ON event_log(
+    json_extract(event_json, '$.invocation.session_id'),
+    json_extract(event_json, '$.fact.input.claim.source.invocation.run_id'),
+    json_extract(event_json, '$.fact.input.claim.source.high_water')
+) WHERE kind = 'invocation_opened' AND json_extract(event_json, '$.fact.input.kind') IN ('continuation','handoff');
+
+-- No two paused owners can reserve the same future physical execution.
+CREATE UNIQUE INDEX handoff_successor_run ON event_log(
+    json_extract(event_json, '$.fact.outcome.pause.intent.successor_run_id')
+) WHERE kind = 'invocation_ended' AND json_extract(event_json, '$.fact.outcome.kind') = 'handoff_paused';
+CREATE UNIQUE INDEX handoff_successor_invocation ON event_log(
+    json_extract(event_json, '$.fact.outcome.pause.intent.successor_invocation_id')
+) WHERE kind = 'invocation_ended' AND json_extract(event_json, '$.fact.outcome.kind') = 'handoff_paused';
+CREATE UNIQUE INDEX handoff_claim ON event_log(
+    json_extract(event_json, '$.fact.outcome.pause.intent.claim_id')
+) WHERE kind = 'invocation_ended' AND json_extract(event_json, '$.fact.outcome.kind') = 'handoff_paused';
+
+PRAGMA user_version = 23;
diff --git a/crates/event-log/src/context/boundary.rs b/crates/event-log/src/context/boundary.rs
index 91324bc9f7..de6d480d03 100644
--- a/crates/event-log/src/context/boundary.rs
+++ b/crates/event-log/src/context/boundary.rs
@@ -56,11 +56,15 @@ pub(super) async fn source_fence(
         (CheckpointMode::Standalone, InvocationInput::ContextCompact { .. })
         | (
             CheckpointMode::PreTurn,
-            InvocationInput::Message { .. } | InvocationInput::Continuation { .. },
+            InvocationInput::Message { .. }
+            | InvocationInput::Continuation { .. }
+            | InvocationInput::Handoff { .. },
         ) => *opened,
         (
             CheckpointMode::MidTurn { anchor_event_id },
-            InvocationInput::Message { .. } | InvocationInput::Continuation { .. },
+            InvocationInput::Message { .. }
+            | InvocationInput::Continuation { .. }
+            | InvocationInput::Handoff { .. },
         ) => {
             if anchor_event_id != &event.id {
                 return Err(invalid(
diff --git a/crates/event-log/src/context/request.rs b/crates/event-log/src/context/request.rs
index c7899fa8bd..da8c3196f9 100644
--- a/crates/event-log/src/context/request.rs
+++ b/crates/event-log/src/context/request.rs
@@ -46,7 +46,7 @@ pub(super) async fn validate(
     // Legacy facts remain replayable. New explicit purposes must agree with the
     // canonical opening; automatic summaries can never use the legacy default.
     if purpose.is_none() {
-        let compact: bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened' AND json_extract(event_json,'$.fact.input.kind') IN ('context_compact','continuation'))")
+        let compact: bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened' AND json_extract(event_json,'$.fact.input.kind') IN ('context_compact','continuation','handoff'))")
             .bind(&event.invocation.invocation_id).fetch_one(connection).await?;
         if compact {
             return Err(invalid(
@@ -79,16 +79,18 @@ pub(super) async fn validate(
     let high = if resolved == ModelPurpose::Summary {
         let mode = match input {
             InvocationInput::ContextCompact { .. } => CheckpointMode::Standalone,
-            InvocationInput::Message { .. } | InvocationInput::Continuation { .. }
+            InvocationInput::Message { .. }
+            | InvocationInput::Continuation { .. }
+            | InvocationInput::Handoff { .. }
                 if *source_high_water < opened =>
             {
                 CheckpointMode::PreTurn
             }
-            InvocationInput::Message { .. } | InvocationInput::Continuation { .. } => {
-                CheckpointMode::MidTurn {
-                    anchor_event_id: opening.id.clone(),
-                }
-            }
+            InvocationInput::Message { .. }
+            | InvocationInput::Continuation { .. }
+            | InvocationInput::Handoff { .. } => CheckpointMode::MidTurn {
+                anchor_event_id: opening.id.clone(),
+            },
             _ => return Err(invalid("invalid summary opening")),
         };
         boundary::summary_span(connection, event, i64::MAX as u64).await?;
diff --git a/crates/event-log/src/context/selection.rs b/crates/event-log/src/context/selection.rs
index c534f653c4..14ef67e5b7 100644
--- a/crates/event-log/src/context/selection.rs
+++ b/crates/event-log/src/context/selection.rs
@@ -73,12 +73,15 @@ impl Selection {
             return Err(super::invalid("lineage root has no opening"));
         };
         let mut runs = vec![invocation];
-        let base = match input {
+        let base = match &input {
             InvocationInput::Message { .. } => {
                 super::evidence::high_water(connection, session, sequence as i64).await?
             }
-            InvocationInput::Continuation { claim, .. } => {
-                claim.validate(&event.invocation).map_err(super::invalid)?;
+            InvocationInput::Continuation { claim, .. }
+            | InvocationInput::Handoff { claim, .. } => {
+                input
+                    .validate_inheritance(&event.invocation)
+                    .map_err(super::invalid)?;
                 let workspace = configuration
                     .as_ref()
                     .and_then(|c| c.workspace_identity.as_ref())
@@ -116,7 +119,7 @@ impl Selection {
         let Fact::InvocationOpened { input, .. } = &opening.fact else {
             return Err(super::invalid("selection requires a canonical opening"));
         };
-        if matches!(input, InvocationInput::Continuation { .. }) {
+        if input.inherited_claim().is_some() {
             Self::resolve(
                 connection,
                 &LogScope::Lineage {
diff --git a/crates/event-log/src/continuation.rs b/crates/event-log/src/continuation.rs
index 912b8428e7..564550ba0d 100644
--- a/crates/event-log/src/continuation.rs
+++ b/crates/event-log/src/continuation.rs
@@ -21,7 +21,7 @@
 
 use crate::{EventLog, StoreError, sequence_number};
 use maka_runtime::{
-    continuation::{MAX_ANCESTRY, RunBoundary},
+    continuation::{MAX_ANCESTRY, MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS, RunBoundary},
     event::{Fact, InvocationInput, RuntimeEvent, StoredEvent},
 };
 use sqlx::{Connection, SqliteConnection};
@@ -68,7 +68,7 @@ impl EventLog {
             let rows: Vec<(i64, Option<String>)> = sqlx::query_as(
                 "SELECT sequence, CASE WHEN length(CAST(event_json AS BLOB)) <= 1048576 THEN event_json END
                  FROM runtime_events WHERE kind='invocation_opened'
-                 AND json_extract(event_json,'$.fact.input.kind')='continuation'
+                 AND json_extract(event_json,'$.fact.input.kind') IN ('continuation','handoff')
                  AND json_extract(event_json,'$.invocation.session_id')=?
                  AND json_extract(event_json,'$.fact.input.claim.source.invocation.run_id')=?
                  AND json_extract(event_json,'$.fact.input.claim.source.high_water')=? LIMIT 2"
@@ -77,10 +77,11 @@ impl EventLog {
             if rows.len() > 1 { return Err(invalid("ambiguous continuation claim")); }
             let result = rows.into_iter().next().map(|(sequence, json)| {
                 let event: RuntimeEvent = serde_json::from_str(&json.ok_or_else(|| invalid("claim opening exceeds capacity"))?)?;
-                let Fact::InvocationOpened { input: InvocationInput::Continuation { claim, .. }, .. } = &event.fact else {
+                let Fact::InvocationOpened { input, .. } = &event.fact else {
                     return Err(invalid("claim index has invalid opening"));
                 };
-                claim.validate(&event.invocation).map_err(invalid)?;
+                let claim = input.inherited_claim().ok_or_else(|| invalid("claim index has no claim"))?;
+                input.validate_inheritance(&event.invocation).map_err(invalid)?;
                 if claim.source != source { return Err(invalid("claimed source evidence changed")); }
                 Ok(StoredEvent { sequence: sequence_number(sequence)?, event })
             }).transpose()?;
@@ -95,13 +96,18 @@ pub(crate) async fn validate(
     event: &RuntimeEvent,
 ) -> Result<(), StoreError> {
     let Fact::InvocationOpened {
-        input: InvocationInput::Continuation { claim, .. },
+        input,
         configuration,
     } = &event.fact
     else {
         return Ok(());
     };
-    claim.validate(&event.invocation).map_err(invalid)?;
+    let Some(claim) = input.inherited_claim() else {
+        return Ok(());
+    };
+    input
+        .validate_inheritance(&event.invocation)
+        .map_err(invalid)?;
     let workspace = configuration
         .as_ref()
         .and_then(|c| c.workspace_identity.as_ref())
@@ -109,11 +115,13 @@ pub(crate) async fn validate(
     let reused: bool = sqlx::query_scalar(
         "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE kind='invocation_opened'
          AND json_extract(event_json,'$.invocation.session_id')=?1
-         AND (json_extract(event_json,'$.invocation.turn_id')=?2 OR json_extract(event_json,'$.invocation.run_id')=?3))"
+         AND ((?4 AND json_extract(event_json,'$.invocation.turn_id')=?2) OR json_extract(event_json,'$.invocation.run_id')=?3))"
     ).bind(&event.invocation.session_id).bind(&event.invocation.turn_id).bind(&event.invocation.run_id)
-        .fetch_one(&mut *connection).await?;
+        .bind(matches!(input, InvocationInput::Continuation { .. })).fetch_one(&mut *connection).await?;
     if reused {
-        return Err(invalid("manual continuation reused a prior Turn or Run"));
+        return Err(invalid(
+            "continuation reused a reserved Turn or physical Run",
+        ));
     }
     crate::context::safety::require_safe(connection, &event.invocation.session_id, None).await?;
     ancestors(connection, &claim.source, &claim.base, workspace).await?;
@@ -155,8 +163,8 @@ pub(crate) async fn ancestors(
             &source.invocation.run_id,
             &id,
             through,
-            10_000,
-            8 * 1024 * 1024,
+            MAX_SOURCE_EVENTS,
+            MAX_SOURCE_BYTES,
         )
         .await?;
         if prefix.invocation != source.invocation
@@ -200,8 +208,11 @@ pub(crate) async fn ancestors(
                 .await?;
                 return Ok(result);
             }
-            InvocationInput::Continuation { claim: parent, .. } => {
-                parent.validate(&prefix.invocation).map_err(invalid)?;
+            InvocationInput::Continuation { claim: parent, .. }
+            | InvocationInput::Handoff { claim: parent, .. } => {
+                input
+                    .validate_inheritance(&prefix.invocation)
+                    .map_err(invalid)?;
                 if &parent.base != base {
                     return Err(invalid("continuation changed its inherited Session base"));
                 }
diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
index e34207c633..f8ffb5fc7f 100644
--- a/crates/event-log/src/handoff.rs
+++ b/crates/event-log/src/handoff.rs
@@ -17,25 +17,98 @@
  * under the License.
  */
 
-use crate::StoreError;
+use crate::{EventLog, StoreError};
 use maka_runtime::event::{Fact, InvocationInput, InvocationOutcome, RuntimeEvent};
-use sqlx::SqliteConnection;
+use sqlx::{Connection, SqliteConnection};
+
+impl EventLog {
+    /// Eligibility only. The source still owns execution until its seal commits.
+    pub async fn check_handoff(
+        &self,
+        invocation: &maka_runtime::event::Invocation,
+        pause: &maka_runtime::handoff::HandoffPause,
+    ) -> Result<(), StoreError> {
+        self.validate_root()?;
+        let event = RuntimeEvent::new(
+            invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::HandoffPaused {
+                    pause: pause.clone(),
+                },
+            },
+        );
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    validate(&mut tx, &event).await?;
+                    tx.commit().await?;
+                    Ok(())
+                })
+            })
+            .await
+    }
+}
 
 pub(crate) async fn validate(
     tx: &mut SqliteConnection,
     event: &RuntimeEvent,
 ) -> Result<(), StoreError> {
-    if matches!(event.fact, Fact::InvocationOpened { .. }) {
-        let reserved: bool = sqlx::query_scalar(
-            "SELECT EXISTS(SELECT 1 FROM runtime_events pause
-             WHERE pause.kind='invocation_ended'
-             AND json_extract(pause.event_json,'$.invocation.session_id')=?1
-             AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
-             AND NOT EXISTS(SELECT 1 FROM runtime_events successor WHERE successor.kind='invocation_opened'
-               AND json_extract(successor.event_json,'$.fact.input.kind')='handoff'
-               AND json_extract(successor.event_json,'$.fact.input.claim.source.invocation.invocation_id')=pause.invocation_id))",
-        ).bind(&event.invocation.session_id).fetch_one(&mut *tx).await?;
-        if reserved {
+    if let Fact::InvocationOpened {
+        input,
+        configuration,
+    } = &event.fact
+    {
+        let source = match input {
+            InvocationInput::Handoff { claim, .. } => {
+                Some(claim.source.invocation.invocation_id.as_str())
+            }
+            _ => None,
+        };
+        if reservation_conflict(
+            tx,
+            &event.invocation.run_id,
+            &event.invocation.invocation_id,
+            input.inherited_claim().map(|claim| claim.id.as_str()),
+            source,
+        )
+        .await?
+        {
+            return Err(invalid("opening would steal a reserved handoff identity"));
+        }
+        // Admission permits one physical owner per Session. A paused owner
+        // blocks the next opening, so only the latest opening can reserve it.
+        let reserved: Option<String> = sqlx::query_scalar(
+            "SELECT pause.invocation_id FROM runtime_events pause
+             WHERE pause.kind='invocation_ended' AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
+             AND pause.invocation_id=(SELECT invocation_id FROM runtime_events WHERE kind='invocation_opened'
+               AND json_extract(event_json,'$.invocation.session_id')=?1 ORDER BY sequence DESC LIMIT 1)",
+        ).bind(&event.invocation.session_id).fetch_optional(&mut *tx).await?;
+        if let InvocationInput::Handoff { claim, pause } = input {
+            if reserved.as_deref() != Some(&claim.source.invocation.invocation_id) {
+                return Err(invalid(
+                    "handoff does not own the pending Session reservation",
+                ));
+            }
+            let source = &claim.source.invocation;
+            let (opening, terminal): (String, String) = sqlx::query_as(
+                "SELECT o.event_json, t.event_json FROM runtime_events o JOIN runtime_events t
+                 ON t.invocation_id=o.invocation_id AND t.kind='invocation_ended'
+                 WHERE o.invocation_id=? AND o.kind='invocation_opened'",
+            )
+            .bind(&source.invocation_id)
+            .fetch_one(&mut *tx)
+            .await?;
+            let opening: RuntimeEvent = serde_json::from_str(&opening)?;
+            let terminal: RuntimeEvent = serde_json::from_str(&terminal)?;
+            if !matches!(terminal.fact, Fact::InvocationEnded { outcome: InvocationOutcome::HandoffPaused { pause: sealed } } if sealed == **pause)
+                || !matches!(opening.fact, Fact::InvocationOpened { configuration: Some(ref frozen), .. } if Some(frozen) == configuration.as_ref())
+            {
+                return Err(invalid(
+                    "handoff changes its sealed intent, budget or admitted configuration",
+                ));
+            }
+        } else if reserved.is_some() {
             return Err(invalid(
                 "Session is reserved for its sealed handoff successor",
             ));
@@ -49,6 +122,12 @@ pub(crate) async fn validate(
         return Ok(());
     };
     pause.validate(&event.invocation).map_err(invalid)?;
+    let ended: bool = sqlx::query_scalar(
+        "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id=? AND kind='invocation_ended')",
+    ).bind(&event.invocation.invocation_id).fetch_one(&mut *tx).await?;
+    if ended {
+        return Err(invalid("handoff source is already sealed"));
+    }
     let opening: String = sqlx::query_scalar(
         "SELECT event_json FROM runtime_events WHERE invocation_id=? AND kind='invocation_opened'",
     )
@@ -56,14 +135,54 @@ pub(crate) async fn validate(
     .fetch_one(&mut *tx)
     .await?;
     let opening: RuntimeEvent = serde_json::from_str(&opening)?;
-    if !matches!(
-        &opening.fact,
+    if opening.invocation != event.invocation {
+        return Err(invalid("handoff source invocation changed"));
+    }
+    let Fact::InvocationOpened {
+        input,
+        configuration: Some(configuration),
+    } = &opening.fact
+    else {
+        return Err(invalid("handoff has no admitted configuration"));
+    };
+    let workspace = configuration
+        .workspace_identity
+        .as_ref()
+        .ok_or_else(|| invalid("handoff requires an observed workspace identity"))?;
+    crate::context::safety::require_safe(
+        tx,
+        &event.invocation.session_id,
+        Some(&event.invocation.invocation_id),
+    )
+    .await?;
+    if let Some(claim) = input.inherited_claim()
+        && crate::continuation::ancestors(tx, &claim.source, &claim.base, workspace)
+            .await?
+            .len()
+            >= maka_runtime::continuation::MAX_ANCESTRY
+    {
+        return Err(invalid(
+            "handoff would exceed the continuation lineage capacity",
+        ));
+    }
+    let root = match &opening.fact {
         Fact::InvocationOpened {
             input: InvocationInput::Message { .. } | InvocationInput::Continuation { .. },
             configuration: Some(_),
+        } => &event.invocation.run_id,
+        Fact::InvocationOpened {
+            input: InvocationInput::Handoff {
+                pause: previous, ..
+            },
+            configuration: Some(_),
+        } if pause.remaining_steps <= previous.remaining_steps => &previous.intent.root_run_id,
+        _ => {
+            return Err(invalid(
+                "handoff requires an admitted model Run with remaining budget",
+            ));
         }
-    ) || pause.intent.root_run_id != event.invocation.run_id
-    {
+    };
+    if &pause.intent.root_run_id != root {
         return Err(invalid(
             "handoff must preserve its admitted logical model Run",
         ));
@@ -72,20 +191,70 @@ pub(crate) async fn validate(
     // harmless; an interrupted request never proves a provider effect settled.
     crate::context::safety::settled_boundary(tx, &event.invocation.invocation_id, i64::MAX as u64)
         .await?;
+    crate::interactions::lifecycle::require_closed(tx, &event.invocation).await?;
+    // Use a stable upper bound in both preflight and append: time advancing
+    // while held must not consume the headroom a second time.
+    let mut envelope = event.clone();
+    envelope.recorded_at = std::time::UNIX_EPOCH;
+    let seal_bytes =
+        serde_json::to_vec(&envelope)?.len() + (u64::MAX.ilog10() + u32::MAX.ilog10()) as usize;
+    let source_bytes = maka_runtime::continuation::MAX_SOURCE_BYTES
+        .checked_sub(seal_bytes)
+        .ok_or(StoreError::PrefixTooLarge)?;
+    crate::run_prefix::read(
+        tx,
+        &event.invocation.session_id,
+        &event.invocation.run_id,
+        &event.invocation.invocation_id,
+        i64::MAX,
+        maka_runtime::continuation::MAX_SOURCE_EVENTS - 1,
+        source_bytes,
+    )
+    .await?;
     let occupied: bool = sqlx::query_scalar(
         "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE kind='invocation_opened'
-         AND (invocation_id=?1 OR json_extract(event_json,'$.invocation.run_id')=?2))",
+         AND (invocation_id=?1 OR json_extract(event_json,'$.invocation.run_id')=?2
+           OR json_extract(event_json,'$.fact.input.claim.id')=?3))",
     )
     .bind(&pause.intent.successor_invocation_id)
     .bind(&pause.intent.successor_run_id)
+    .bind(&pause.intent.claim_id)
     .fetch_one(&mut *tx)
     .await?;
     if occupied {
         return Err(invalid("handoff successor identity already exists"));
     }
+    if reservation_conflict(
+        tx,
+        &pause.intent.successor_run_id,
+        &pause.intent.successor_invocation_id,
+        Some(&pause.intent.claim_id),
+        None,
+    )
+    .await?
+    {
+        return Err(invalid("handoff successor identity is already reserved"));
+    }
     Ok(())
 }
 
+async fn reservation_conflict(
+    tx: &mut SqliteConnection,
+    run: &str,
+    invocation: &str,
+    claim: Option<&str>,
+    source: Option<&str>,
+) -> Result<bool, StoreError> {
+    Ok(sqlx::query_scalar(
+        "WITH reservations AS NOT MATERIALIZED (SELECT event_json FROM runtime_events
+         WHERE kind='invocation_ended' AND json_extract(event_json,'$.fact.outcome.kind')='handoff_paused'
+         AND (?4 IS NULL OR invocation_id != ?4))
+         SELECT EXISTS(SELECT 1 FROM reservations WHERE json_extract(event_json,'$.fact.outcome.pause.intent.successor_run_id')=?1
+           UNION ALL SELECT 1 FROM reservations WHERE json_extract(event_json,'$.fact.outcome.pause.intent.successor_invocation_id')=?2
+           UNION ALL SELECT 1 FROM reservations WHERE json_extract(event_json,'$.fact.outcome.pause.intent.claim_id')=?3)",
+    ).bind(run).bind(invocation).bind(claim).bind(source).fetch_one(tx).await?)
+}
+
 fn invalid(message: &str) -> StoreError {
     StoreError::InvalidTransition(message.into())
 }
diff --git a/crates/event-log/src/schema.rs b/crates/event-log/src/schema.rs
index 1ba33a8462..491576a0ce 100644
--- a/crates/event-log/src/schema.rs
+++ b/crates/event-log/src/schema.rs
@@ -38,7 +38,7 @@ pub(crate) async fn initialize_connection(
     .fetch_one(&mut *connection)
     .await?;
     if !((application_id == 0 && version == 0 && tables == 0)
-        || (application_id == 0x4d414b52 && matches!(version, 0..=22)))
+        || (application_id == 0x4d414b52 && matches!(version, 0..=23)))
     {
         return Err(StoreError::UnsupportedDatabase);
     }
diff --git a/crates/event-log/tests/continuation.rs b/crates/event-log/tests/continuation.rs
index 07d18e7dc8..bf591f400c 100644
--- a/crates/event-log/tests/continuation.rs
+++ b/crates/event-log/tests/continuation.rs
@@ -33,6 +33,10 @@ use maka_runtime::{
 mod fixtures;
 #[path = "continuation/handoff.rs"]
 mod handoff;
+#[path = "continuation/handoff_budget.rs"]
+mod handoff_budget;
+#[path = "continuation/handoff_claim.rs"]
+mod handoff_claim;
 use fixtures::*;
 
 #[tokio::test]
diff --git a/crates/event-log/tests/continuation/handoff_budget.rs b/crates/event-log/tests/continuation/handoff_budget.rs
new file mode 100644
index 0000000000..6b05595248
--- /dev/null
+++ b/crates/event-log/tests/continuation/handoff_budget.rs
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_runtime::{
+    continuation::{MAX_SOURCE_BYTES, MAX_SOURCE_EVENTS},
+    handoff::{HandoffIntent, HandoffPause},
+    model::{ModelFinishReason, ModelPart, ModelStep, ModelUsage, TextKind},
+};
+use std::{
+    num::NonZeroU16,
+    time::{Duration, UNIX_EPOCH},
+};
+
+#[tokio::test]
+async fn near_capacity_handoff_keeps_its_seal_budget_when_timestamp_width_changes() {
+    let directory = tempfile::tempdir().unwrap();
+    let log = EventLog::open(&directory.path().join("capacity.sqlite"))
+        .await
+        .unwrap();
+    let source = opening("root", None);
+    append(&log, &source).await;
+    let context = log
+        .read_model_context("session", Some("root"), 100, 65536)
+        .await
+        .unwrap();
+    append(
+        &log,
+        &RuntimeEvent::new(
+            source.invocation.clone(),
+            Fact::ModelRequested {
+                step_id: "step".into(),
+                model_id: "test".into(),
+                purpose: Some(maka_runtime::context::ModelPurpose::Main),
+                context: None,
+                source_scope: context.source_evidence.scope,
+                source_high_water: context.source_evidence.high_water,
+                source_digest: context.source_evidence.digest,
+                effective_source_digest: Some(context.effective_source_digest),
+                input_digest: digest('a'),
+                route_identity: digest('b'),
+                checkpoint_event_id: None,
+            },
+        ),
+    )
+    .await;
+    let prefix = log
+        .run_prefix("session", "root", None, 100, MAX_SOURCE_BYTES)
+        .await
+        .unwrap()
+        .unwrap();
+    let existing_bytes: usize = prefix
+        .events
+        .iter()
+        .map(|event| serde_json::to_vec(&event.event).unwrap().len())
+        .sum();
+    let pause = HandoffPause {
+        intent: HandoffIntent {
+            handoff_id: "handoff".into(),
+            host_epoch: "host".into(),
+            root_run_id: "root".into(),
+            successor_run_id: "successor-run".into(),
+            successor_invocation_id: "successor-invocation".into(),
+            claim_id: "claim".into(),
+        },
+        remaining_steps: NonZeroU16::new(2).unwrap(),
+    };
+    let mut seal = RuntimeEvent::new(
+        source.invocation.clone(),
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::HandoffPaused {
+                pause: pause.clone(),
+            },
+        },
+    );
+    let mut widest = serde_json::to_value(&seal).unwrap();
+    widest["recorded_at"] =
+        serde_json::json!({ "secs_since_epoch": u64::MAX, "nanos_since_epoch": u32::MAX });
+    let reserved_bytes = serde_json::to_vec(&widest).unwrap().len();
+    let mut completed = RuntimeEvent::new(
+        source.invocation.clone(),
+        Fact::ModelCompleted {
+            step_id: "step".into(),
+            output: ModelStep {
+                parts: vec![ModelPart::Text {
+                    text_kind: TextKind::Text,
+                    text: String::new(),
+                    provider_options: None,
+                }],
+                finish_reason: ModelFinishReason::Stop,
+                usage: ModelUsage::default(),
+                provider_options: None,
+                response_id: None,
+                model: None,
+                timestamp: None,
+            },
+        },
+    );
+    let remaining = MAX_SOURCE_BYTES
+        - reserved_bytes
+        - existing_bytes
+        - serde_json::to_vec(&completed).unwrap().len();
+    let Fact::ModelCompleted { output, .. } = &mut completed.fact else {
+        unreachable!()
+    };
+    let ModelPart::Text { text, .. } = &mut output.parts[0] else {
+        unreachable!()
+    };
+    *text = "x".repeat(remaining);
+    append(&log, &completed).await;
+    log.check_handoff(&source.invocation, &pause).await.unwrap();
+    seal.recorded_at = UNIX_EPOCH + Duration::new(100_000_000_000, 999_999_999);
+    append(&log, &seal).await;
+    let sealed = log
+        .run_prefix("session", "root", None, MAX_SOURCE_EVENTS, MAX_SOURCE_BYTES)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(sealed.events.last().unwrap().event, seal);
+    assert_eq!(
+        sealed.high_water, 4,
+        "the pause remains claimable within the exact source budget"
+    );
+    log.close().await.unwrap();
+}
diff --git a/crates/event-log/tests/continuation/handoff_claim.rs b/crates/event-log/tests/continuation/handoff_claim.rs
new file mode 100644
index 0000000000..783d93620d
--- /dev/null
+++ b/crates/event-log/tests/continuation/handoff_claim.rs
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_runtime::handoff::{HandoffIntent, HandoffPause};
+use std::num::NonZeroU16;
+
+#[tokio::test]
+async fn handoff_claim_preserves_turn_and_frozen_ancestry_without_releasing_reserved_identity() {
+    let directory = tempfile::tempdir().unwrap();
+    let path = directory.path().join("handoff.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    let root = opening("root", None);
+    append(&log, &root).await;
+    let context = log
+        .context_before_run(&root.invocation, 100, 65536)
+        .await
+        .unwrap();
+    let base = SessionBase {
+        high_water: context.source_evidence.high_water,
+        digest: context.source_evidence.digest,
+    };
+    let mut source = root.clone();
+    for round in 0..2 {
+        let pause = HandoffPause {
+            intent: HandoffIntent {
+                handoff_id: format!("handoff-{round}"),
+                host_epoch: format!("host-{round}"),
+                root_run_id: root.invocation.run_id.clone(),
+                successor_run_id: format!("run-{round}"),
+                successor_invocation_id: format!("invocation-{round}"),
+                claim_id: format!("claim-{round}"),
+            },
+            remaining_steps: NonZeroU16::new(3 - round).unwrap(),
+        };
+        append(
+            &log,
+            &RuntimeEvent::new(
+                source.invocation.clone(),
+                Fact::InvocationEnded {
+                    outcome: InvocationOutcome::HandoffPaused {
+                        pause: pause.clone(),
+                    },
+                },
+            ),
+        )
+        .await;
+        let before = log.prefix(100, 65536).await.unwrap();
+        let inherited = claim(&log, &pause.intent.claim_id, &source, base.clone()).await;
+        let Fact::InvocationOpened { configuration, .. } = &source.fact else {
+            unreachable!()
+        };
+        let target = RuntimeEvent::new(
+            pause.intent.successor(&source.invocation),
+            Fact::InvocationOpened {
+                configuration: configuration.clone(),
+                input: InvocationInput::Handoff {
+                    claim: Box::new(inherited.clone()),
+                    pause: Box::new(pause.clone()),
+                },
+            },
+        );
+        for mutation in 0..5 {
+            let mut changed = target.clone();
+            let Fact::InvocationOpened {
+                input: InvocationInput::Handoff { claim, pause },
+                configuration,
+            } = &mut changed.fact
+            else {
+                unreachable!()
+            };
+            match mutation {
+                0 => pause.remaining_steps = NonZeroU16::new(20).unwrap(),
+                1 => configuration.as_mut().unwrap().permission_mode = PermissionMode::Bypass,
+                2 => claim.source.digest = digest('d'),
+                3 => claim.id = "not-reserved".into(),
+                _ => changed.invocation.turn_id = "new-turn".into(),
+            }
+            match EventWrite::plain(changed) {
+                Ok(write) => assert!(log.append(&write).await.is_err()),
+                Err(maka_runtime::event::CommitError::Rejected(_)) => {}
+                Err(error) => panic!("unexpected failure: {error}"),
+            }
+        }
+        for steal_invocation in [false, true] {
+            let mut other = opening("unrelated", None);
+            other.invocation.session_id = "other-session".into();
+            if steal_invocation {
+                other.invocation.invocation_id = pause.intent.successor_invocation_id.clone();
+            } else {
+                other.invocation.run_id = pause.intent.successor_run_id.clone();
+            }
+            assert!(
+                log.append(&EventWrite::plain(other).unwrap())
+                    .await
+                    .is_err()
+            );
+        }
+        assert_eq!(log.prefix(100, 65536).await.unwrap().digest, before.digest);
+        if round == 0 {
+            let mut competitor = opening("competitor", None);
+            competitor.invocation.session_id = "other-session".into();
+            append(&log, &competitor).await;
+            for field in 0..3 {
+                let mut conflicting = pause.clone();
+                conflicting.intent.root_run_id = competitor.invocation.run_id.clone();
+                if field != 0 {
+                    conflicting.intent.successor_run_id = "other-run".into();
+                }
+                if field != 1 {
+                    conflicting.intent.successor_invocation_id = "other-invocation".into();
+                }
+                if field != 2 {
+                    conflicting.intent.claim_id = "other-claim".into();
+                }
+                assert!(
+                    log.check_handoff(&competitor.invocation, &conflicting)
+                        .await
+                        .is_err(),
+                    "known reservations must be rejected before an irreversible decision"
+                );
+            }
+            close(&log, &competitor).await;
+        }
+        assert!(
+            log.continuation_for_source(&inherited.source)
+                .await
+                .unwrap()
+                .is_none()
+        );
+        let sequence = append(&log, &target).await;
+        assert_eq!(
+            append(&log, &target).await,
+            sequence,
+            "canonical replay does not acquire twice"
+        );
+        assert_eq!(
+            log.continuation_for_source(&inherited.source)
+                .await
+                .unwrap()
+                .unwrap()
+                .event
+                .id,
+            target.id
+        );
+        assert_eq!(target.invocation.turn_id, root.invocation.turn_id);
+        let mut view = InvocationView::new(1024).unwrap();
+        assert!(
+            view.push(&StoredEvent {
+                sequence,
+                event: target.clone()
+            })
+            .unwrap()
+            .is_empty(),
+            "a successor does not invent another user message"
+        );
+        source = target;
+    }
+    close(&log, &source).await;
+    let last = claim(&log, "manual", &source, base).await;
+    let manual = opening("manual-turn", Some(last));
+    append(&log, &manual).await;
+    close(&log, &manual).await;
+    let prefix = log
+        .run_prefix("session", &manual.invocation.run_id, None, 100, 65536)
+        .await
+        .unwrap()
+        .unwrap();
+    let boundary = RunBoundary {
+        invocation: prefix.invocation,
+        high_water: prefix.high_water,
+        digest: prefix.digest,
+    };
+    let before = log
+        .read_lineage_context(&boundary, 100, 65536)
+        .await
+        .unwrap();
+    assert_eq!(
+        before
+            .tail
+            .iter()
+            .filter(|event| matches!(event,
+        maka_event_log::context::ContextEvent::Canonical(event) if matches!(event.event.fact,
+            Fact::InvocationOpened { input: InvocationInput::Message { .. }, .. })))
+            .count(),
+        1
+    );
+    log.close().await.unwrap();
+    let reopened = EventLog::open(&path).await.unwrap();
+    let after = reopened
+        .read_lineage_context(&boundary, 100, 65536)
+        .await
+        .unwrap();
+    assert_eq!(before.source_evidence.scope, after.source_evidence.scope);
+    assert_eq!(
+        before.source_evidence.high_water,
+        after.source_evidence.high_water
+    );
+    assert_eq!(before.source_evidence.digest, after.source_evidence.digest);
+    assert_eq!(
+        before.effective_source_digest,
+        after.effective_source_digest
+    );
+    reopened.close().await.unwrap();
+}
+
+#[tokio::test]
+async fn ineligible_handoff_never_seals_or_reserves_a_successor() {
+    let directory = tempfile::tempdir().unwrap();
+    let log = EventLog::open(&directory.path().join("eligibility.sqlite"))
+        .await
+        .unwrap();
+    let mut missing = opening("missing", None);
+    if let Fact::InvocationOpened {
+        configuration: Some(configuration),
+        ..
+    } = &mut missing.fact
+    {
+        configuration.workspace_identity = None;
+    }
+    append(&log, &missing).await;
+    let mut pause = HandoffPause {
+        intent: HandoffIntent {
+            handoff_id: "handoff".into(),
+            host_epoch: "host".into(),
+            root_run_id: "missing".into(),
+            successor_run_id: "successor".into(),
+            successor_invocation_id: "successor-invocation".into(),
+            claim_id: "claim".into(),
+        },
+        remaining_steps: NonZeroU16::new(3).unwrap(),
+    };
+    assert!(
+        log.check_handoff(&missing.invocation, &pause)
+            .await
+            .is_err()
+    );
+    assert!(
+        log.append(
+            &EventWrite::plain(RuntimeEvent::new(
+                missing.invocation.clone(),
+                Fact::InvocationEnded {
+                    outcome: InvocationOutcome::HandoffPaused {
+                        pause: pause.clone()
+                    },
+                }
+            ))
+            .unwrap()
+        )
+        .await
+        .is_err()
+    );
+    close(&log, &missing).await;
+
+    let root = opening("root", None);
+    append(&log, &root).await;
+    let context = log
+        .context_before_run(&root.invocation, 100, 65536)
+        .await
+        .unwrap();
+    let base = SessionBase {
+        high_water: context.source_evidence.high_water,
+        digest: context.source_evidence.digest,
+    };
+    pause.intent.root_run_id = root.invocation.run_id.clone();
+    let mut source = root;
+    for round in 0..=maka_runtime::continuation::MAX_ANCESTRY {
+        pause.intent.handoff_id = format!("handoff-{round}");
+        pause.intent.successor_run_id = format!("run-{round}");
+        pause.intent.successor_invocation_id = format!("invocation-{round}");
+        pause.intent.claim_id = format!("claim-{round}");
+        let seal = EventWrite::plain(RuntimeEvent::new(
+            source.invocation.clone(),
+            Fact::InvocationEnded {
+                outcome: InvocationOutcome::HandoffPaused {
+                    pause: pause.clone(),
+                },
+            },
+        ))
+        .unwrap();
+        if round == maka_runtime::continuation::MAX_ANCESTRY {
+            assert!(log.check_handoff(&source.invocation, &pause).await.is_err());
+            assert!(
+                log.append(&seal).await.is_err(),
+                "do not strand an execution beyond the claim capacity"
+            );
+            break;
+        }
+        log.check_handoff(&source.invocation, &pause).await.unwrap();
+        log.append(&seal).await.unwrap();
+        let inherited = claim(&log, &pause.intent.claim_id, &source, base.clone()).await;
+        let Fact::InvocationOpened { configuration, .. } = &source.fact else {
+            unreachable!()
+        };
+        source = RuntimeEvent::new(
+            pause.intent.successor(&source.invocation),
+            Fact::InvocationOpened {
+                configuration: configuration.clone(),
+                input: InvocationInput::Handoff {
+                    claim: Box::new(inherited),
+                    pause: Box::new(pause.clone()),
+                },
+            },
+        );
+        append(&log, &source).await;
+    }
+    close(&log, &source).await;
+    append(&log, &opening("next-turn", None)).await;
+    log.close().await.unwrap();
+}
diff --git a/crates/event-log/tests/migrations.rs b/crates/event-log/tests/migrations.rs
index 360d3d310a..aa7b17d92c 100644
--- a/crates/event-log/tests/migrations.rs
+++ b/crates/event-log/tests/migrations.rs
@@ -113,7 +113,7 @@ async fn embedded_migration_adopts_only_rust_schema_and_reopens_without_rewritin
             .map(|(version, _)| *version)
             .collect::<Vec<_>>(),
         vec![
-            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22
+            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
         ]
     );
     assert!(checksums.iter().all(|(_, checksum)| checksum.len() == 48));
@@ -202,7 +202,7 @@ async fn embedded_migration_adopts_only_rust_schema_and_reopens_without_rewritin
             .query_row("SELECT count(*) FROM _sqlx_migrations", [], |row| row
                 .get::<_, i64>(0))
             .unwrap(),
-        22
+        23
     );
 }
 
@@ -271,7 +271,7 @@ async fn mismatched_and_unknown_migrations_fail_closed_without_touching_committe
                 .query_row("SELECT count(*) FROM _sqlx_migrations", [], |row| row
                     .get::<_, i64>(0))
                 .unwrap(),
-            22
+            23
         );
     }
 
@@ -333,7 +333,7 @@ async fn interrupted_initial_migration_remains_openable_under_the_rust_applicati
         connection
             .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
             .unwrap(),
-        22
+        23
     );
     assert_eq!(
         connection
@@ -343,6 +343,6 @@ async fn interrupted_initial_migration_remains_openable_under_the_rust_applicati
                 |row| row.get::<_, i64>(0)
             )
             .unwrap(),
-        22
+        23
     );
 }
diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs
index 3229fa8bda..a2f86b51d4 100644
--- a/crates/presentation/src/lib.rs
+++ b/crates/presentation/src/lib.rs
@@ -167,7 +167,7 @@ impl InvocationView {
                         content,
                         self.max_text_bytes,
                     )?);
-                } else if !matches!(input, InvocationInput::Continuation { .. }) {
+                } else if input.inherited_claim().is_none() {
                     return Err(ProjectionError::Unsupported("Code invocation"));
                 }
                 self.state = State::Active {
diff --git a/crates/runtime-host/src/execution/snapshot.rs b/crates/runtime-host/src/execution/snapshot.rs
index 4227fc0450..517db00fc3 100644
--- a/crates/runtime-host/src/execution/snapshot.rs
+++ b/crates/runtime-host/src/execution/snapshot.rs
@@ -89,7 +89,7 @@ pub(crate) fn project(boundary: TurnBoundary) -> RecordedTurn {
                 request_fingerprint,
                 ..
             } => Some(request_fingerprint),
-            InvocationInput::Code { .. } => None,
+            InvocationInput::Code { .. } | InvocationInput::Handoff { .. } => None,
         },
     }
 }
diff --git a/crates/runtime/src/context.rs b/crates/runtime/src/context.rs
index 79bf000441..eb163f9268 100644
--- a/crates/runtime/src/context.rs
+++ b/crates/runtime/src/context.rs
@@ -70,9 +70,12 @@ pub fn resolve_model_purpose(
 ) -> Result<ModelPurpose, &'static str> {
     use crate::input::InvocationInput;
     match (opening, purpose) {
-        (InvocationInput::Message { .. } | InvocationInput::Continuation { .. }, purpose) => {
-            Ok(purpose.unwrap_or(ModelPurpose::Main))
-        }
+        (
+            InvocationInput::Message { .. }
+            | InvocationInput::Continuation { .. }
+            | InvocationInput::Handoff { .. },
+            purpose,
+        ) => Ok(purpose.unwrap_or(ModelPurpose::Main)),
         (InvocationInput::ContextCompact { .. }, None | Some(ModelPurpose::Summary)) => {
             Ok(ModelPurpose::Summary)
         }
diff --git a/crates/runtime/src/continuation.rs b/crates/runtime/src/continuation.rs
index 626dd0b0f0..4458d92912 100644
--- a/crates/runtime/src/continuation.rs
+++ b/crates/runtime/src/continuation.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-//! Durable manual continuation identity. The opening itself acquires the claim;
+//! Durable continuation identity. The opening itself acquires the claim;
 //! a query or an in-memory plan has no execution authority.
 
 use crate::{archive::valid_projection_digest, event::Invocation, interaction::entity_id};
@@ -60,9 +60,19 @@ pub struct ReplayEvidence {
 
 pub const REPLAY_VERSION: u32 = 1;
 pub const MAX_ANCESTRY: usize = 64;
+pub const MAX_SOURCE_EVENTS: usize = 10_000;
+pub const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024;
 
 impl ContinuationClaim {
     pub fn validate(&self, target: &Invocation) -> Result<(), &'static str> {
+        self.validate_boundary(target)?;
+        if self.source.invocation.turn_id == target.turn_id {
+            return Err("manual continuation requires a fresh Turn");
+        }
+        Ok(())
+    }
+
+    pub(crate) fn validate_boundary(&self, target: &Invocation) -> Result<(), &'static str> {
         entity_id(&self.id)?;
         for invocation in [&self.source.invocation, target] {
             for id in [
@@ -76,13 +86,10 @@ impl ContinuationClaim {
         }
         let source = &self.source.invocation;
         if source.session_id != target.session_id
-            || source.turn_id == target.turn_id
             || source.run_id == target.run_id
             || source.invocation_id == target.invocation_id
         {
-            return Err(
-                "manual continuation requires fresh Turn, Run and Invocation in the same Session",
-            );
+            return Err("continuation requires fresh Run and Invocation in the same Session");
         }
         if self.source.high_water == 0
             || self.source.high_water > crate::configuration::validation::MAX_SAFE_INTEGER
diff --git a/crates/runtime/src/event_write.rs b/crates/runtime/src/event_write.rs
index 351f369550..890840fda2 100644
--- a/crates/runtime/src/event_write.rs
+++ b/crates/runtime/src/event_write.rs
@@ -53,24 +53,24 @@ impl ProjectionArtifactWrite {
 
 impl EventWrite {
     pub fn plain(event: RuntimeEvent) -> Result<Self, CommitError> {
+        if let Fact::InvocationOpened { input, .. } = &event.fact {
+            input
+                .validate_inheritance(&event.invocation)
+                .map_err(|reason| CommitError::Rejected(reason.into()))?;
+        }
         if let Fact::InvocationOpened {
             input:
                 crate::input::InvocationInput::Continuation {
-                    claim,
                     request_fingerprint,
                     ..
                 },
             ..
         } = &event.fact
+            && !crate::archive::valid_projection_digest(request_fingerprint)
         {
-            claim
-                .validate(&event.invocation)
-                .map_err(|reason| CommitError::Rejected(reason.into()))?;
-            if !crate::archive::valid_projection_digest(request_fingerprint) {
-                return Err(CommitError::Rejected(
-                    "invalid continuation request fingerprint".into(),
-                ));
-            }
+            return Err(CommitError::Rejected(
+                "invalid continuation request fingerprint".into(),
+            ));
         }
         if let Fact::InvocationOpened {
             input:
diff --git a/crates/runtime/src/handoff.rs b/crates/runtime/src/handoff.rs
index b5e949fedd..44e0033ad6 100644
--- a/crates/runtime/src/handoff.rs
+++ b/crates/runtime/src/handoff.rs
@@ -76,6 +76,21 @@ impl HandoffIntent {
 }
 
 impl HandoffPause {
+    pub fn validate_claim(
+        &self,
+        claim: &crate::continuation::ContinuationClaim,
+        target: &Invocation,
+    ) -> Result<(), &'static str> {
+        claim.validate_boundary(target)?;
+        self.validate(&claim.source.invocation)?;
+        if claim.id != self.intent.claim_id
+            || *target != self.intent.successor(&claim.source.invocation)
+        {
+            return Err("handoff must acquire its reserved successor in the same Turn");
+        }
+        Ok(())
+    }
+
     pub fn validate(&self, source: &Invocation) -> Result<(), &'static str> {
         self.intent.validate(source)?;
         if self.remaining_steps.get() > 256 {
diff --git a/crates/runtime/src/input.rs b/crates/runtime/src/input.rs
index c1f5320bda..03681e0a33 100644
--- a/crates/runtime/src/input.rs
+++ b/crates/runtime/src/input.rs
@@ -25,6 +25,10 @@ pub use references::{DirectoryReference, InlineReference, InlineReferenceKind, Q
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(tag = "kind", rename_all = "snake_case")]
 pub enum InvocationInput {
+    Handoff {
+        claim: Box<crate::continuation::ContinuationClaim>,
+        pause: Box<crate::handoff::HandoffPause>,
+    },
     Continuation {
         claim: Box<crate::continuation::ContinuationClaim>,
         request_fingerprint: String,
@@ -48,6 +52,26 @@ pub enum InvocationInput {
     },
 }
 
+impl InvocationInput {
+    pub fn inherited_claim(&self) -> Option<&crate::continuation::ContinuationClaim> {
+        match self {
+            Self::Continuation { claim, .. } | Self::Handoff { claim, .. } => Some(claim),
+            _ => None,
+        }
+    }
+
+    pub fn validate_inheritance(
+        &self,
+        target: &crate::event::Invocation,
+    ) -> Result<(), &'static str> {
+        match self {
+            Self::Continuation { claim, .. } => claim.validate(target),
+            Self::Handoff { claim, pause } => pause.validate_claim(claim, target),
+            _ => Ok(()),
+        }
+    }
+}
+
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct MessageInput {
     pub text: String,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 09/29: fix(oauth): separate enrollment generations from token refresh

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 5dda97b5213f923a81a59dc29dc55ac1db27928c
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 15:23:53 2026 +0800

    fix(oauth): separate enrollment generations from token refresh
    
    Generated-by: OpenAI Codex
---
 crates/config/src/oauth/enrollment/commit.rs       |  2 +-
 crates/config/src/vault.rs                         | 14 ++++++++--
 crates/config/src/vault/write.rs                   | 15 ++++++++++
 crates/config/tests/oauth.rs                       | 32 +++++++++++++++++++++-
 crates/config/tests/oauth_login/discovery.rs       | 14 ++++++----
 crates/config/tests/oauth_login/recovery.rs        | 31 +++++++++++++++++++++
 crates/runtime-host/src/execution/provider/auth.rs | 13 +++++++--
 7 files changed, 109 insertions(+), 12 deletions(-)

diff --git a/crates/config/src/oauth/enrollment/commit.rs b/crates/config/src/oauth/enrollment/commit.rs
index 86818ffd52..18c35adf3f 100644
--- a/crates/config/src/oauth/enrollment/commit.rs
+++ b/crates/config/src/oauth/enrollment/commit.rs
@@ -75,7 +75,7 @@ impl PreparedLogin {
                         catalog::advance(tx, catalog.revision, catalog.default_target.as_ref())
                             .await?;
                     }
-                    vault::write_secret(tx, &locator, &secret, now).await?;
+                    vault::replace_secret(tx, &locator, &secret, now).await?;
                     vault::advance(tx).await?;
                     let saved = LoginReceipt {
                         target: input.target,
diff --git a/crates/config/src/vault.rs b/crates/config/src/vault.rs
index f66a3665f0..c0694ac910 100644
--- a/crates/config/src/vault.rs
+++ b/crates/config/src/vault.rs
@@ -21,7 +21,7 @@ use maka_runtime::configuration::*;
 use sqlx::SqliteConnection;
 mod headers;
 mod write;
-pub(crate) use write::write_secret;
+pub(crate) use write::{replace_secret, write_secret};
 
 use crate::{ConfigError, ConfigurationStore, Result, TransactionMode, catalog};
 
@@ -83,7 +83,17 @@ impl ConfigurationStore {
                 if expected != actual {
                     return Ok(CredentialMutationResult::CredentialStale { expected, actual });
                 }
-                write_secret(tx, &input.locator, &input.secret, now).await?;
+                if matches!(
+                    input.locator,
+                    CredentialLocator::Connection {
+                        kind: ConnectionCredentialKind::OauthToken,
+                        ..
+                    }
+                ) {
+                    replace_secret(tx, &input.locator, &input.secret, now).await?;
+                } else {
+                    write_secret(tx, &input.locator, &input.secret, now).await?;
+                }
                 invalidate_test(tx, &input.locator).await?;
                 Ok(CredentialMutationResult::Committed {
                     vault_revision: advance(tx).await?,
diff --git a/crates/config/src/vault/write.rs b/crates/config/src/vault/write.rs
index 12be945a34..e5305b2bae 100644
--- a/crates/config/src/vault/write.rs
+++ b/crates/config/src/vault/write.rs
@@ -23,6 +23,21 @@ use maka_runtime::configuration::{CredentialLocator, CredentialState};
 use sqlx::SqliteConnection;
 use uuid::Uuid;
 
+/// New enrollment is a new authority even when the token bytes are identical.
+/// The caller's transaction rolls back both deletion and insertion on failure.
+pub(crate) async fn replace_secret(
+    tx: &mut SqliteConnection,
+    locator: &CredentialLocator,
+    secret: &str,
+    now: u64,
+) -> Result<()> {
+    sqlx::query("DELETE FROM credentials WHERE locator = ?")
+        .bind(serde_json::to_string(locator)?)
+        .execute(&mut *tx)
+        .await?;
+    write_secret(tx, locator, secret, now).await
+}
+
 pub(crate) async fn write_secret(
     tx: &mut SqliteConnection,
     locator: &CredentialLocator,
diff --git a/crates/config/tests/oauth.rs b/crates/config/tests/oauth.rs
index 6c4a2a469b..f89a6ba3eb 100644
--- a/crates/config/tests/oauth.rs
+++ b/crates/config/tests/oauth.rs
@@ -115,7 +115,37 @@ async fn refresh_cas_preserves_generation_across_races_logout_aba_and_reopen() {
             .unwrap()
             .is_none()
     );
-    let expected = relogged.basis().clone();
+    let replacement = fixture
+        .store
+        .set_credential(
+            SetCredentialInput {
+                locator: relogged.basis().locator.clone(),
+                expected: Some(CredentialIdentityBasis {
+                    credential_id: relogged.basis().credential_id.clone(),
+                    revision: relogged.basis().revision,
+                }),
+                expected_connection: Some(fixture.target.clone()),
+                secret: "old-grant".into(),
+            },
+            14,
+        )
+        .await
+        .unwrap();
+    assert!(matches!(
+        replacement,
+        CredentialMutationResult::Committed { .. }
+    ));
+    assert!(
+        relogged.current_generation().await.unwrap().is_none(),
+        "client Copilot enrollment also replaces, rather than refreshes, authority"
+    );
+    let replacement = fixture.snapshot().await;
+    assert_ne!(
+        replacement.basis().credential_id,
+        relogged.basis().credential_id
+    );
+    let expected = replacement.basis().clone();
+    drop(replacement);
     drop((original, current, relogged));
     let Fixture {
         temp,
diff --git a/crates/config/tests/oauth_login/discovery.rs b/crates/config/tests/oauth_login/discovery.rs
index 65285a604d..f7a3092582 100644
--- a/crates/config/tests/oauth_login/discovery.rs
+++ b/crates/config/tests/oauth_login/discovery.rs
@@ -160,12 +160,7 @@ async fn discovery_tracks_authenticated_generation_without_losing_edits_or_accep
             "cannot move an observation backwards"
         );
         let old = observation.oauth_credential().unwrap();
-        store
-            .delete_credential(DeleteCredentialInput {
-                expected: old.basis().clone(),
-            })
-            .await
-            .unwrap();
+        // Direct re-login must rotate identity without requiring an earlier logout.
         let ticket = prepare(
             &store,
             existing(&format!("relogin-{}", provider.as_str()), &id),
@@ -177,6 +172,13 @@ async fn discovery_tracks_authenticated_generation_without_losing_edits_or_accep
         ));
         let new = fetch(&store, &id).await.oauth_credential().unwrap();
         assert_ne!(old.basis().credential_id, new.basis().credential_id);
+        assert!(old.current_generation().await.unwrap().is_none());
+        assert!(
+            old.commit_refresh("stale-login-refresh".into(), 7)
+                .await
+                .unwrap()
+                .is_none()
+        );
         assert!(verification.accept_oauth(new.clone()).is_err());
         assert!(matches!(verification.complete(verified()).await.unwrap(),
             ConnectionTestRunResult::Superseded { changed }
diff --git a/crates/config/tests/oauth_login/recovery.rs b/crates/config/tests/oauth_login/recovery.rs
index 6427e928d7..30336b3042 100644
--- a/crates/config/tests/oauth_login/recovery.rs
+++ b/crates/config/tests/oauth_login/recovery.rs
@@ -113,6 +113,37 @@ async fn login_rollback_unknown_commit_and_stale_tickets_never_publish_partial_s
     else {
         panic!("configured")
     };
+    sqlx::query("CREATE TRIGGER fail_relogin BEFORE INSERT ON oauth_login_receipts BEGIN SELECT RAISE(ABORT, 'injected replacement failure'); END")
+        .execute(&mut sql).await.unwrap();
+    assert!(
+        prepare(&store, existing("failed-relogin", id))
+            .await
+            .complete("different-account".into(), 4)
+            .await
+            .is_err()
+    );
+    let restored: (String, i64, String) =
+        sqlx::query_as("SELECT credential_id, revision, secret FROM credentials WHERE locator = ?")
+            .bind(serde_json::to_string(&locator).unwrap())
+            .fetch_one(&mut sql)
+            .await
+            .unwrap();
+    assert_eq!(
+        restored,
+        (credential_id.clone(), revision as i64, "grant".into()),
+        "failed enrollment must restore the old generation and grant"
+    );
+    assert!(
+        store
+            .oauth_login_receipt("failed-relogin".into())
+            .await
+            .unwrap()
+            .is_none()
+    );
+    sqlx::query("DROP TRIGGER fail_relogin")
+        .execute(&mut sql)
+        .await
+        .unwrap();
     assert!(matches!(
         store
             .delete_credential(DeleteCredentialInput {
diff --git a/crates/runtime-host/src/execution/provider/auth.rs b/crates/runtime-host/src/execution/provider/auth.rs
index bf498c959e..a25b72e77b 100644
--- a/crates/runtime-host/src/execution/provider/auth.rs
+++ b/crates/runtime-host/src/execution/provider/auth.rs
@@ -32,9 +32,18 @@ pub(super) struct Binding {
 
 impl Binding {
     pub fn auth(self: &Arc<Self>) -> Result<ProviderAuth, OperationError> {
+        let basis = self.snapshot.basis();
         Ok(ProviderAuth::Bound {
-            identity: serde_json::to_string(self.snapshot.basis())
-                .map_err(|_| unavailable("Invalid OAuth credential identity"))?,
+            // Refresh advances the secret revision, not the login generation.
+            // Enrollment always mints a new ID; the resolver still validates its
+            // real current basis and cannot revive a revoked generation.
+            identity: serde_json::to_string(&(
+                "maka.oauth.execution.v1",
+                self.provider,
+                &basis.locator,
+                &basis.credential_id,
+            ))
+            .map_err(|_| unavailable("Invalid OAuth credential identity"))?,
             resolver: self.clone(),
         })
     }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org