diff --git a/.runs/slice-29-shared-dispatch-contract/branch-map.json b/.runs/slice-29-shared-dispatch-contract/branch-map.json new file mode 100644 index 000000000..52cd76e6b --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/branch-map.json @@ -0,0 +1,35 @@ +{ + "authoritative_branch": "feat/gateway-mediated-llm-fulfillment", + "authoritative_head_at_P0": "4c61ab779752a9185c6b7558275d7fdb5880893c", + "accepted_tip_after_G1": "50a450a05f374e46c736f79f9b5c7fec5c0e54d9", + "accepted_tip_after_G2": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "accepted_tip_after_G3": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "lane_branches": { + "L0": "codex/feat-gateway-mediated-llm-fulfillment-s29-a1-a2-contract-foundation", + "L1": "codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze", + "L2": "codex/feat-gateway-mediated-llm-fulfillment-s29-a4-human-caller-adoption", + "L3": "codex/feat-gateway-mediated-llm-fulfillment-s29-a5-repl-dispatch-adoption", + "L4": "codex/feat-gateway-mediated-llm-fulfillment-s29-a6-docs-truth-sync" + }, + "lane_worktrees": { + "root": "/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract", + "L0": "/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a1-a2-contract-foundation", + "L1": "/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze", + "L2": "/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a4-human-caller-adoption", + "L3": "/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a5-repl-dispatch-adoption", + "L4": "/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a6-docs-truth-sync" + }, + "lane_base_shas": { + "L0": "4c61ab779752a9185c6b7558275d7fdb5880893c", + "L1": "50a450a05f374e46c736f79f9b5c7fec5c0e54d9", + "L2": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "L3": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "L4": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72" + }, + "replay_target_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "notes": [ + "ORCH_PLAN.md declares equivalent worktree names under a different absolute workstation root; this run maps them to the live workspace root in this environment.", + "accepted_tip_after_G2 is the authoritative post-L1 integration commit from which both L2 and L3 were created.", + "accepted_tip_after_G3 is the authoritative slice-29 implementation/docs/test tree that passed the locked validation wall; the follow-up closeout commit only updates parent-owned .runs artifacts." + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/contract-freeze.json b/.runs/slice-29-shared-dispatch-contract/contract-freeze.json new file mode 100644 index 000000000..faf11e365 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/contract-freeze.json @@ -0,0 +1,55 @@ +{ + "module_owner": "crates/shell/src/execution/agent_runtime/dispatch_contract.rs", + "frozen_vocabulary": { + "dispatch_request_envelope": "DispatchRequestEnvelope", + "dispatch_capability_override_set": "DispatchCapabilityOverrideSet", + "attach_launch_knobs": "AttachLaunchKnobs", + "resolved_launch_contract": "ResolvedLaunchContract", + "field_provenance": "FieldProvenance", + "dispatch_resolution_error": "DispatchResolutionError" + }, + "baseline_kinds": [ + "inventory_backed", + "persisted_attach_backed" + ], + "caller_kinds": [ + "human_start", + "human_reattach", + "human_fork", + "human_detached_turn_attach", + "orchestrator_member_dispatch" + ], + "capability_families": [ + "backend_identity", + "scope", + "cli_mode", + "capability_profile", + "policy_overlay" + ], + "attach_knob_vocabulary": [ + "attach_mode", + "continuity_selector", + "resume_contract_required" + ], + "merge_precedence": [ + "baseline_truth", + "explicit_supported_dispatch_overrides", + "effective_policy_narrowing", + "runtime_realizability_validation" + ], + "fail_closed_categories": [ + "unknown_override_family", + "unsupported_override", + "baseline_broadening", + "invalid_policy_overlay", + "policy_denial", + "runtime_unrealizable", + "missing_required_continuity" + ], + "frozen_invariants": [ + "One shared internal dispatch contract owner exists under crates/shell/src/execution/agent_runtime/.", + "Inventory-backed and persisted-attach-backed baseline domains remain explicit.", + "Policy remains narrowing-only and fail-closed.", + "Human callers and orchestrator-controlled dispatch must resolve through the same contract semantics." + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/durable-state-freeze.json b/.runs/slice-29-shared-dispatch-contract/durable-state-freeze.json new file mode 100644 index 000000000..37e414896 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/durable-state-freeze.json @@ -0,0 +1,27 @@ +{ + "durable_owner": "HostAttachContract", + "owner_module": "crates/shell/src/execution/agent_runtime/orchestration_session.rs", + "required_persisted_fields": [ + "backend identity derived from resolved contract", + "scope truth derived from resolved contract", + "cli mode truth derived from resolved contract", + "capability truth derived from resolved contract", + "attach continuity selector state", + "resume-contract continuity requirements" + ], + "continuity_successor_copy_rule": [ + "Preserve resolved launch truth needed for successor attach.", + "Clear only continuity-specific state on successor copy.", + "Do not persist a second durable attach object.", + "Do not persist full provenance trees." + ], + "compatibility_constraints": [ + "Persisted JSON changes must remain additive or migration-safe through OrchestrationSessionRecord::validate_persisted_invariants(...).", + "Detached attach planning must consume persisted attach truth instead of ambient participant state." + ], + "prohibitions": [ + "No second durable attach object.", + "No caller-specific reconstruction of host launch truth.", + "No non-additive persisted-state break." + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/final-summary.md b/.runs/slice-29-shared-dispatch-contract/final-summary.md new file mode 100644 index 000000000..a3d2dfd25 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/final-summary.md @@ -0,0 +1,15 @@ +# Final Summary + +Accepted implementation/docs/test tip: `0d15fb2fe8902a9201c891eccd3d7a20325f9d72` + +- Landed one shared internal dispatch contract in `dispatch_contract.rs`, with explicit inventory-backed and persisted-attach-backed baseline domains. +- Kept `HostAttachContract` as the only durable host-attach truth and generalized it to persist resolved launch semantics without inventing a second durable attach object. +- Brought the human caller plane and orchestrator-controlled dispatch back onto the same contract semantics, including same-session parked-turn reattach/stop continuity. +- Aligned the allowed docs and llm-last-mile truth surfaces to the merged runtime behavior. + +Validation: + +- The locked shell selectors passed, including `resolve_public_control_target`, `public_turn_prompt_requests_require_exact_session_and_backend_contract`, `new_session_starts_active_attached`, and `detached_postures_enforce_pending_inbox_truth`. +- `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` and `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` passed on the accepted tree. +- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace -- --nocapture` passed. +- `npx gitnexus detect-changes --repo substrate --scope staged` reported `7 files`, `37 symbols`, `0 affected processes`, `risk level: low`. diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/PASSED b/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/PASSED new file mode 100644 index 000000000..b0aad4deb --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/PASSED @@ -0,0 +1 @@ +passed diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/evidence.md b/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/evidence.md new file mode 100644 index 000000000..0a2090a76 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/evidence.md @@ -0,0 +1,16 @@ +# G0 Evidence + +`G0` passes because all frozen preconditions in `ORCH_PLAN.md` are satisfied: + +1. The authoritative branch is `feat/gateway-mediated-llm-fulfillment`. +2. `PLAN.md` working-tree bytes were hashed and source-locked in `source-lock.json`. +3. `contract-freeze.json` and `durable-state-freeze.json` were written by the parent. +4. `branch-map.json`, `lane-ownership.json`, and `merge-order.json` were written by the parent. +5. The slice-29 worktree root exists, but no slice-29 worker worktree exists yet. + +Supporting command evidence: + +- `git rev-parse HEAD` => `4c61ab779752a9185c6b7558275d7fdb5880893c` +- `sha256sum PLAN.md` => `9389b48a1d20f93c8411a7670f11e25ed0929b0cddce7dc4deeb5c5adb5182cc` +- `git status --short --branch -- PLAN.md ORCH_PLAN.md` => `## feat/gateway-mediated-llm-fulfillment...origin/feat/gateway-mediated-llm-fulfillment` +- `git worktree list --porcelain` => only the authoritative checkout plus `/tmp/substrate-origin-main-check`; no slice-29 worker worktree exists at `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/` diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/gate.json b/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/gate.json new file mode 100644 index 000000000..89bb852c9 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/gate.json @@ -0,0 +1,6 @@ +{ + "gate_id": "G0-run-freeze", + "status": "passed", + "opened_at": "2026-05-24T18:13:37Z", + "passed_at": "2026-05-24T18:13:37Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/PASSED b/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/PASSED new file mode 100644 index 000000000..b0aad4deb --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/PASSED @@ -0,0 +1 @@ +passed diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/evidence.md b/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/evidence.md new file mode 100644 index 000000000..33828ddb2 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/evidence.md @@ -0,0 +1,34 @@ +# G1 Evidence + +`G1` passes because the integrated authoritative tree satisfies the contract-foundation acceptance criteria in `ORCH_PLAN.md`. + +1. `dispatch_contract.rs` exists and is exported from `agent_runtime/mod.rs`. + - [`crates/shell/src/execution/agent_runtime/dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:1) + - [`crates/shell/src/execution/agent_runtime/mod.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/mod.rs:2) +2. Inventory-backed and persisted-attach-backed baseline domains are explicit, with per-field provenance tracked inside the shared contract owner. + - Inventory projection/origin helpers: [`agent_inventory.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:105), [`agent_inventory.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:196) + - Inventory and persisted-attach resolvers: [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:210), [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:313), [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:389), [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:470) +3. Policy remains narrowing-only and fail-closed, and the contract produces runtime materialization inputs instead of a second merge owner. + - Override/policy rejection layers and provenance: [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:156), [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:442) + - Runtime materialization from `ResolvedLaunchContract`: [`validator.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/validator.rs:124), [`validator.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/validator.rs:173), [`validator.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/validator.rs:231) +4. `control.rs` consumes downstream descriptor/state-store results rather than owning top-level merge semantics, so no `L0` control edit was required. + - Public prompt dispatch resolves through exact participant/session targeting only: [`control.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:1601), [`control.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:2272) + - The focused contract-semantic control test passes: [`control.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:2733) +5. The accepted authoritative diff stayed inside the authorized `L0` hotspot set. + - `git diff --name-only HEAD^ HEAD` => `agent_inventory.rs`, `dispatch_contract.rs`, `mod.rs`, `validator.rs` + - No durable-state, public CLI, REPL, docs, or `.runs/**` worker edits were merged from the lane. +6. Focused authoritative validation passed. + - `cargo fmt --all -- --check` + - `cargo test -p shell dispatch_contract -- --nocapture` + - `cargo test -p shell validate_member_selection_returns_descriptor_for_unique_world_member -- --nocapture` + - `cargo test -p shell validate_exact_backend_selection_bypasses_world_ambiguity_when_backend_matches_exactly -- --nocapture` + - `cargo test -p shell public_turn_prompt_requests_require_exact_session_and_backend_contract -- --nocapture` +7. The accepted authoritative tip is recorded and `L1` branches from it exactly. + - `accepted_tip_after_G1 = 50a450a05f374e46c736f79f9b5c7fec5c0e54d9` + - `L1` worktree was created from that SHA at `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze` + +Ancillary evidence: + +- A broader `cargo test -p shell validate_ -- --nocapture` attempt earlier hit unrelated linker bus errors from integration targets matched by the broad filter; the focused selectors above are the authoritative `G1` evidence. +- An authoritative `cargo clippy -p shell --lib --tests -- -D warnings` attempt was blocked by disk exhaustion before cleanup. This is an environment incident, not a code regression, and is recorded in the task artifacts. +- `npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^` now returns `Changes: 6 files, 16 symbols`, `Affected processes: 0`, `Risk level: low`, but the stale index still misclassifies some symbols and includes unrelated `AGENTS.md`/`CLAUDE.md` drift. diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/gate.json b/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/gate.json new file mode 100644 index 000000000..5b153eb3e --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/gate.json @@ -0,0 +1,6 @@ +{ + "gate_id": "G1-contract-foundation", + "status": "passed", + "opened_at": "2026-05-24T18:13:37Z", + "passed_at": "2026-05-24T18:35:32Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/PASSED b/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/PASSED new file mode 100644 index 000000000..53cdf1e93 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/PASSED @@ -0,0 +1 @@ +PASSED diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/evidence.md b/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/evidence.md new file mode 100644 index 000000000..8f7ef5201 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/evidence.md @@ -0,0 +1,40 @@ +# G2 Evidence + +`G2` passes because the integrated authoritative tree satisfies the durable attach freeze acceptance criteria in `ORCH_PLAN.md`. + +1. Session birth persists generalized host attach truth derived from the resolved host launch contract. + - `HostAttachContract` now persists launch descriptor, capabilities, attach knobs, and continuity selector with additive serde defaults: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:75), [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:88), [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:127) + - Session creation derives that persisted truth from the resolved host launch manifest via `from_manifest(...)`: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:145), [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:159) + - Birth invariants are covered by `new_session_starts_active_attached`: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:595) +2. Successor copy preserves launch truth while clearing only continuity-specific state. + - `fork_successor_attach_contract(...)` clones the persisted attach contract and clears only `continuity_uaa_session_id`: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:335) + - The exact behavior is proven by `successor_attach_contract_clears_continuity_and_preserves_launch_truth`: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:664) +3. `state_store.rs` uses persisted attach truth rather than ambient participant state for detached attach planning. + - Public control targeting carries the persisted contract, gates resume/fork/stop off persisted capabilities, and requires continuity from the durable selector rather than the live participant: [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:779) + - Public turn targeting now returns the persisted host attach contract on the resolved target: [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:901), [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:968) + - Detached posture reconciliation and stale-attachment recovery both depend on persisted capability and continuity truth: [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:2360), [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:2399) + - Coverage exists for persisted continuity control/turn routing and legacy-json backfill: [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:3554), [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:3609), [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:3807) +4. Persisted-state validation remains additive and fail-closed. + - The generalized fields are migration-safe through serde defaults, and `validate_persisted_invariants(...)` fail-closes on descriptor, scope, protocol, and attach-knob drift: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:80), [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:128), [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:396) + - The drift guard is covered by `host_attach_contract_knob_drift_fails_closed`: [`orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:691) + - Legacy session JSON without the new fields backfills defaults and still loads cleanly: [`state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs:3807) +5. The durable-state schema freeze finalized without reopening `L0` semantics. + - The accepted authoritative diff from `50a450a05f374e46c736f79f9b5c7fec5c0e54d9` to `42636f8eb68fe2e817d973a4896f5c35cfc5b12b` is limited to `orchestration_session.rs`, `state_store.rs`, and one compile-only fixture update in `dispatch_contract.rs`. + - The only `dispatch_contract.rs` change is the test fixture expansion needed to instantiate the generalized `HostAttachContract`; persisted attach baseline semantics stay frozen: [`dispatch_contract.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:828) + - Focused authoritative validation passed on the merged tree: + - `cargo fmt --all -- --check` + - `cargo clippy -p shell --lib --tests -- -D warnings` + - `cargo test -p shell resolve_public_control_target -- --nocapture` + - `cargo test -p shell new_session_starts_active_attached -- --nocapture` + - `cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture` + - `cargo test -p shell successor_attach_contract_clears_continuity_and_preserves_launch_truth -- --nocapture` + - `cargo test -p shell host_attach_contract_knob_drift_fails_closed -- --nocapture` + - `cargo test -p shell resolve_public_turn_target_uses_persisted_continuity_truth -- --nocapture` + - `cargo test -p shell load_session_backfills_generalized_attach_contract_defaults_for_legacy_json -- --nocapture` + - `cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture` + +Ancillary evidence: + +- `accepted_tip_after_G2 = 42636f8eb68fe2e817d973a4896f5c35cfc5b12b` +- `L2` and `L3` were both created from that exact accepted authoritative SHA. +- GitNexus `detect-changes` compare remains stale and medium-risk because the index refresh crashes, but the output was preserved in the task artifacts rather than ignored. diff --git a/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/gate.json b/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/gate.json new file mode 100644 index 000000000..77bb17c4b --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/gates/G2-durable-attach/gate.json @@ -0,0 +1,6 @@ +{ + "gate_id": "G2-durable-attach", + "status": "passed", + "opened_at": "2026-05-24T18:35:32Z", + "passed_at": "2026-05-24T18:57:05Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/lane-ownership.json b/.runs/slice-29-shared-dispatch-contract/lane-ownership.json new file mode 100644 index 000000000..6c0e984af --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/lane-ownership.json @@ -0,0 +1,99 @@ +{ + "parent": { + "owns": [ + ".runs/**", + "PLAN.md", + "ORCH_PLAN.md", + "gate transitions", + "merge decisions", + "blocked-run decisions", + "validation wall", + "final acceptance" + ] + }, + "L0": { + "owned_files": [ + "crates/shell/src/execution/agent_runtime/dispatch_contract.rs", + "crates/shell/src/execution/agent_runtime/mod.rs", + "crates/shell/src/execution/agent_inventory.rs", + "crates/shell/src/execution/agent_runtime/validator.rs", + "crates/shell/src/execution/agent_runtime/control.rs" + ], + "forbidden_files": [ + "crates/shell/src/execution/agent_runtime/orchestration_session.rs", + "crates/shell/src/execution/agent_runtime/state_store.rs", + "crates/shell/src/execution/agents_cmd.rs", + "crates/shell/src/repl/async_repl.rs", + "crates/shell/src/execution/routing/dispatch/world_ops.rs", + "docs/**", + "llm-last-mile/**", + ".runs/**" + ] + }, + "L1": { + "owned_files": [ + "crates/shell/src/execution/agent_runtime/orchestration_session.rs", + "crates/shell/src/execution/agent_runtime/state_store.rs" + ], + "forbidden_files": [ + "crates/shell/src/execution/agent_runtime/dispatch_contract.rs", + "crates/shell/src/execution/agent_inventory.rs", + "crates/shell/src/execution/agent_runtime/validator.rs", + "crates/shell/src/execution/agent_runtime/control.rs", + "crates/shell/src/execution/agents_cmd.rs", + "crates/shell/src/repl/async_repl.rs", + "crates/shell/src/execution/routing/dispatch/world_ops.rs", + "docs/**", + "llm-last-mile/**", + ".runs/**" + ] + }, + "L2": { + "owned_files": [ + "crates/shell/src/execution/agents_cmd.rs", + "crates/shell/src/execution/agent_runtime/control.rs", + "crates/shell/src/execution/prompt_fulfillment.rs", + "crates/shell/tests/agent_public_control_surface_v1.rs" + ], + "forbidden_files": [ + "crates/shell/src/execution/agent_runtime/dispatch_contract.rs", + "crates/shell/src/execution/agent_inventory.rs", + "crates/shell/src/execution/agent_runtime/validator.rs", + "crates/shell/src/execution/agent_runtime/orchestration_session.rs", + "crates/shell/src/execution/agent_runtime/state_store.rs", + "crates/shell/src/repl/async_repl.rs", + "crates/shell/src/execution/routing/dispatch/world_ops.rs", + "docs/**", + "llm-last-mile/**", + ".runs/**" + ] + }, + "L3": { + "owned_files": [ + "crates/shell/src/repl/async_repl.rs", + "crates/shell/src/execution/routing/dispatch/world_ops.rs" + ], + "forbidden_files": [ + "crates/shell/src/execution/agent_runtime/dispatch_contract.rs", + "crates/shell/src/execution/agent_inventory.rs", + "crates/shell/src/execution/agent_runtime/validator.rs", + "crates/shell/src/execution/agent_runtime/control.rs", + "crates/shell/src/execution/agent_runtime/orchestration_session.rs", + "crates/shell/src/execution/agent_runtime/state_store.rs", + "crates/shell/src/execution/agents_cmd.rs", + "docs/**", + "llm-last-mile/**", + ".runs/**" + ] + }, + "L4": { + "owned_files": [ + "docs/**", + "llm-last-mile/**" + ], + "forbidden_files": [ + "crates/**", + ".runs/**" + ] + } +} diff --git a/.runs/slice-29-shared-dispatch-contract/merge-order.json b/.runs/slice-29-shared-dispatch-contract/merge-order.json new file mode 100644 index 000000000..c6b450735 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/merge-order.json @@ -0,0 +1,27 @@ +{ + "default_order": [ + "P0", + "L0", + "G1", + "L1", + "G2", + "L2", + "L3", + "G3", + "L4", + "G4", + "P1", + "G5", + "P2" + ], + "parallel_window": [ + "L2", + "L3" + ], + "parallel_window_precondition": "G2 must pass and freeze both contract vocabulary and durable-state schema before L2 and L3 branch from the same accepted SHA.", + "merge_constraints": [ + "Parent merges L2 before L3.", + "L3 is replayed onto the accepted L2 tree if required.", + "L4 does not open until both runtime lanes are accepted." + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/run-state.json b/.runs/slice-29-shared-dispatch-contract/run-state.json new file mode 100644 index 000000000..91d085d3a --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/run-state.json @@ -0,0 +1,12 @@ +{ + "run_id": "slice-29-shared-dispatch-contract", + "status": "complete", + "authoritative_branch": "feat/gateway-mediated-llm-fulfillment", + "head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "current_task_id": "P2-parent-closeout", + "current_gate_id": "G5-final-acceptance", + "worker_cap_initial": 0, + "worker_cap_peak": 2, + "updated_at": "2026-05-24T20:47:10Z", + "note": "Run completed. The authoritative slice-29 implementation/docs/test tree is accepted at 0d15fb2fe8902a9201c891eccd3d7a20325f9d72, the same-session parked-turn regression was resolved through the LOW-risk can_park_host_runtime_after_detach seam without editing the previously escalated HIGH-risk dispatch_targeted_follow_up_turn seam, and the locked validation wall passed on that accepted tree." +} diff --git a/.runs/slice-29-shared-dispatch-contract/sentinels/RUN_COMPLETE b/.runs/slice-29-shared-dispatch-contract/sentinels/RUN_COMPLETE new file mode 100644 index 000000000..3e8cd4118 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/sentinels/RUN_COMPLETE @@ -0,0 +1 @@ +2026-05-24T20:47:10Z diff --git a/.runs/slice-29-shared-dispatch-contract/session-log.md b/.runs/slice-29-shared-dispatch-contract/session-log.md new file mode 100644 index 000000000..a063dcac2 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/session-log.md @@ -0,0 +1,34 @@ +# Session Log + +- `2026-05-24T18:13:37Z` Parent entered `P0`, confirmed branch `feat/gateway-mediated-llm-fulfillment`, locked `PLAN.md` SHA `9389b48a1d20f93c8411a7670f11e25ed0929b0cddce7dc4deeb5c5adb5182cc`, and initialized the slice-29 run-state tree. +- `2026-05-24T18:13:37Z` GitNexus status reported the local index as stale (`Indexed commit a628e41`, `Current commit 4c61ab7`). +- `2026-05-24T18:13:37Z` Parent attempted `npx gitnexus analyze` to refresh the index before any symbol edits; the CLI failed during rebuild with `free(): invalid pointer`. The failure is recorded here and in `P0` artifacts; impact and detect-changes will still be run from the available index as a best-effort control unless the tool becomes unusable. +- `2026-05-24T18:13:37Z` Inspection confirmed the authoritative tree is pre-slice-29 in code: `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` does not exist and current launch semantics remain distributed across `agents_cmd.rs`, `control.rs`, `state_store.rs`, and `async_repl.rs`. +- `2026-05-24T18:13:37Z` `G0` passed: source lock, contract freeze, durable-state freeze, lane ownership, merge order, and worktree root initialization are complete; no slice-29 worker worktree exists yet. +- `2026-05-24T18:29:42Z` The `L0` worker lane committed `6be348293ad140690385fbde5463992e084f5470` on `codex/feat-gateway-mediated-llm-fulfillment-s29-a1-a2-contract-foundation`, adding `dispatch_contract.rs`, inventory projection helpers, and validator materialization through the shared contract. +- `2026-05-24T18:30:02Z` The parent integrated `L0` by cherry-picking the worker tip onto the authoritative branch, producing accepted commit `50a450a05f374e46c736f79f9b5c7fec5c0e54d9`. +- `2026-05-24T18:34:55Z` Authoritative validation was briefly blocked by disk exhaustion (`/dev/root` at 100%); the parent removed reproducible build artifacts from the `L0` worktree target and `target/tests-tmp`, restoring 26G free space without touching source state. +- `2026-05-24T18:35:32Z` Focused authoritative checks passed for `dispatch_contract`, validator exact/unique selection, `public_turn_prompt_requests_require_exact_session_and_backend_contract`, and `cargo fmt --all -- --check`. +- `2026-05-24T18:35:32Z` `npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^` now returns a low-risk compare summary for the integrated commit, but the stale index still misclassifies `dispatch_contract.rs` and unrelated `AGENTS.md`/`CLAUDE.md` drift as `undefined`; that limitation is preserved as evidence rather than ignored. +- `2026-05-24T18:35:32Z` `G1` passed. The parent recorded `accepted_tip_after_G1=50a450a05f374e46c736f79f9b5c7fec5c0e54d9`, created the `L1` worktree at `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze`, and moved the run to `L1 -> G2`. +- `2026-05-24T18:38:35Z` `L1` impact triage completed before any durable-state edits. `sync_host_attach_contract` returned `MEDIUM`, `fork_successor_attach_contract` returned `LOW`, and `resolve_public_control_target` returned `HIGH` (`19` impacted symbols, `2` affected processes, including `handle_agent_command`). Per the run contract, the parent stopped before editing and wrote `blocked.json` pending explicit user direction to continue through the HIGH-risk seam. +- `2026-05-24T18:50:21Z` `L1` resumed under explicit user direction to proceed through the previously escalated HIGH-risk `resolve_public_control_target` seam while staying inside the frozen `A3` boundary. The worker lane committed `aa656181aa53c92cefbec1264145c5b071e5e803`, generalizing `HostAttachContract`, preserving successor launch truth, and moving detached posture checks onto persisted attach truth. +- `2026-05-24T18:50:46Z` The parent integrated `L1` by cherry-picking `aa656181aa53c92cefbec1264145c5b071e5e803` onto the authoritative branch, producing accepted commit `42636f8eb68fe2e817d973a4896f5c35cfc5b12b`. The integrated diff touched `dispatch_contract.rs` only for a compile-only unit-test fixture update required by the expanded `HostAttachContract` fields; shared contract semantics remained closed. +- `2026-05-24T18:57:05Z` Focused authoritative `G2` validation passed: `cargo fmt --all -- --check`, `cargo clippy -p shell --lib --tests -- -D warnings`, `resolve_public_control_target`, `new_session_starts_active_attached`, `detached_postures_enforce_pending_inbox_truth`, `successor_attach_contract_clears_continuity_and_preserves_launch_truth`, `host_attach_contract_knob_drift_fails_closed`, `resolve_public_turn_target_uses_persisted_continuity_truth`, `load_session_backfills_generalized_attach_contract_defaults_for_legacy_json`, and `persisted_attach_contract_is_explicit_baseline_domain`. +- `2026-05-24T18:57:05Z` `G2` passed. The parent recorded `accepted_tip_after_G2=42636f8eb68fe2e817d973a4896f5c35cfc5b12b`, removed the stale blocked state, and opened the only safe parallel window with `L2` at `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a4-human-caller-adoption` and `L3` at `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a5-repl-dispatch-adoption`. +- `2026-05-24T18:57:05Z` Parent dispatched the live parallel workers: `Popper` owns `L2` human caller adoption and `Lovelace` owns `L3` REPL dispatch adoption. Both were instructed to run GitNexus impact before symbol edits, stop on any new `HIGH` or `CRITICAL` result, respect the frozen ownership boundaries, and return detect-changes plus validation evidence. +- `2026-05-24T19:03:41Z` `L3` halted at its impact gate with no code changes. `build_member_dispatch_transport_request` returned `LOW`, but `dispatch_targeted_follow_up_turn` returned `HIGH` with affected processes `main`, `run_shell_with_cli`, and `run_async_repl`, so the lane is parked pending explicit escalation. +- `2026-05-24T19:12:43Z` `L2` reported no `HIGH` or `CRITICAL` impact results. The lane hit an environment issue instead: the worktree-local `target/` ran out of space, but the required `public_turn_prompt_requests_require_exact_session_and_backend_contract` selector passed once the worker switched to `TMPDIR=/mnt/localssd/tmp` and `CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-s29-a4-target`. The parent resumed Popper to finish the remaining required validation and final handoff. +- `2026-05-24T19:46:12Z` The parent took over `L2`, closed the stalled worker, and reran the locked human-caller checks from the `a4-human-caller-adoption` worktree with `TMPDIR=/mnt/localssd/tmp` and `CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target`. Targeted selectors for exact session/backend control, detached continuity recovery, and slow-start prompt normalization passed, but `cargo test -p shell --test agent_public_control_surface_v1 public_same_session_parked_status_turn_reattach_and_stop_stay_on_one_orchestration_session_id -- --nocapture` failed deterministically after emitting a successful `completed` turn envelope because `sess_turn_reattach_stop` became terminal before the resumed participant reparked. +- `2026-05-24T19:46:12Z` Code inspection narrowed the remaining fault to the fast-exit auto-park handoff in `crates/shell/src/repl/async_repl.rs`: the hidden owner-helper completion can win before the queued `park_after_turn` request is consumed, leaving the resumed turn to invalidate instead of parking. That file is owned exclusively by `L3` under the controller, and `L3` is already stopped on a `HIGH` GitNexus impact result for `dispatch_targeted_follow_up_turn`, so the parent wrote `blocked.json`, added `RUN_BLOCKED`, and stopped the run at `G3` with no legal in-scope fix path remaining. +- `2026-05-24T19:56:36Z` The parent made one last `L2`-owned attempt to recover terminalized fast-exit resumed turns without reopening `async_repl.rs`: a narrow `control.rs` reconciliation for success-exit terminal fingerprints plus a stricter post-reconciliation stability check in `agents_cmd.rs`. The attempt did not clear the blocker. The latest same-session turn failure still ended at `owner_unreachable`, and the enriched obstruction message showed the persisted late-write pair: `session_state=Invalidated`, `session_reason='attached control turn exited with status 0'`, with the participant flipping between `Invalidated('attached control turn exited with status 0')` and `Failed('shell-owned orchestrator cancel did not produce authoritative terminal completion')`. This confirmed the remaining defect lives in the helper/REPL closeout path, not the human caller surface. +- `2026-05-24T20:10:54Z` The parent cleaned the dead-end `L2` terminal-revival experiment out of `control.rs`, staged only the owned lane files, and cut two lane commits: `7ec35e3d` (`feat: adopt shared dispatch contract`) for `L2` and `7b560e2e` (`fix: preserve auto-parked host continuity`) for the low-risk `L3` helper-closeout fix. `gitnexus detect-changes --scope staged` returned `No changes detected` in both worktrees despite the staged code diff; the limitation is preserved as evidence rather than treated as a clean detect-changes result. +- `2026-05-24T20:11:24Z` The parent integrated the lane commits onto the authoritative branch in plan order, producing `d5323945` for `L2` and `c2721ed5` for the low-risk `L3` fix. The authoritative checkout target directory was moved onto `/mnt/localssd/tmp/substrate-authoritative-target` to avoid root-disk exhaustion during acceptance runs. +- `2026-05-24T20:14:06Z` Focused merged-tree selectors passed on authoritative head `c2721ed5ec0bdb4d764c26f033d47fbe4f648c06`: `resolve_public_control_target`, `public_turn_prompt_requests_require_exact_session_and_backend_contract`, `new_session_starts_active_attached`, and `detached_postures_enforce_pending_inbox_truth`. +- `2026-05-24T20:15:53Z` `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` on authoritative head `c2721ed5` failed at only one case: `public_same_session_parked_status_turn_reattach_and_stop_stay_on_one_orchestration_session_id`. Earlier control-surface cases, including detached pending-inbox normalization and persisted reattach continuity, passed. +- `2026-05-24T20:16:58Z` The parent traced the remaining same-session failure to the targeted follow-up host launch path in `crates/shell/src/repl/async_repl.rs`, where `dispatch_targeted_follow_up_turn` calls `start_host_orchestrator_runtime_with_prepared_prompt(..., false /* auto_park_after_public_turn */ , ...)`. That seam is the same `HIGH` GitNexus impact area that previously halted `L3`. +- `2026-05-24T20:17:15Z` The parent tested and reverted one last low-risk experiment by widening `LOCAL_RETAINED_AUTO_PARK_GRACE_TIMEOUT` from `250ms` to `2s` and rerunning only the failing same-session selector. The failure reproduced unchanged with `session_state=Invalidated`, `session_reason='attached control turn exited with status 0'`, `participant_state=Failed`, and `participant_reason='shell-owned orchestrator cancel did not produce authoritative terminal completion'`. The run remains blocked at `G3` pending explicit authorization to cross the `HIGH` targeted follow-up seam. +- `2026-05-24T20:31:18Z` The parent resumed from the accepted authoritative tree without touching the HIGH-risk targeted follow-up seam. Fresh impact checks kept `can_park_host_runtime_after_detach` in the LOW-risk bucket, and the parent used that seam to reconcile the persisted-store lag that was still invalidating same-session parked turns after successful one-turn completion. +- `2026-05-24T20:35:44Z` The new low-risk `async_repl.rs` handoff predicate and regression test cleared the last runtime failure. `public_same_session_parked_status_turn_reattach_and_stop_stay_on_one_orchestration_session_id`, the full `agent_public_control_surface_v1`, the full `repl_world_first_routing_v1`, and the full `agent_successor_contract_ahcsitc0` suite all passed on the authoritative tree. +- `2026-05-24T20:45:31Z` The parent aligned the allowed docs truth surfaces (`ADR-0027`, `llm-last-mile/29`, `/30`, `/31`, and `README.md`) to the merged runtime behavior and committed the authoritative implementation/docs/test tree as `0d15fb2fe8902a9201c891eccd3d7a20325f9d72` (`feat: complete slice-29 shared dispatch contract`). +- `2026-05-24T20:47:10Z` `G3`, `L4`, `G4`, `P1`, and `G5` all passed against accepted tip `0d15fb2fe8902a9201c891eccd3d7a20325f9d72`. The locked commands from `ORCH_PLAN.md` passed, and `npx gitnexus detect-changes --repo substrate --scope staged` reported `7 files`, `37 symbols`, `0 affected processes`, `risk level: low`. The parent then entered `P2` to write final closeout artifacts and mark the run complete. diff --git a/.runs/slice-29-shared-dispatch-contract/source-lock.json b/.runs/slice-29-shared-dispatch-contract/source-lock.json new file mode 100644 index 000000000..204bd235e --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/source-lock.json @@ -0,0 +1,14 @@ +{ + "authoritative_branch": "feat/gateway-mediated-llm-fulfillment", + "authoritative_head_sha": "4c61ab779752a9185c6b7558275d7fdb5880893c", + "repo_root": "/home/azureuser/__Active_Code/atomize-hq/substrate", + "plan_path": "/home/azureuser/__Active_Code/atomize-hq/substrate/PLAN.md", + "orch_path": "/home/azureuser/__Active_Code/atomize-hq/substrate/ORCH_PLAN.md", + "plan_sha256": "9389b48a1d20f93c8411a7670f11e25ed0929b0cddce7dc4deeb5c5adb5182cc", + "git_status_short_plan_orch": "## feat/gateway-mediated-llm-fulfillment...origin/feat/gateway-mediated-llm-fulfillment", + "timestamp": "2026-05-24T18:13:37Z", + "notes": [ + "ORCH_PLAN.md hard guard 4 expected PLAN.md to be dirty in the authoritative checkout, but the live checkout is clean for PLAN.md and ORCH_PLAN.md at source-lock time.", + "The parent locked the live working-tree PLAN.md bytes exactly as found and will stop the run if they change materially." + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks.json b/.runs/slice-29-shared-dispatch-contract/tasks.json new file mode 100644 index 000000000..cec8733c2 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks.json @@ -0,0 +1,54 @@ +{ + "P0-parent-freeze": { + "status": "completed", + "note": "Source lock, contract freeze, durable-state freeze, lane ownership, merge order, and G0 evidence were written by the parent." + }, + "L0-a1-a2-contract-foundation": { + "status": "completed", + "note": "Worker landed the shared dispatch contract foundation on its lane branch and the parent integrated it at 50a450a05f374e46c736f79f9b5c7fec5c0e54d9." + }, + "G1-contract-foundation-accept": { + "status": "completed", + "note": "Parent accepted the contract foundation, recorded accepted_tip_after_G1, and opened L1 from the accepted authoritative tip." + }, + "L1-a3-durable-attach-freeze": { + "status": "completed", + "note": "Worker landed generalized persisted attach truth and detached-planning consumption on branch codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze; the parent accepted it at 42636f8eb68fe2e817d973a4896f5c35cfc5b12b." + }, + "G2-durable-attach-accept": { + "status": "completed", + "note": "Parent accepted the durable attach freeze, recorded accepted_tip_after_G2, and opened the L2/L3 worktrees from the same authoritative SHA." + }, + "L2-a4-human-caller-adoption": { + "status": "completed", + "note": "Parent took over L2 after the worker stalled, landed the caller-surface adoption as authoritative commit d5323945, and preserved the shared contract semantics through final acceptance. The final same-session parked-turn repair did not require further L2-owned edits." + }, + "L3-a5-repl-dispatch-adoption": { + "status": "completed", + "note": "Parent first landed the low-risk helper-closeout fix as authoritative commit c2721ed5, then resolved the remaining same-session parked-turn race on the LOW-risk can_park_host_runtime_after_detach seam in authoritative commit 0d15fb2fe8902a9201c891eccd3d7a20325f9d72. The previously escalated HIGH-risk dispatch_targeted_follow_up_turn seam remained untouched." + }, + "G3-parallel-window-accept": { + "status": "completed", + "note": "G3 passed on authoritative commit 0d15fb2fe8902a9201c891eccd3d7a20325f9d72. Both runtime lanes now satisfy the shared dispatch contract semantics, including same-session parked-turn reattach/stop continuity." + }, + "L4-a6-docs-truth-sync": { + "status": "completed", + "note": "Parent aligned ADR-0027 and the llm-last-mile slice-29/30/31 truth surfaces to the merged runtime behavior on authoritative commit 0d15fb2fe8902a9201c891eccd3d7a20325f9d72." + }, + "G4-docs-accept": { + "status": "completed", + "note": "G4 passed. The allowed docs truth surfaces now match the merged runtime: one shared dispatch-contract owner, explicit inventory/persisted-attach baseline domains, persisted HostAttachContract launch truth, caller parity, and no second durable attach object." + }, + "P1-a7-validation-wall": { + "status": "completed", + "note": "The locked validation wall passed on authoritative commit 0d15fb2fe8902a9201c891eccd3d7a20325f9d72: the four named shell selectors, agent_public_control_surface_v1, repl_world_first_routing_v1, cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace -- --nocapture." + }, + "G5-final-acceptance": { + "status": "completed", + "note": "G5 passed on authoritative commit 0d15fb2fe8902a9201c891eccd3d7a20325f9d72. GitNexus detect-changes on the staged implementation tree reported 7 files, 37 symbols, 0 affected processes, and overall risk level low." + }, + "P2-parent-closeout": { + "status": "completed", + "note": "Parent wrote the final summary, validation evidence, task/gate closeout records, and RUN_COMPLETE sentinel." + } +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/ACCEPTED new file mode 100644 index 000000000..377cefa1a --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/ACCEPTED @@ -0,0 +1 @@ +accepted diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/HEAD_SHA.txt new file mode 100644 index 000000000..c61b0a7d3 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/HEAD_SHA.txt @@ -0,0 +1 @@ +50a450a05f374e46c736f79f9b5c7fec5c0e54d9 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/changed-files.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/changed-files.txt new file mode 100644 index 000000000..180aa7815 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/changed-files.txt @@ -0,0 +1,11 @@ +crates/shell/src/execution/agent_inventory.rs +crates/shell/src/execution/agent_runtime/dispatch_contract.rs +crates/shell/src/execution/agent_runtime/mod.rs +crates/shell/src/execution/agent_runtime/validator.rs +.runs/slice-29-shared-dispatch-contract/branch-map.json +.runs/slice-29-shared-dispatch-contract/run-state.json +.runs/slice-29-shared-dispatch-contract/tasks.json +.runs/slice-29-shared-dispatch-contract/session-log.md +.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/* +.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/* +.runs/slice-29-shared-dispatch-contract/gates/G1-contract-foundation/* diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/commands.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/commands.txt new file mode 100644 index 000000000..e653aafa4 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/commands.txt @@ -0,0 +1,8 @@ +git cherry-pick 6be348293ad140690385fbde5463992e084f5470 +cargo fmt --all -- --check +cargo test -p shell dispatch_contract -- --nocapture +cargo test -p shell validate_member_selection_returns_descriptor_for_unique_world_member -- --nocapture +cargo test -p shell validate_exact_backend_selection_bypasses_world_ambiguity_when_backend_matches_exactly -- --nocapture +cargo test -p shell public_turn_prompt_requests_require_exact_session_and_backend_contract -- --nocapture +npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^ +git worktree add -b codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze 50a450a05f374e46c736f79f9b5c7fec5c0e54d9 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/deliverable.txt new file mode 100644 index 000000000..eb93a7c05 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/deliverable.txt @@ -0,0 +1 @@ +Accepted L0 on the authoritative branch, recorded `accepted_tip_after_G1`, wrote contract-foundation evidence, and opened the serialized L1 worktree from the accepted tip. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/dependencies.json new file mode 100644 index 000000000..a13bd97cf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/dependencies.json @@ -0,0 +1,8 @@ +{ + "depends_on": [ + "L0-a1-a2-contract-foundation" + ], + "unblocks": [ + "L1-a3-durable-attach-freeze" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/exit-codes.json b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/exit-codes.json new file mode 100644 index 000000000..9cdfcffc5 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/exit-codes.json @@ -0,0 +1,14 @@ +{ + "git cherry-pick 6be348293ad140690385fbde5463992e084f5470": 0, + "cargo fmt --all -- --check": 0, + "cargo test -p shell dispatch_contract -- --nocapture": 0, + "cargo test -p shell validate_member_selection_returns_descriptor_for_unique_world_member -- --nocapture": 0, + "cargo test -p shell validate_exact_backend_selection_bypasses_world_ambiguity_when_backend_matches_exactly -- --nocapture": 0, + "cargo test -p shell public_turn_prompt_requests_require_exact_session_and_backend_contract -- --nocapture": 0, + "npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^": 0, + "git worktree add -b codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze 50a450a05f374e46c736f79f9b5c7fec5c0e54d9": 0, + "cargo test -p shell validate_ -- --nocapture": 101, + "cargo clippy -p shell --lib --tests -- -D warnings": 101, + "rm -rf /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a1-a2-contract-foundation/target": 0, + "rm -rf /home/azureuser/__Active_Code/atomize-hq/substrate/target/tests-tmp": 0 +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/gitnexus-detect-changes.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/gitnexus-detect-changes.txt new file mode 100644 index 000000000..f7ff5e264 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/gitnexus-detect-changes.txt @@ -0,0 +1,10 @@ +Authoritative compare result: + +- `npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^` +- Exit code: `0` +- Summary: `Changes: 6 files, 16 symbols`, `Affected processes: 0`, `Risk level: low` + +Limitations retained as evidence: + +- The compare output still lists unrelated `AGENTS.md` / `CLAUDE.md` drift because those files changed earlier outside this slice and are present in `HEAD^..HEAD`. +- The stale index labels several entries as `undefined` and does not expose `dispatch_contract.rs` directly, so detect-changes is helpful but not authoritative for per-symbol coverage in this run. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/handoff-notes.md b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/handoff-notes.md new file mode 100644 index 000000000..4d5c649a0 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/handoff-notes.md @@ -0,0 +1,11 @@ +Parent acceptance notes: + +- Accepted authoritative commit: `50a450a05f374e46c736f79f9b5c7fec5c0e54d9` +- Recorded in `branch-map.json` as `accepted_tip_after_G1` +- Opened `L1` branch `codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze` +- `L1` worktree path: `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze` + +Important carry-forward constraints: + +- `dispatch_contract.rs`, `agent_inventory.rs`, and `validator.rs` are now frozen after `G1`. +- `L1` may touch only `orchestration_session.rs`, `state_store.rs`, and directly related tests. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/impact-analysis-summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/impact-analysis-summary.md new file mode 100644 index 000000000..68657531e --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/impact-analysis-summary.md @@ -0,0 +1,5 @@ +G1 reviewed the pre-edit impact evidence already captured under `L0` and confirmed the integrated tree stayed within the frozen hotspot set: + +- Modified authoritative code files: `agent_inventory.rs`, `dispatch_contract.rs`, `mod.rs`, `validator.rs` +- No `control.rs`, `orchestration_session.rs`, `state_store.rs`, `agents_cmd.rs`, `async_repl.rs`, or docs edits landed in `L0` +- The accepted tree therefore remained inside the authorized `A1+A2` boundary despite the HIGH-risk validator blast radius diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/owner.txt new file mode 100644 index 000000000..9676f5bfe --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/owner.txt @@ -0,0 +1 @@ +parent diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/scope.txt new file mode 100644 index 000000000..7dcedc364 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/scope.txt @@ -0,0 +1 @@ +Review L0 against the frozen slice-29 contract criteria, integrate only the accepted lane tip, record accepted_tip_after_G1, and open L1 from that exact authoritative SHA. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/summary.md new file mode 100644 index 000000000..06cdd9666 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/summary.md @@ -0,0 +1 @@ +G1 passed. The authoritative tree now contains the shared dispatch contract foundation at `50a450a05f374e46c736f79f9b5c7fec5c0e54d9`, focused contract/validator/control evidence is green, and L1 has been opened from the recorded accepted tip. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/task.json new file mode 100644 index 000000000..57b2d0809 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G1-contract-foundation-accept/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "G1-contract-foundation-accept", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "50a450a05f374e46c736f79f9b5c7fec5c0e54d9", + "started_at": "2026-05-24T18:29:42Z", + "completed_at": "2026-05-24T18:35:32Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/ACCEPTED new file mode 100644 index 000000000..a6413548e --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/ACCEPTED @@ -0,0 +1 @@ +ACCEPTED diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/HEAD_SHA.txt new file mode 100644 index 000000000..ebdbcfc31 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/HEAD_SHA.txt @@ -0,0 +1 @@ +42636f8eb68fe2e817d973a4896f5c35cfc5b12b diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/changed-files.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/changed-files.txt new file mode 100644 index 000000000..08f0ae98f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/changed-files.txt @@ -0,0 +1,3 @@ +crates/shell/src/execution/agent_runtime/dispatch_contract.rs +crates/shell/src/execution/agent_runtime/orchestration_session.rs +crates/shell/src/execution/agent_runtime/state_store.rs diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/commands.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/commands.txt new file mode 100644 index 000000000..570103cb2 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/commands.txt @@ -0,0 +1,14 @@ +git cherry-pick aa656181aa53c92cefbec1264145c5b071e5e803 +cargo fmt --all -- --check +cargo clippy -p shell --lib --tests -- -D warnings +cargo test -p shell resolve_public_control_target -- --nocapture +cargo test -p shell new_session_starts_active_attached -- --nocapture +cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture +cargo test -p shell successor_attach_contract_clears_continuity_and_preserves_launch_truth -- --nocapture +cargo test -p shell host_attach_contract_knob_drift_fails_closed -- --nocapture +cargo test -p shell resolve_public_turn_target_uses_persisted_continuity_truth -- --nocapture +cargo test -p shell load_session_backfills_generalized_attach_contract_defaults_for_legacy_json -- --nocapture +cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture +npx gitnexus detect-changes --repo substrate --scope compare --base-ref 50a450a05f374e46c736f79f9b5c7fec5c0e54d9 +git worktree add /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a4-human-caller-adoption -b codex/feat-gateway-mediated-llm-fulfillment-s29-a4-human-caller-adoption 42636f8eb68fe2e817d973a4896f5c35cfc5b12b +git worktree add /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a5-repl-dispatch-adoption -b codex/feat-gateway-mediated-llm-fulfillment-s29-a5-repl-dispatch-adoption 42636f8eb68fe2e817d973a4896f5c35cfc5b12b diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/deliverable.txt new file mode 100644 index 000000000..90253aaba --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/deliverable.txt @@ -0,0 +1 @@ +The parent accepted the authoritative L1 integration at 42636f8eb68fe2e817d973a4896f5c35cfc5b12b, finalized the durable-state schema freeze, recorded accepted_tip_after_G2, and opened the only safe parallel window with L2 and L3 branched from that exact accepted SHA. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/dependencies.json new file mode 100644 index 000000000..b20d0ad3f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/dependencies.json @@ -0,0 +1,9 @@ +{ + "depends_on": [ + "L1-a3-durable-attach-freeze" + ], + "unblocks": [ + "L2-a4-human-caller-adoption", + "L3-a5-repl-dispatch-adoption" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/exit-codes.json b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/exit-codes.json new file mode 100644 index 000000000..f663a4b94 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/exit-codes.json @@ -0,0 +1,16 @@ +{ + "git cherry-pick aa656181aa53c92cefbec1264145c5b071e5e803": 0, + "cargo fmt --all -- --check": 0, + "cargo clippy -p shell --lib --tests -- -D warnings": 0, + "cargo test -p shell resolve_public_control_target -- --nocapture": 0, + "cargo test -p shell new_session_starts_active_attached -- --nocapture": 0, + "cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture": 0, + "cargo test -p shell successor_attach_contract_clears_continuity_and_preserves_launch_truth -- --nocapture": 0, + "cargo test -p shell host_attach_contract_knob_drift_fails_closed -- --nocapture": 0, + "cargo test -p shell resolve_public_turn_target_uses_persisted_continuity_truth -- --nocapture": 0, + "cargo test -p shell load_session_backfills_generalized_attach_contract_defaults_for_legacy_json -- --nocapture": 0, + "cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture": 0, + "npx gitnexus detect-changes --repo substrate --scope compare --base-ref 50a450a05f374e46c736f79f9b5c7fec5c0e54d9": 0, + "git worktree add /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a4-human-caller-adoption -b codex/feat-gateway-mediated-llm-fulfillment-s29-a4-human-caller-adoption 42636f8eb68fe2e817d973a4896f5c35cfc5b12b": 0, + "git worktree add /home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a5-repl-dispatch-adoption -b codex/feat-gateway-mediated-llm-fulfillment-s29-a5-repl-dispatch-adoption 42636f8eb68fe2e817d973a4896f5c35cfc5b12b": 0 +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/gitnexus-detect-changes.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/gitnexus-detect-changes.txt new file mode 100644 index 000000000..65e2463f1 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/gitnexus-detect-changes.txt @@ -0,0 +1,33 @@ +`npx gitnexus detect-changes --repo substrate --scope compare --base-ref 50a450a05f374e46c736f79f9b5c7fec5c0e54d9` output: + +```text +Changes: 5 files, 47 symbols +Affected processes: 4 +Risk level: medium + +Changed symbols: + undefined GitNexus — Code Intelligence → AGENTS.md + undefined GitNexus — Code Intelligence → CLAUDE.md + undefined from_manifest → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined new → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined transition_state → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined mark_startup_prompt_completed → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined HostAttachContract → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined OrchestrationSessionRecord → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined continuity_uaa_session_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined orchestration_session_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined shell_trace_session_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined workspace_root → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined shell_owner_pid → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined state → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined opened_at → crates/shell/src/execution/agent_runtime/orchestration_session.rs + ... and 32 more + +Affected execution flows: + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → RuntimeSelectionDescriptor (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → New (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → Transition_state (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → Bind_active_session_handle (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session +``` + +The compare output remains constrained by the stale index and still misattributes unrelated `AGENTS.md` / `CLAUDE.md` drift. It is preserved as required evidence rather than treated as authoritative scope proof. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/handoff-notes.md b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/handoff-notes.md new file mode 100644 index 000000000..821c2563f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/handoff-notes.md @@ -0,0 +1,14 @@ +Parent acceptance notes: + +- Accepted authoritative commit: `42636f8eb68fe2e817d973a4896f5c35cfc5b12b` +- Recorded in `branch-map.json` as `accepted_tip_after_G2` +- Opened `L2` branch `codex/feat-gateway-mediated-llm-fulfillment-s29-a4-human-caller-adoption` +- Opened `L3` branch `codex/feat-gateway-mediated-llm-fulfillment-s29-a5-repl-dispatch-adoption` +- `L2` worktree path: `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a4-human-caller-adoption` +- `L3` worktree path: `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a5-repl-dispatch-adoption` + +Important carry-forward constraints: + +- `dispatch_contract.rs`, `agent_inventory.rs`, `validator.rs`, `orchestration_session.rs`, and `state_store.rs` are now frozen after `G2`. +- `L2` may touch only `agents_cmd.rs`, `control.rs`, `prompt_fulfillment.rs`, `crates/shell/tests/agent_public_control_surface_v1.rs`, and minimal supporting tests in its owned surface. +- `L3` may touch only `repl/async_repl.rs`, `crates/shell/tests/repl_world_first_routing_v1.rs`, and `world_ops.rs` only if a concrete additive transport field is proven necessary. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/impact-analysis-summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/impact-analysis-summary.md new file mode 100644 index 000000000..16c60d576 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/impact-analysis-summary.md @@ -0,0 +1,5 @@ +G2 acceptance preserved the L1 GitNexus impact evidence rather than rerunning new symbol edits on the parent branch. + +- The highest-risk L1 seam remained `AgentRuntimeStateStore.resolve_public_control_target` with `HIGH` risk and `19` impacted symbols. +- The run stayed blocked until that result was escalated and explicit user direction authorized continuation within the frozen `A3` ownership boundary. +- The merged authoritative diff still centered on durable-state files; the only out-of-band path was a compile-only unit-test fixture update in `dispatch_contract.rs`, which did not reopen the shared dispatch semantics frozen at `G1`. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/owner.txt new file mode 100644 index 000000000..9676f5bfe --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/owner.txt @@ -0,0 +1 @@ +parent diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/scope.txt new file mode 100644 index 000000000..e7c36813b --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/scope.txt @@ -0,0 +1 @@ +Review L1 against the frozen durable-state criteria, integrate only the accepted lane tip, record accepted_tip_after_G2, and open the one allowed L2/L3 parallel window from that exact authoritative SHA without reopening the shared contract or durable-state owners. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/summary.md new file mode 100644 index 000000000..86eff4e83 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/summary.md @@ -0,0 +1 @@ +G2 passed. The authoritative tree at `42636f8eb68fe2e817d973a4896f5c35cfc5b12b` satisfies the durable attach freeze criteria, `accepted_tip_after_G2` is recorded, and the only safe parallel window is open with both runtime lanes branched from the same accepted tree. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/task.json new file mode 100644 index 000000000..5cb5b2f58 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G2-durable-attach-accept/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "G2-durable-attach-accept", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "started_at": "2026-05-24T18:35:32Z", + "completed_at": "2026-05-24T18:57:05Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/ACCEPTED new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/ACCEPTED @@ -0,0 +1 @@ + diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/HEAD_SHA.txt new file mode 100644 index 000000000..487582b54 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/HEAD_SHA.txt @@ -0,0 +1 @@ +0d15fb2fe8902a9201c891eccd3d7a20325f9d72 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/summary.md new file mode 100644 index 000000000..621e42fdf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/summary.md @@ -0,0 +1 @@ +G3 passed on `0d15fb2fe8902a9201c891eccd3d7a20325f9d72`. The runtime lanes now satisfy the shared dispatch contract together, including the same-session parked-turn reattach/stop case that previously blocked acceptance. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/task.json new file mode 100644 index 000000000..807e2fb67 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G3-parallel-window-accept/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "G3-parallel-window-accept", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "started_at": "2026-05-24T18:57:05Z", + "completed_at": "2026-05-24T20:47:10Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/ACCEPTED new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/ACCEPTED @@ -0,0 +1 @@ + diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/HEAD_SHA.txt new file mode 100644 index 000000000..487582b54 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/HEAD_SHA.txt @@ -0,0 +1 @@ +0d15fb2fe8902a9201c891eccd3d7a20325f9d72 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/summary.md new file mode 100644 index 000000000..09700c259 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/summary.md @@ -0,0 +1 @@ +G4 passed. The merged docs truth now matches the accepted runtime behavior and does not reassign contract ownership or durable attach truth outside the shared slice-29 contract surface. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/task.json new file mode 100644 index 000000000..c9f33649f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G4-docs-accept/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "G4-docs-accept", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "started_at": "2026-05-24T20:35:44Z", + "completed_at": "2026-05-24T20:47:10Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/ACCEPTED new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/ACCEPTED @@ -0,0 +1 @@ + diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/HEAD_SHA.txt new file mode 100644 index 000000000..487582b54 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/HEAD_SHA.txt @@ -0,0 +1 @@ +0d15fb2fe8902a9201c891eccd3d7a20325f9d72 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/summary.md new file mode 100644 index 000000000..7afba54c0 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/summary.md @@ -0,0 +1 @@ +G5 passed. The accepted tree satisfies the slice-29 implementation contract from `PLAN.md` and `ORCH_PLAN.md`, the validation wall is green, and the recorded detect-changes scope is low risk. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/task.json new file mode 100644 index 000000000..4266ae704 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/G5-final-acceptance/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "G5-final-acceptance", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "started_at": "2026-05-24T20:45:31Z", + "completed_at": "2026-05-24T20:47:10Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/ACCEPTED new file mode 100644 index 000000000..377cefa1a --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/ACCEPTED @@ -0,0 +1 @@ +accepted diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/HEAD_SHA.txt new file mode 100644 index 000000000..0d20a4812 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/HEAD_SHA.txt @@ -0,0 +1 @@ +6be348293ad140690385fbde5463992e084f5470 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/changed-files.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/changed-files.txt new file mode 100644 index 000000000..61af962a7 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/changed-files.txt @@ -0,0 +1,4 @@ +crates/shell/src/execution/agent_inventory.rs +crates/shell/src/execution/agent_runtime/dispatch_contract.rs +crates/shell/src/execution/agent_runtime/mod.rs +crates/shell/src/execution/agent_runtime/validator.rs diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/commands.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/commands.txt new file mode 100644 index 000000000..a5dbd97f0 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/commands.txt @@ -0,0 +1,12 @@ +npx gitnexus impact resolve_gateway_backend_inventory_entry --repo substrate --direction upstream +npx gitnexus impact validate_runtime_realizability --repo substrate --direction upstream +npx gitnexus impact validate_member_selection --repo substrate --direction upstream +npx gitnexus impact validate_exact_backend_selection --repo substrate --direction upstream +cargo test -p shell inventory_contract_tracks_workspace_origin_and_config_defaults -- --nocapture +cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture +cargo test -p shell validate_exact_backend_selection_bypasses_world_ambiguity_when_backend_matches_exactly -- --nocapture +cargo test -p shell validate_member_selection_returns_descriptor_for_unique_world_member -- --nocapture +cargo test -p shell dispatch_contract -- --nocapture +cargo test -p shell validate_ -- --nocapture +cargo fmt --all +cargo clippy -p shell --lib --tests -- -D warnings diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/deliverable.txt new file mode 100644 index 000000000..ef85e5f8d --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/deliverable.txt @@ -0,0 +1 @@ +Shared dispatch-contract foundation landed on the L0 worker branch with explicit inventory and persisted-attach baseline domains, runtime materialization through the contract boundary, and no second contract owner or durable attach object. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/dependencies.json new file mode 100644 index 000000000..46dd2fba5 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/dependencies.json @@ -0,0 +1,8 @@ +{ + "depends_on": [ + "P0-parent-freeze" + ], + "unblocks": [ + "G1-contract-foundation-accept" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/exit-codes.json b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/exit-codes.json new file mode 100644 index 000000000..0efddab39 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/exit-codes.json @@ -0,0 +1,14 @@ +{ + "npx gitnexus impact resolve_gateway_backend_inventory_entry --repo substrate --direction upstream": 0, + "npx gitnexus impact validate_runtime_realizability --repo substrate --direction upstream": 0, + "npx gitnexus impact validate_member_selection --repo substrate --direction upstream": 0, + "npx gitnexus impact validate_exact_backend_selection --repo substrate --direction upstream": 0, + "cargo test -p shell inventory_contract_tracks_workspace_origin_and_config_defaults -- --nocapture": 0, + "cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture": 0, + "cargo test -p shell validate_exact_backend_selection_bypasses_world_ambiguity_when_backend_matches_exactly -- --nocapture": 0, + "cargo test -p shell validate_member_selection_returns_descriptor_for_unique_world_member -- --nocapture": 0, + "cargo test -p shell dispatch_contract -- --nocapture": 0, + "cargo test -p shell validate_ -- --nocapture": 0, + "cargo fmt --all": 0, + "cargo clippy -p shell --lib --tests -- -D warnings": 0 +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/gitnexus-detect-changes.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/gitnexus-detect-changes.txt new file mode 100644 index 000000000..7a37584e6 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/gitnexus-detect-changes.txt @@ -0,0 +1,6 @@ +Worker-side detect-changes evidence before commit was limited by the stale index and the inability to refresh it (`npx gitnexus analyze` crashed with `free(): invalid pointer`). + +Observed behavior before parent integration: + +- `npx gitnexus detect-changes --repo substrate --scope unstaged` and `--scope staged` returned either `No changes detected` or symbol output polluted by unrelated `AGENTS.md` / `CLAUDE.md` drift. +- After integration, `npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^` produced a usable low-risk compare summary, but it still failed to classify `dispatch_contract.rs` directly because the index was stale. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/handoff-notes.md b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/handoff-notes.md new file mode 100644 index 000000000..efc7163ef --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/handoff-notes.md @@ -0,0 +1,9 @@ +Worker branch: `codex/feat-gateway-mediated-llm-fulfillment-s29-a1-a2-contract-foundation` + +Worker tip: `6be348293ad140690385fbde5463992e084f5470` + +Parent integration: + +- Cherry-picked onto the authoritative branch as `50a450a05f374e46c736f79f9b5c7fec5c0e54d9`. +- No merge conflicts. +- `control.rs` remained unchanged because it already consumed `RuntimeSelectionDescriptor`/state-store outputs rather than owning top-level launch merging. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/impact-analysis-summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/impact-analysis-summary.md new file mode 100644 index 000000000..6ad934136 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/impact-analysis-summary.md @@ -0,0 +1,8 @@ +GitNexus impact was run before editing the affected symbols. + +- `validate_runtime_realizability`: `HIGH`, 29 impacted symbols, 4 affected processes (`handle_agent_command`, `run_async_repl`, `run_shell_with_cli`, `main`). +- `validate_member_selection`: `HIGH`, 11 impacted symbols, 1 affected process (`handle_agent_command`). +- `validate_exact_backend_selection`: `HIGH`, 10 impacted symbols, 1 affected process (`handle_agent_command`). +- `resolve_gateway_backend_inventory_entry`: `LOW`, 4 impacted symbols, 0 affected processes. + +These HIGH-risk seams were reported before edits. The L0 change was then kept inside the frozen hotspot set and validated with focused unit coverage before parent integration. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/owner.txt new file mode 100644 index 000000000..ff0d03824 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/owner.txt @@ -0,0 +1 @@ +worker diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/scope.txt new file mode 100644 index 000000000..e187de20c --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/scope.txt @@ -0,0 +1 @@ +Own the shared dispatch-contract module and immediate inventory/validator consumers only: create the contract vocabulary, make baseline domains explicit, keep policy narrowing fail-closed, and stop before durable-state, public CLI, REPL, docs, or .runs ownership. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/summary.md new file mode 100644 index 000000000..532169d4c --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/summary.md @@ -0,0 +1 @@ +L0 completed on the worker lane. The slice added `dispatch_contract.rs` as the single internal dispatch owner, projected inventory entries into explicit baseline/value origins, resolved persisted host attach truth without inventing a second durable attach object, and moved validator runtime selection through `ResolvedLaunchContract` materialization. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/task.json new file mode 100644 index 000000000..07c198ff3 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L0-a1-a2-contract-foundation/task.json @@ -0,0 +1,10 @@ +{ + "task_id": "L0-a1-a2-contract-foundation", + "status": "completed", + "owner": "worker", + "worker_branch": "codex/feat-gateway-mediated-llm-fulfillment-s29-a1-a2-contract-foundation", + "worker_head_sha": "6be348293ad140690385fbde5463992e084f5470", + "accepted_head_sha": "50a450a05f374e46c736f79f9b5c7fec5c0e54d9", + "started_at": "2026-05-24T18:13:37Z", + "completed_at": "2026-05-24T18:29:42Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/ACCEPTED new file mode 100644 index 000000000..a6413548e --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/ACCEPTED @@ -0,0 +1 @@ +ACCEPTED diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/HEAD_SHA.txt new file mode 100644 index 000000000..6402ae14a --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/HEAD_SHA.txt @@ -0,0 +1 @@ +aa656181aa53c92cefbec1264145c5b071e5e803 diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/changed-files.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/changed-files.txt new file mode 100644 index 000000000..08f0ae98f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/changed-files.txt @@ -0,0 +1,3 @@ +crates/shell/src/execution/agent_runtime/dispatch_contract.rs +crates/shell/src/execution/agent_runtime/orchestration_session.rs +crates/shell/src/execution/agent_runtime/state_store.rs diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/commands.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/commands.txt new file mode 100644 index 000000000..83c8c527f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/commands.txt @@ -0,0 +1,19 @@ +npx gitnexus impact OrchestrationSessionRecord.sync_host_attach_contract --repo substrate --direction upstream +npx gitnexus impact OrchestrationSessionRecord.fork_successor_attach_contract --repo substrate --direction upstream +npx gitnexus impact AgentRuntimeStateStore.resolve_public_control_target --repo substrate --direction upstream +npx gitnexus impact OrchestrationSessionRecord.validate_persisted_invariants --repo substrate --direction upstream +npx gitnexus impact valid_detached_host_continuity_posture --repo substrate --direction upstream +npx gitnexus impact recoverable_stale_host_attachment --repo substrate --direction upstream +cargo fmt --all +cargo fmt --all -- --check +cargo clippy -p shell --lib --tests -- -D warnings +cargo test -p shell new_session_starts_active_attached -- --nocapture +cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture +cargo test -p shell resolve_public_control_target -- --nocapture +cargo test -p shell successor_attach_contract_clears_continuity_and_preserves_launch_truth -- --nocapture +cargo test -p shell host_attach_contract_knob_drift_fails_closed -- --nocapture +cargo test -p shell resolve_public_turn_target_uses_persisted_continuity_truth -- --nocapture +cargo test -p shell load_session_backfills_generalized_attach_contract_defaults_for_legacy_json -- --nocapture +cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture +git diff --check +npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^ diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/deliverable.txt new file mode 100644 index 000000000..7b70c5b3d --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/deliverable.txt @@ -0,0 +1 @@ +Generalized persisted host attach truth landed on the L1 worker branch: session birth now stores launch descriptor, capabilities, and attach knobs inside HostAttachContract; successor copy clears only continuity-specific state; and detached planning consumes persisted attach truth instead of ambient participant truth. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/dependencies.json new file mode 100644 index 000000000..946e89275 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/dependencies.json @@ -0,0 +1,8 @@ +{ + "depends_on": [ + "G1-contract-foundation-accept" + ], + "unblocks": [ + "G2-durable-attach-accept" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/exit-codes.json b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/exit-codes.json new file mode 100644 index 000000000..1527f8775 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/exit-codes.json @@ -0,0 +1,21 @@ +{ + "npx gitnexus impact OrchestrationSessionRecord.sync_host_attach_contract --repo substrate --direction upstream": 0, + "npx gitnexus impact OrchestrationSessionRecord.fork_successor_attach_contract --repo substrate --direction upstream": 0, + "npx gitnexus impact AgentRuntimeStateStore.resolve_public_control_target --repo substrate --direction upstream": 0, + "npx gitnexus impact OrchestrationSessionRecord.validate_persisted_invariants --repo substrate --direction upstream": 0, + "npx gitnexus impact valid_detached_host_continuity_posture --repo substrate --direction upstream": 0, + "npx gitnexus impact recoverable_stale_host_attachment --repo substrate --direction upstream": 0, + "cargo fmt --all": 0, + "cargo fmt --all -- --check": 0, + "cargo clippy -p shell --lib --tests -- -D warnings": 0, + "cargo test -p shell new_session_starts_active_attached -- --nocapture": 0, + "cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture": 0, + "cargo test -p shell resolve_public_control_target -- --nocapture": 0, + "cargo test -p shell successor_attach_contract_clears_continuity_and_preserves_launch_truth -- --nocapture": 0, + "cargo test -p shell host_attach_contract_knob_drift_fails_closed -- --nocapture": 0, + "cargo test -p shell resolve_public_turn_target_uses_persisted_continuity_truth -- --nocapture": 0, + "cargo test -p shell load_session_backfills_generalized_attach_contract_defaults_for_legacy_json -- --nocapture": 0, + "cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture": 0, + "git diff --check": 0, + "npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^": 0 +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/gitnexus-detect-changes.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/gitnexus-detect-changes.txt new file mode 100644 index 000000000..4764aba28 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/gitnexus-detect-changes.txt @@ -0,0 +1,35 @@ +GitNexus warning: + +- `Index for "substrate" was built at /home/azureuser/__Active_Code/atomize-hq/substrate; your cwd (/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-shared-dispatch-contract/a3-durable-attach-freeze) is a sibling clone that is 2 commits ahead of the indexed commit. Results may be stale or incorrect — re-run gitnexus analyze to refresh the index.` + +`npx gitnexus detect-changes --repo substrate --scope compare --base-ref HEAD^` output: + +```text +Changes: 5 files, 47 symbols +Affected processes: 4 +Risk level: medium + +Changed symbols: + undefined GitNexus — Code Intelligence → AGENTS.md + undefined GitNexus — Code Intelligence → CLAUDE.md + undefined from_manifest → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined new → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined transition_state → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined mark_startup_prompt_completed → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined continuity_uaa_session_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined orchestration_session_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined shell_trace_session_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined workspace_root → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined shell_owner_pid → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined state → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined opened_at → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined last_active_at → crates/shell/src/execution/agent_runtime/orchestration_session.rs + undefined orchestrator_agent_id → crates/shell/src/execution/agent_runtime/orchestration_session.rs + ... and 32 more + +Affected execution flows: + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → RuntimeSelectionDescriptor (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → New (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → Transition_state (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session + • Hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session → Bind_active_session_handle (4 steps) — changed: hidden_owner_helper_launch_classifier_accepts_detached_attention_needed_session +``` diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/handoff-notes.md b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/handoff-notes.md new file mode 100644 index 000000000..a8de20000 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/handoff-notes.md @@ -0,0 +1,10 @@ +Worker branch: `codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze` + +Worker tip: `aa656181aa53c92cefbec1264145c5b071e5e803` + +Parent integration: + +- Cherry-picked onto the authoritative branch as `42636f8eb68fe2e817d973a4896f5c35cfc5b12b`. +- No merge conflicts. +- `dispatch_contract.rs` changed only to update the co-located `persisted_attach_contract_is_explicit_baseline_domain` unit-test fixture for the new persisted `HostAttachContract` fields; the shared contract owner and dispatch semantics remained frozen after `G1`. +- The durable-state changes stayed centered on `orchestration_session.rs` and `state_store.rs`, with successor attach truth and detached-posture checks now keyed off persisted contract truth. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/impact-analysis-summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/impact-analysis-summary.md new file mode 100644 index 000000000..bde53b3ee --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/impact-analysis-summary.md @@ -0,0 +1,10 @@ +GitNexus impact was run before the L1 edits. + +- `OrchestrationSessionRecord.sync_host_attach_contract`: `MEDIUM`, `35` impacted symbols, affected processes `start_remote_member_runtime_with_prepared`, `run_async_repl`. +- `OrchestrationSessionRecord.fork_successor_attach_contract`: `LOW`, `3` impacted symbols, affected process `handle_agent_command`. +- `AgentRuntimeStateStore.resolve_public_control_target`: `HIGH`, `19` impacted symbols, affected processes `handle_agent_command`, `resolve_public_control_target_enforces_linux_first_world_posture`. +- `OrchestrationSessionRecord.validate_persisted_invariants`: `LOW`, `3` impacted symbols. +- `valid_detached_host_continuity_posture`: `LOW`, `12` impacted symbols. +- `recoverable_stale_host_attachment`: `LOW`, `3` impacted symbols. + +The `HIGH` result on `resolve_public_control_target` was escalated and recorded in `blocked.json` before edits proceeded. Work resumed only after explicit user direction to continue within the frozen `A3` ownership boundary. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/owner.txt new file mode 100644 index 000000000..ff0d03824 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/owner.txt @@ -0,0 +1 @@ +worker diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/scope.txt new file mode 100644 index 000000000..52c43bfb0 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/scope.txt @@ -0,0 +1 @@ +Own persisted host attach truth and detached-planning state consumption only: generalize HostAttachContract in orchestration_session.rs, move detached continuity checks in state_store.rs onto persisted contract truth, keep migration additive and fail-closed, and do not reopen the L0 shared contract owner or public caller surfaces. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/summary.md new file mode 100644 index 000000000..e24168ecc --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/summary.md @@ -0,0 +1 @@ +L1 completed on the worker lane. HostAttachContract now persists generalized launch truth, capabilities, and attach knobs; successor copies preserve launch truth while clearing only the continuity selector; and state-store detached attach planning now consumes persisted contract truth instead of ambient participant truth. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/task.json new file mode 100644 index 000000000..617b21310 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L1-a3-durable-attach-freeze/task.json @@ -0,0 +1,10 @@ +{ + "task_id": "L1-a3-durable-attach-freeze", + "status": "completed", + "owner": "worker", + "worker_branch": "codex/feat-gateway-mediated-llm-fulfillment-s29-a3-durable-attach-freeze", + "worker_head_sha": "aa656181aa53c92cefbec1264145c5b071e5e803", + "accepted_head_sha": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "started_at": "2026-05-24T18:35:32Z", + "completed_at": "2026-05-24T18:50:21Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/commands.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/commands.txt new file mode 100644 index 000000000..cf1c419ac --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/commands.txt @@ -0,0 +1,8 @@ +TMPDIR=/mnt/localssd/tmp CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target cargo test -p shell public_turn_prompt_requests_require_exact_session_and_backend_contract -- --nocapture +TMPDIR=/mnt/localssd/tmp CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target cargo test -p shell public_turn_resumes_parked_host_session_and_preserves_exact_session_selector_contracts --test agent_public_control_surface_v1 -- --nocapture +TMPDIR=/mnt/localssd/tmp CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target cargo test -p shell public_turn_uses_persisted_attach_continuity_selector_when_recovering_detached_host_turns --test agent_public_control_surface_v1 -- --nocapture +TMPDIR=/mnt/localssd/tmp CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target cargo test -p shell public_start_survives_slow_startup_prompt_completion_after_bootstrap_readiness --test agent_public_control_surface_v1 -- --nocapture +TMPDIR=/mnt/localssd/tmp CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target cargo test -p shell public_same_session_parked_status_turn_reattach_and_stop_stay_on_one_orchestration_session_id --test agent_public_control_surface_v1 -- --nocapture +TMPDIR=/mnt/localssd/tmp CARGO_TARGET_DIR=/mnt/localssd/tmp/substrate-parent-a4-target cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo fmt --all +cargo fmt --all -- --check diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/deliverable.txt new file mode 100644 index 000000000..db4684dba --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/deliverable.txt @@ -0,0 +1 @@ +Adopt the frozen shared dispatch semantics in the human caller surfaces so start/reattach/fork flows resolve through inventory-backed or persisted-attach-backed truth without reopening the contract or durable-state owners. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/dependencies.json new file mode 100644 index 000000000..471103531 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/dependencies.json @@ -0,0 +1,8 @@ +{ + "depends_on": [ + "G2-durable-attach-accept" + ], + "unblocks": [ + "G3-parallel-window-accept" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/exit-codes.json b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/exit-codes.json new file mode 100644 index 000000000..7a7d065bb --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/exit-codes.json @@ -0,0 +1,10 @@ +{ + "public_turn_prompt_requests_require_exact_session_and_backend_contract": 0, + "public_turn_resumes_parked_host_session_and_preserves_exact_session_selector_contracts": 0, + "public_turn_uses_persisted_attach_continuity_selector_when_recovering_detached_host_turns": 0, + "public_start_survives_slow_startup_prompt_completion_after_bootstrap_readiness": 0, + "public_same_session_parked_status_turn_reattach_and_stop_stay_on_one_orchestration_session_id": 101, + "agent_public_control_surface_v1_full_suite": 101, + "cargo_fmt_all": 0, + "cargo_fmt_all_check": 0 +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/owner.txt new file mode 100644 index 000000000..ff0d03824 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/owner.txt @@ -0,0 +1 @@ +worker diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/scope.txt new file mode 100644 index 000000000..4cc923a9b --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/scope.txt @@ -0,0 +1 @@ +Own agents_cmd.rs, agent_runtime/control.rs, prompt_fulfillment.rs, agent_public_control_surface_v1.rs, and minimal directly related supporting tests only. Do not reopen dispatch_contract.rs, inventory/validator ownership, durable-state files, REPL files, docs, or .runs. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/summary.md new file mode 100644 index 000000000..df447ef25 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/summary.md @@ -0,0 +1,5 @@ +# L2 Summary + +- `agents_cmd.rs`, `control.rs`, and `agent_public_control_surface_v1.rs` adoption changes were integrated on the authoritative branch as `d5323945`. +- The final same-session parked-turn repair landed later through the LOW-risk `async_repl.rs` continuity seam, not through any additional L2-owned caller-surface change. +- The final accepted tree at `0d15fb2fe8902a9201c891eccd3d7a20325f9d72` preserves the exact shared contract semantics on the public/human caller plane. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/task.json new file mode 100644 index 000000000..45e00e064 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L2-a4-human-caller-adoption/task.json @@ -0,0 +1,12 @@ +{ + "task_id": "L2-a4-human-caller-adoption", + "status": "completed", + "owner": "parent", + "worker_branch": "codex/feat-gateway-mediated-llm-fulfillment-s29-a4-human-caller-adoption", + "worker_agent_id": null, + "worker_nickname": null, + "base_head_sha": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "started_at": "2026-05-24T18:57:05Z", + "completed_at": "2026-05-24T20:47:10Z", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/deliverable.txt new file mode 100644 index 000000000..32cc743cf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/deliverable.txt @@ -0,0 +1 @@ +Adopt the frozen shared dispatch semantics in orchestrator-controlled REPL routing without reopening the contract or durable-state owners, and touch world_ops.rs only if a concrete additive transport field is proven necessary. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/dependencies.json new file mode 100644 index 000000000..471103531 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/dependencies.json @@ -0,0 +1,8 @@ +{ + "depends_on": [ + "G2-durable-attach-accept" + ], + "unblocks": [ + "G3-parallel-window-accept" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/owner.txt new file mode 100644 index 000000000..ff0d03824 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/owner.txt @@ -0,0 +1 @@ +worker diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/scope.txt new file mode 100644 index 000000000..e8c02ad6e --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/scope.txt @@ -0,0 +1 @@ +Own repl/async_repl.rs, repl_world_first_routing_v1.rs, and world_ops.rs only if an additive transport field is concretely required. Do not reopen dispatch_contract.rs, control.rs, agents_cmd.rs, durable-state files, docs, or .runs. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/summary.md new file mode 100644 index 000000000..5da2884bf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/summary.md @@ -0,0 +1,5 @@ +# L3 Summary + +- The first low-risk `async_repl.rs` helper-closeout changes were integrated as `c2721ed5`, which cleared detached-turn continuity failures. +- The remaining same-session parked-turn race was then resolved through the LOW-risk `can_park_host_runtime_after_detach` seam and committed in the final accepted implementation tree `0d15fb2fe8902a9201c891eccd3d7a20325f9d72`. +- The previously escalated HIGH-risk `dispatch_targeted_follow_up_turn` seam remained untouched; the final repair came from recognizing completed one-turn handoff truth even when the persisted session row lags. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/task.json new file mode 100644 index 000000000..578c0a6c4 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L3-a5-repl-dispatch-adoption/task.json @@ -0,0 +1,12 @@ +{ + "task_id": "L3-a5-repl-dispatch-adoption", + "status": "completed", + "owner": "parent", + "worker_branch": "codex/feat-gateway-mediated-llm-fulfillment-s29-a5-repl-dispatch-adoption", + "worker_agent_id": "019e5b5c-5f2c-7e71-95ba-953a8ccb58de", + "worker_nickname": "Lovelace", + "base_head_sha": "42636f8eb68fe2e817d973a4896f5c35cfc5b12b", + "started_at": "2026-05-24T18:57:05Z", + "completed_at": "2026-05-24T20:47:10Z", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/summary.md new file mode 100644 index 000000000..4618e1007 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/summary.md @@ -0,0 +1 @@ +Parent updated the allowed truth surfaces in `ADR-0027` and `llm-last-mile/{29,30,31,README}.md` so they match the merged slice-29 runtime: one shared dispatch-contract owner, explicit baseline domains, persisted `HostAttachContract` launch truth, and caller parity. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/task.json new file mode 100644 index 000000000..a55bc81ce --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/L4-a6-docs-truth-sync/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "L4-a6-docs-truth-sync", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "started_at": "2026-05-24T20:35:44Z", + "completed_at": "2026-05-24T20:47:10Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/ACCEPTED b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/ACCEPTED new file mode 100644 index 000000000..377cefa1a --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/ACCEPTED @@ -0,0 +1 @@ +accepted diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/HEAD_SHA.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/HEAD_SHA.txt new file mode 100644 index 000000000..07a212251 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/HEAD_SHA.txt @@ -0,0 +1 @@ +4c61ab779752a9185c6b7558275d7fdb5880893c diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/changed-files.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/changed-files.txt new file mode 100644 index 000000000..ac64da6cd --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/changed-files.txt @@ -0,0 +1,14 @@ +.runs/slice-29-shared-dispatch-contract/run-state.json +.runs/slice-29-shared-dispatch-contract/tasks.json +.runs/slice-29-shared-dispatch-contract/source-lock.json +.runs/slice-29-shared-dispatch-contract/contract-freeze.json +.runs/slice-29-shared-dispatch-contract/durable-state-freeze.json +.runs/slice-29-shared-dispatch-contract/branch-map.json +.runs/slice-29-shared-dispatch-contract/lane-ownership.json +.runs/slice-29-shared-dispatch-contract/merge-order.json +.runs/slice-29-shared-dispatch-contract/validation-wall.md +.runs/slice-29-shared-dispatch-contract/session-log.md +.runs/slice-29-shared-dispatch-contract/final-summary.md +.runs/slice-29-shared-dispatch-contract/sentinels/RUN_OPEN +.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/* +.runs/slice-29-shared-dispatch-contract/gates/G0-run-freeze/* diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/commands.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/commands.txt new file mode 100644 index 000000000..1b0ea17a3 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/commands.txt @@ -0,0 +1,6 @@ +git status --short --branch -- PLAN.md ORCH_PLAN.md +git rev-parse HEAD +sha256sum PLAN.md +git worktree list --porcelain +npx gitnexus status +npx gitnexus analyze diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/deliverable.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/deliverable.txt new file mode 100644 index 000000000..ada794bca --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/deliverable.txt @@ -0,0 +1 @@ +Written P0 run-state artifacts, passed G0 evidence, and a clean authorization boundary for opening the L0 worktree. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/dependencies.json b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/dependencies.json new file mode 100644 index 000000000..9eb629f37 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/dependencies.json @@ -0,0 +1,6 @@ +{ + "depends_on": [], + "unblocks": [ + "L0-a1-a2-contract-foundation" + ] +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/exit-codes.json b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/exit-codes.json new file mode 100644 index 000000000..ce5ab0021 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/exit-codes.json @@ -0,0 +1,8 @@ +{ + "git status --short --branch -- PLAN.md ORCH_PLAN.md": 0, + "git rev-parse HEAD": 0, + "sha256sum PLAN.md": 0, + "git worktree list --porcelain": 0, + "npx gitnexus status": 0, + "npx gitnexus analyze": 1 +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/gitnexus-detect-changes.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/gitnexus-detect-changes.txt new file mode 100644 index 000000000..ca1784e18 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/gitnexus-detect-changes.txt @@ -0,0 +1 @@ +Not run in P0. This task did not edit code symbols and has no worker handoff. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/handoff-notes.md b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/handoff-notes.md new file mode 100644 index 000000000..467220282 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/handoff-notes.md @@ -0,0 +1 @@ +Parent-only task. No worker branch or merge was involved. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/impact-analysis-summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/impact-analysis-summary.md new file mode 100644 index 000000000..c4ee9bfda --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/impact-analysis-summary.md @@ -0,0 +1,7 @@ +No code symbols were edited during `P0`; this task only initialized parent-owned `.runs/**` artifacts. + +GitNexus refresh attempt note: + +- `npx gitnexus status` reported the index as stale. +- `npx gitnexus analyze` was attempted before any future symbol edits and failed with `free(): invalid pointer`. +- The failure is recorded as environment evidence, not ignored. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/owner.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/owner.txt new file mode 100644 index 000000000..9676f5bfe --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/owner.txt @@ -0,0 +1 @@ +parent diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/scope.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/scope.txt new file mode 100644 index 000000000..af56ee2e2 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/scope.txt @@ -0,0 +1 @@ +Source-lock the live PLAN.md, freeze the slice-29 contract and durable-state vocabulary, initialize parent-owned .runs state, and authorize only L0. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/summary.md new file mode 100644 index 000000000..b139ab22d --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/summary.md @@ -0,0 +1 @@ +P0 completed successfully. The parent locked the live PLAN.md bytes, froze the contract vocabulary and durable-state rules, initialized the slice-29 run-state tree, recorded the GitNexus refresh failure, and passed G0 without opening any slice-29 worker worktree. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/task.json new file mode 100644 index 000000000..201c1e16f --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P0-parent-freeze/task.json @@ -0,0 +1,7 @@ +{ + "task_id": "P0-parent-freeze", + "status": "completed", + "owner": "parent", + "started_at": "2026-05-24T18:13:37Z", + "completed_at": "2026-05-24T18:13:37Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/gitnexus-detect-changes.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/gitnexus-detect-changes.txt new file mode 100644 index 000000000..5c66612ee --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/gitnexus-detect-changes.txt @@ -0,0 +1,21 @@ +Changes: 7 files, 37 symbols +Affected processes: 0 +Risk level: low + +Changed symbols: + undefined apply_parked_host_runtime_snapshots → crates/shell/src/repl/async_repl.rs + undefined shell_owner_pid_is_alive → crates/shell/src/repl/async_repl.rs + undefined start_member_runtime_reuses_parent_session_and_persists_world_binding → crates/shell/src/repl/async_repl.rs + undefined prepare_member_replacement_runtime_preserves_resumed_from_lineage → crates/shell/src/repl/async_repl.rs + undefined tests → crates/shell/src/repl/async_repl.rs + undefined orchestration_session_manifest_with_options → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined write_json_file → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined repo_root → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined read_repo_file → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined read_production_shell_source_without_inline_tests → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined agent_status_json_surfaces_parked_resumable_fields_from_parent_session_truth → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined agent_status_json_surfaces_awaiting_attention_fields_from_parent_session_truth → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined agent_doctor_does_not_treat_trace_records_as_control_plane_authorization → crates/shell/tests/agent_successor_contract_ahcsitc0.rs + undefined ADR-0027 — LLM + Agent Config/Policy Surface (Existing Files, New Keys) → docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md + undefined User Contract (Authoritative) → docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md + ... and 22 more diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/summary.md new file mode 100644 index 000000000..e9b97ee2c --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/summary.md @@ -0,0 +1 @@ +P1 passed on accepted tip `0d15fb2fe8902a9201c891eccd3d7a20325f9d72`. All locked commands from `ORCH_PLAN.md` passed, and GitNexus `detect-changes` reported a low-risk staged implementation scope with no affected processes. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/task.json new file mode 100644 index 000000000..5ac232508 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P1-a7-validation-wall/task.json @@ -0,0 +1,8 @@ +{ + "task_id": "P1-a7-validation-wall", + "status": "completed", + "owner": "parent", + "accepted_head_sha": "0d15fb2fe8902a9201c891eccd3d7a20325f9d72", + "started_at": "2026-05-24T20:35:44Z", + "completed_at": "2026-05-24T20:47:10Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/status.txt b/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/status.txt new file mode 100644 index 000000000..6ab9fedbf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/status.txt @@ -0,0 +1 @@ +completed diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/summary.md b/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/summary.md new file mode 100644 index 000000000..e7f3cd2bf --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/summary.md @@ -0,0 +1 @@ +Parent-only closeout completed: final summary, validation wall, task/gate state, accepted-tip records, and `RUN_COMPLETE` were written under `.runs/slice-29-shared-dispatch-contract/`. diff --git a/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/task.json b/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/task.json new file mode 100644 index 000000000..26cf7237a --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/tasks/P2-parent-closeout/task.json @@ -0,0 +1,7 @@ +{ + "task_id": "P2-parent-closeout", + "status": "completed", + "owner": "parent", + "started_at": "2026-05-24T20:47:10Z", + "completed_at": "2026-05-24T20:47:10Z" +} diff --git a/.runs/slice-29-shared-dispatch-contract/validation-wall.md b/.runs/slice-29-shared-dispatch-contract/validation-wall.md new file mode 100644 index 000000000..5d98bbfc0 --- /dev/null +++ b/.runs/slice-29-shared-dispatch-contract/validation-wall.md @@ -0,0 +1,45 @@ +# Validation Wall + +Locked merged-tree commands from `ORCH_PLAN.md`: + +```bash +cargo test -p shell resolve_public_control_target -- --nocapture +cargo test -p shell public_turn_prompt_requests_require_exact_session_and_backend_contract -- --nocapture +cargo test -p shell new_session_starts_active_attached -- --nocapture +cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace -- --nocapture +``` + +Additional parent proof obligations: + +- final merged-tree GitNexus `detect-changes` transcription +- contract/domain parity audit against `PLAN.md` +- docs and `llm-last-mile/` truth-sync audit against merged runtime behavior + +## Result + +Accepted authoritative implementation/docs/test tip: + +- `0d15fb2fe8902a9201c891eccd3d7a20325f9d72` + +Locked command results on that accepted tree: + +- `cargo test -p shell resolve_public_control_target -- --nocapture` passed +- `cargo test -p shell public_turn_prompt_requests_require_exact_session_and_backend_contract -- --nocapture` passed +- `cargo test -p shell new_session_starts_active_attached -- --nocapture` passed +- `cargo test -p shell detached_postures_enforce_pending_inbox_truth -- --nocapture` passed +- `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` passed +- `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` passed +- `cargo fmt --all -- --check` passed +- `cargo clippy --workspace --all-targets -- -D warnings` passed +- `cargo test --workspace -- --nocapture` passed + +Additional proof obligations: + +- GitNexus `detect-changes` on the staged implementation tree reported `7 files`, `37 symbols`, `0 affected processes`, `risk level: low` +- Contract/domain parity audit passed: the merged runtime keeps `dispatch_contract.rs` as the sole internal contract owner, makes both baseline domains explicit (`InventoryLaunch`, `PersistedHostAttach`), preserves generalized launch truth in `HostAttachContract`, and keeps human/public + orchestrator-controlled dispatch on the same semantics +- Docs truth-sync audit passed for the allowed surfaces in `docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md` and `llm-last-mile/{29,30,31,README}.md` diff --git a/AGENTS.md b/AGENTS.md index 4b1600db3..1d9dc3421 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ cargo bench # exercise hotspots when touching pe # GitNexus — Code Intelligence -This project is indexed by GitNexus as **substrate** (49108 symbols, 75625 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **substrate** (49740 symbols, 77046 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/AGENT_ORCHESTRATION_GAP_MATRIX.md b/AGENT_ORCHESTRATION_GAP_MATRIX.md index 9728719b0..d41a47d74 100644 --- a/AGENT_ORCHESTRATION_GAP_MATRIX.md +++ b/AGENT_ORCHESTRATION_GAP_MATRIX.md @@ -74,7 +74,7 @@ Terminology rule for this repository: ## Current State Summary - This is no longer modeling-only. - - `crates/shell` now depends on `unified-agent-api` (`agent_api`) and the REPL boots a shell-owned UAA runtime in `crates/shell/src/repl/async_repl.rs`. + - `crates/shell` now depends on `unified-agent-api` (`agent_api`) and the REPL boots a shell-owned orchestrator runtime in `crates/shell/src/repl/async_repl.rs`. - Live orchestration and participant state is persisted by `AgentRuntimeStateStore` under `~/.substrate/run/agent-hub/sessions//...`, with compatibility snapshots under `~/.substrate/run/agent-hub/{sessions,participants}`. - Host orchestration sessions now persist explicit durable posture and attachment truth: `posture`, `attached_participant_id`, `pending_inbox_count`, and per-session durable inbox artifacts under `sessions//inbox/.json`. - That durable inbox state is intentionally narrow: persistence, posture normalization into `awaiting_attention`, internal ack/dismiss support, and dev-support/test ingress exist, but no public inbox command surface or automatic resume workflow is shipped. @@ -82,12 +82,13 @@ Terminology rule for this repository: - Persistent world sessions now support explicit shared-world owner binding via `SharedWorldOwnerSpec` / `SharedWorldBindingSnapshot`, and the shell invalidates stale world members when `world_generation` rolls forward. - Canonical `AgentEvent` production is live for the REPL-owned orchestrator/member runtime, and `substrate agent status` now prefers live runtime state with trace fallback while surfacing parked and attention-needed durable host sessions on the read path. - The narrow public control plane is now live under `substrate agent start|turn|reattach|fork|stop`, with exact `--backend ` / `--session ` selectors, one-of prompt-source validation for `start` and `turn`, helper-owned streaming NDJSON for prompt-taking calls, authoritative completion-time `session_posture`, hidden-owner launch handoff, exact-session `reattach` recovery to durable attached truth, owner-transport stop routing as the canonical closeout path for attached and parked durable sessions, and explicit `Completed`/`Failed` terminal delivery after `Accepted`. + - Prompt-bearing host and world-member fulfillment now enter the gateway-owned adapter seam through `crates/shell/src/execution/prompt_fulfillment.rs`, `crates/world-service/src/prompt_fulfillment.rs`, and `crates/gateway/src/adapter_runtime.rs` rather than duplicating direct backend registration in shell/world prompt-taking code. - Recent validation tightened several earlier suspected gaps: - Linux shared-world replacement ordering and `session.json` durability are already landed in the current backend. - Linux world-scoped member runtime placement over the existing host↔world execute-stream transport is now landed, including authoritative world binding validation, replacement-member relaunch, fail-closed startup semantics, and retained live-member coexistence for distinct backend ids such as `cli:codex` and `cli:claude_code` within one orchestration session / world generation. - The selected-follow-up contract is now explicit and regression-proven for the Linux-first / REPL-first surface: exact `:: ` targeting stays fail-closed, host follow-up turns resume only the active orchestrator backend, same-generation world follow-up turns reuse the exact retained member, and stale world state relaunches the exact backend slot before typed `/v1/member_turn/stream` submit. - The broad PID-based orchestration-stamping concern is mostly retired from production event emission; the remaining authority cleanup is narrower. -- The gateway runtime is also real for nested LLM/gateway lifecycle work, but it is still a separate runtime path from pure-agent orchestration. +- The gateway lifecycle runtime remains separately visible for nested `status|sync|restart` work, but prompt-bearing pure-agent orchestration now shares the same gateway-owned adapter fulfillment seam. - The main remaining gaps are: - `substrate -c` is still shell wrap mode rather than an agent-prompt caller surface, - the shipped public control plane is intentionally narrow: root `start` is host-only, existing-session prompt-taking accepts only exact `orchestration_session_id` plus exact `backend_id`, there is still no default-agent routing, and there is still no public world-root start, @@ -102,7 +103,7 @@ Terminology rule for this repository: | Area | Status | What exists now | What is missing for the intended v1 path | |---|---|---|---| | Product intent | `Now explicit` | This file now records the intended v1 and later product shape | Keep downstream docs aligned to this statement | -| Unified Agent API adoption | `Landed for host orchestrator and supported Linux/macOS world member runtime` | `crates/shell/Cargo.toml` now pulls `unified-agent-api`, `agent_runtime/registry.rs` registers Codex / Claude Code backends, the REPL keeps the host orchestrator under attached control, and supported world-scoped members now launch through `world-service` over the existing host↔world transport on Linux and the macOS/Lima forwarded path | Remaining v1 work is no longer basic UAA runtime adoption; it is on operator-facing controls, status resilience, Windows/WSL posture, and broader caller-surface productization | +| Unified Agent API adoption | `Landed for host orchestrator and supported Linux/macOS world member runtime` | `crates/shell/Cargo.toml` now pulls `unified-agent-api`, gateway-owned backend registration is funneled through `crates/gateway/src/adapter_runtime.rs`, shell/world prompt-bearing call sites delegate through the `prompt_fulfillment` bridges, and supported world-scoped members still launch through `world-service` over the existing host↔world transport on Linux and the macOS/Lima forwarded path | Remaining v1 work is no longer basic UAA runtime adoption; it is on operator-facing controls, status resilience, Windows/WSL posture, and broader caller-surface productization | | UAA vs local `agent-api-*` boundary | `Partially clarified` | Code now distinguishes shell-owned UAA runtime from local host/world transport; `PURE_AGENT_PROTOCOL` comments explicitly say `substrate.agent.session` is a Substrate-local normalized label, not an upstream protocol claim | Local crates are still named `agent-api-*`, and the `substrate.agent.session` label still exists, so naming confusion has been reduced but not removed | | Agent config and inventory | `Landed` | Inventory schema, capability gates, derived `backend_id`, scope resolution, and backend selection are live in `agent_inventory.rs` and `agent_runtime/validator.rs` | No major v1 inventory gap remains; future work is mostly on runtime breadth rather than selection modeling | | Agent CLI inspection surface | `Landed for inspection plus narrow public control` | `substrate agent list|status|doctor|toolbox status|env` is live, `agent status` now merges live runtime-state snapshots with trace fallback and surfaces durable `parked_resumable` / `awaiting_attention` host sessions, and the namespace now exposes first-class `start|turn|reattach|fork|stop` actions with exact selectors plus one-of prompt-source validation for `start` and `turn` | Remaining gaps are broader productization only: there is still no default-agent routing, no member-level public selector surface beyond exact `(orchestration_session_id, backend_id)`, and no public world-root start | diff --git a/CLAUDE.md b/CLAUDE.md index 2abe38f42..61e2d3c5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **substrate** (49108 symbols, 75625 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **substrate** (49740 symbols, 77046 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/Cargo.lock b/Cargo.lock index 0659f5263..1c0202f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5133,6 +5133,7 @@ dependencies = [ "signal-hook", "substrate-broker", "substrate-common", + "substrate-gateway", "substrate-graph", "substrate-replay", "substrate-trace", @@ -5144,6 +5145,8 @@ dependencies = [ "transport-api-client", "transport-api-types", "unified-agent-api", + "unified-agent-api-claude-code", + "unified-agent-api-codex", "url", "uuid", "which 6.0.3", @@ -5427,6 +5430,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "unified-agent-api", "url", "uuid", ] @@ -7319,6 +7323,7 @@ dependencies = [ "sha2", "substrate-broker", "substrate-common", + "substrate-gateway", "tempfile", "thiserror 1.0.69", "tokio", diff --git a/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md b/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md index da4e49d38..87cdedca3 100644 --- a/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md +++ b/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md @@ -169,6 +169,24 @@ It is not intended to: If `reattach` reports success, the intended truth is that attached ownership was actually restored. +## Meaning Of `agent fork` + +`substrate agent fork --session ` is successor allocation only. + +It is intended to: + +- allocate a new durable orchestration session that points back to the exact source session lineage +- copy the source session's persisted host attach-contract shape +- clear any inherited backend-native continuity selector from the successor +- return the successor honestly as `parked_resumable` with no attached owner loop + +It is not intended to: + +- submit a prompt +- attach a live owner loop immediately +- borrow the parent's continuity token +- report false `active_attached` truth for the successor session + ## Meaning Of `agent stop` `substrate agent stop --session ` is the explicit shutdown action for the durable orchestration session. @@ -208,6 +226,7 @@ The current public durable-session contract is: - `substrate agent turn --session ... --backend ... --prompt ...` is prompt-taking follow-up on the same durable orchestration session - `substrate agent reattach --session ...` is attached-owner recovery only for that same durable orchestration session +- `substrate agent fork --session ...` is successor durable-session allocation only and returns a parked unattached successor - `substrate agent stop --session ...` is the canonical closeout path for attached and parked durable host sessions - `substrate agent status --json` is the authoritative parked-session read surface for live-runtime durable-session posture truth - detached-world follow-up remains fail-closed until `reattach` restores an active host owner @@ -222,10 +241,11 @@ The intended final shape is: 4. `parked_resumable` means the session is idle/detached but still alive, routable, resumable, and still Substrate-owned for orchestration responsibilities. 5. `turn` resumes prompt-taking against that same durable session. 6. `reattach` restores actual attached host ownership for that same durable session without submitting a prompt. -7. `stop` cleanly stops that same durable session. -8. `status` shows that same durable session even while parked. -9. Durable inbox state remains narrow: persistence exists, `awaiting_attention` posture normalization exists, internal ack/dismiss support exists, and no public inbox workflow is implied. -10. Detached-world follow-up remains fail-closed until `reattach` restores an active host owner. +7. `fork` allocates a successor durable session without submitting a prompt and returns that successor as `parked_resumable` with no attached owner loop. +8. `stop` cleanly stops that same durable session. +9. `status` shows that same durable session even while parked. +10. Durable inbox state remains narrow: persistence exists, `awaiting_attention` posture normalization exists, internal ack/dismiss support exists, and no public inbox workflow is implied. +11. Detached-world follow-up remains fail-closed until `reattach` restores an active host owner. ## Non-Negotiable Truths diff --git a/ORCH_PLAN.md b/ORCH_PLAN.md index 6842f815e..7e01c0833 100644 --- a/ORCH_PLAN.md +++ b/ORCH_PLAN.md @@ -1,959 +1,837 @@ -# ORCH_PLAN: UAA Boundary And Naming Cleanup Execution Controller - -Authoritative plan source: [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md) -Source SOW: [27-uaa-boundary-and-naming-cleanup.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/27-uaa-boundary-and-naming-cleanup.md) -Style reference: [ORCH_PLAN-25.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/ORCH_PLAN-25.md) -Current workspace branch: `chore/uaa-boundary-and-naming-cleanup` -Execution type: direct-cutover naming correction, parent-frozen contract, one serialized foundation code lane, two follow-on parallel lanes, one parent-only validation wall -Live repo root: `/Users/spensermcconnell/__Active_Code/atomize-hq/substrate` -Fresh worktree root: `/Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup` -Run id: `plan-uaa-boundary-and-naming-cleanup` -Worker model: `GPT-5.4` with `reasoning_effort=high` -Initial concurrent worker cap: `1` -Peak concurrent worker cap: `2` -Parent role: sole integrator, sole approval authority, sole writer of `.runs/**` artifacts +# ORCH_PLAN: Shared Dispatch Contract Closeout And Parity Hardening -## Summary +Authoritative plan source: [PLAN.md](PLAN.md) +Controller file: [ORCH_PLAN.md](ORCH_PLAN.md) +Current branch: `feat/gateway-mediated-llm-fulfillment` +Authoritative checkout: `/home/azureuser/__Active_Code/atomize-hq/substrate` +Worktree root: `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-5-shared-dispatch-closeout` +Run id: `slice-29-5-shared-dispatch-closeout-parity-hardening` -This controller executes [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md). It is not a restatement of the plan. +## Execution Summary -The run shape is frozen: +This file is the execution controller for the current dirty working-tree [PLAN.md](PLAN.md). It is not a summary of the plan. It tells the parent exactly how to run the slice. -1. Parent freezes the rename contract, the live-surface grep wall, the historical allowlist, and the worker ownership map. -2. One foundation code lane lands the whole shared-hotspot cutover in order: - - `uaa.agent.session` -> `substrate.agent.session` - - `world-agent*` -> `world-service*` - - `agent-api*` -> `transport-api*` -3. Only after the foundation lane is accepted does the parent open the parallel window: - - scripts, CI, release, and operator helper lane - - docs, ADR, and repo-truth lane -4. Parent integrates the parallel lanes, runs the full validation wall from [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md), and decides final acceptance. +Operator facts: -This run is honest only if the merged tree proves all of the following together: +| Item | Value | +| --- | --- | +| Current branch | `feat/gateway-mediated-llm-fulfillment` | +| Parent role | sole integrator, sole gate owner, sole writer of `.runs/**`, sole final acceptance authority | +| Worker model | `GPT-5.4 high` | +| Authoritative checkout | `/home/azureuser/__Active_Code/atomize-hq/substrate` | +| Worker worktree root | `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-5-shared-dispatch-closeout` | +| Concurrency cap | `2` total live worker lanes | +| Exact safe parallel window | only after `G3`: `L4 retained member parity` plus `L4T frozen-surface regression tests` | +| Integration owner | parent only | +| Gate owner | parent only | +| `.runs/**` owner | parent only | + +Execution shape: + +1. `P0` parent freeze and source lock. +2. `T1/L1` durable attach truth first. +3. `G1` parent integration gate. +4. `T2/L2` policy overlay merge second. +5. `G2` parent integration gate. +6. `T3/L3` capability override closeout third. +7. `G3` parent integration gate and hotspot freeze. +8. `T4/L4` retained member parity plus `T4T/L4T` frozen-surface regression coverage in the only safe parallel window. +9. `G4` parent integration gate for the parallel window. +10. `T5/L5` docs truth sync last. +11. `G5` parent docs gate. +12. `P1` parent validation wall. +13. `G6` parent final acceptance. +14. `P2` parent closeout. + +Integration and gate ownership: + +| Step | Owner | Output | +| --- | --- | --- | +| `P0` | parent | source lock, lane map, branch map, hotspot freeze | +| `T1` | worker `L1` | Phase 1 code + handoff | +| `G1` | parent | integrated accepted tip for Phase 1 | +| `T2` | worker `L2` | Phase 2 code + handoff | +| `G2` | parent | integrated accepted tip for Phase 2 | +| `T3` | worker `L3` | Phase 3 code + handoff | +| `G3` | parent | integrated accepted tip for Phase 3 and shared-vocabulary freeze | +| `T4` | worker `L4` | retained parity code + parity tests | +| `T4T` | worker `L4T` | frozen-surface regression test additions only | +| `G4` | parent | integrated accepted tip for the only parallel window | +| `T5` | worker `L5` | docs truth sync | +| `G5` | parent | accepted docs tip | +| `P1` | parent | merged-tree validation wall | +| `G6` | parent | final acceptance decision | +| `P2` | parent | final run artifacts and completion sentinels | -1. upstream `agent_api`, adopted `agent_api.*`, `uaa_session_id`, and unchanged `world-api` remain intact, -2. `substrate.agent.session` is the only canonical supported local protocol-family label on live surfaces, -3. the local daemon family is `world-service*` everywhere live, -4. the local typed host↔world contract family is `transport-api*` everywhere live, -5. world-required routing still fails closed if renamed service discovery breaks, -6. the live-surface grep wall is green outside the explicit historical allowlist, -7. code, scripts, CI, release bundles, docs, ADRs, fixtures, tests, and operator guidance tell one coherent boundary story. +## Summary -## Hard Guards +This run is complete only when the merged tree proves one truthful contract floor across the current slice: -These are run-stopping invariants. +1. durable attach truth is persisted from resolved launch truth; +2. inventory `policy_overlay` is merged into actual `effective_policy`; +3. bounded capability override narrowing works for the approved family and fails closed for all other fields; +4. retained member follow-up turns consume a shared-contract-derived subset instead of a hidden second dialect; +5. docs, tests, and final validation agree on the same shipped semantics. -1. This slice is a naming correction and boundary cleanup only. It is not a runtime redesign, not a public contract redesign, and not a compatibility-migration project. -2. Upstream `agent_api` and adopted `agent_api.*` ids remain untouched. -3. `world-api` remains untouched. -4. `uaa_session_id` and `internal.uaa_session_id` remain untouched. -5. `substrate.agent.session` is the only canonical supported local protocol-family label after cutover. -6. Old supported live names do not remain as aliases on canonical live surfaces just to avoid repo work. -7. Stale `uaa.agent.session` configs, fixtures, or persisted runtime rows fail closed with explicit operator-readable errors after cutover. -8. World-required routing must still hard-fail if renamed binary, socket, or unit discovery breaks. No rename drift may silently fall back to host execution. -9. No parallel worker may touch shared foundation hotspots before the foundation lane is accepted: - - [Cargo.toml](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/Cargo.toml) - - `dist-workspace.toml` - - `crates/shell/**` - - mixed-boundary imports that mention upstream `agent_api` plus local transport or service crates - - local protocol validation and persistence seams -10. Parent is the only integrator, the only gate authority, and the only writer of `/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/.runs/plan-uaa-boundary-and-naming-cleanup/**`. -11. Workers do not edit [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md), [ORCH_PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/ORCH_PLAN.md), or `.runs/**`. -12. Every symbol edit requires prior GitNexus impact analysis. Any `HIGH` or `CRITICAL` result is a parent-only escalation point. -13. Every worker handoff must include `gitnexus_detect_changes()` status before the parent considers merge. -14. Parent runs a final `gitnexus_detect_changes()` on the merged tree before closeout. +## Hard Guards -Stop the run and write `blocked.json` if any of these become true: +1. The parent is the sole integrator, sole gate owner, sole writer of `.runs/**`, and sole final acceptance authority. +2. Workers must not edit `.runs/**`, `PLAN.md`, or `ORCH_PLAN.md`. +3. The authoritative checkout is already dirty because `PLAN.md` is modified; this run must not assume a clean tree and must not revert or normalize that drift. +4. The authoritative plan text is the current working-tree contents of `PLAN.md`, not the committed copy that may exist in clean worker worktrees. +5. Worker worktrees branch only from committed accepted gate tips; source-lock state stays in the dirty authoritative checkout. +6. `dispatch_contract.rs` is serialized through `T1`, `T2`, and `T3`; after `G3` it is frozen for the remainder of the run. +7. `orchestration_session.rs` is Phase-1-only and frozen after `G1`. +8. `policy_model.rs` and `agent_inventory.rs` are Phase-2-only and frozen after `G2`. +9. `state_store.rs`, `validator.rs`, and `agents_cmd.rs` are Phase-3 surfaces and frozen after `G3`. +10. `async_repl.rs` and additive `world_ops.rs` work belong only to retained parity after `G3`. +11. Docs stay last; runtime semantic work does not continue during the docs lane. +12. Honest parallelism only: two write lanes may overlap only when ownership is disjoint and all prerequisite hotspots are already frozen. +13. Any need to reopen a frozen hotspot after `G3` stops the run and forces re-planning. +14. Parent acceptance is against merged-tree behavior and the authoritative `PLAN.md` acceptance criteria, never lane-local green status alone. -1. The foundation cutover needs to be split across multiple concurrent code owners to make progress. -2. The rename cannot be landed without changing public selector semantics, `world-api`, upstream `agent_api.*`, or `uaa_session_id`. -3. The only path to green is a long-lived compatibility alias layer for old live names. -4. Fail-closed routing cannot be proven after the `world-service` rename. -5. Parallel lanes need to reopen shared manifests, shell runtime seams, or rename decisions that should have been frozen by the foundation lane. -6. Docs or ADRs can only be made truthful by contradicting the merged code and scripts tree. -7. The final validation wall cannot prove grep, cargo, operator, release, and fail-closed checks on the same merged tree. +## Blocked-Run Conditions -## Fresh Worktrees / Branches / Worker Model / Concurrency Cap +Stop the run and mark it blocked if any of the following becomes true: -Fresh worktree root: +1. `T1` cannot make `HostAttachContract` authoritative without introducing a second durable attach object. +2. `T2` cannot merge `policy_overlay` using shared resolver semantics and instead requires caller-specific patch logic. +3. `T3` cannot implement the approved narrowing-only override family without broadening scope, policy, or unsupported capability fields. +4. `T4` retained parity needs to reopen any frozen `G1` to `G3` hotspot. +5. `T4T` regression coverage needs to touch any frozen runtime hotspot instead of external tests only. +6. Equivalent human and orchestrator cold starts still resolve to different contract truth after `T1` to `T4`. +7. Persisted attach flows still regain permissive defaults from ambient runtime state after `T1` or `T3`. +8. Docs can only be made truthful by contradicting merged runtime behavior. +9. The validation wall cannot be satisfied on the same merged tree as the final docs. +10. `PLAN.md` changes materially after source lock and before `G6`. +11. A worker edits files outside its assigned lane ownership and the parent cannot quarantine the drift cleanly. -- `/Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup` +Blocked-path discipline: -Authoritative integration checkout: +1. Parent writes `.runs//blocked.json`. +2. Parent creates `.runs//sentinels/RUN_BLOCKED`. +3. Parent writes the current stop reason into the active gate directory under `decision.md`. +4. Parent records accepted lanes, rejected lanes, and unresolved files in `final-summary.md`. +5. Rejected worker branches remain unmerged. -- `/Users/spensermcconnell/__Active_Code/atomize-hq/substrate` -- branch: `chore/uaa-boundary-and-naming-cleanup` +## Fresh Worktree And Branch Protocol -Worker worktrees: +All worker worktrees live under: -- `/Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup/foundation-code` -- `/Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup/scripts-release` -- `/Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup/docs-adrs` +- `/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-5-shared-dispatch-closeout` -Worker branches: +The parent integrates only in: -- `codex/chore-uaa-boundary-and-naming-cleanup-foundation` -- `codex/chore-uaa-boundary-and-naming-cleanup-scripts-release` -- `codex/chore-uaa-boundary-and-naming-cleanup-docs-adrs` +- `/home/azureuser/__Active_Code/atomize-hq/substrate` -Exact setup order: +Parent run-open commands: ```bash -mkdir -p /Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup +RUN_ROOT="/home/azureuser/__Active_Code/atomize-hq/substrate/.runs/slice-29-5-shared-dispatch-closeout-parity-hardening" +WORKTREE_ROOT="/home/azureuser/__Active_Code/atomize-hq/.worktrees/substrate-s29-5-shared-dispatch-closeout" +REPO_ROOT="/home/azureuser/__Active_Code/atomize-hq/substrate" + +mkdir -p "$RUN_ROOT" "$WORKTREE_ROOT" +git -C "$REPO_ROOT" status --short +BASE_HEAD="$(git -C "$REPO_ROOT" rev-parse HEAD)" +``` + +Cut protocol rules: + +1. The parent records `BASE_HEAD` in `branch-map.json`. +2. The parent snapshots dirty `PLAN.md` and current `ORCH_PLAN.md` into `.runs/**/source-lock/` before any worker starts. +3. Every worker worktree is cut from a committed accepted gate tip, never from the dirty authoritative checkout state. +4. The parent records each accepted gate tip SHA in `branch-map.json` before cutting the next lane. +5. The parent merges worker branches back into the authoritative checkout; workers never self-merge. -git -C /Users/spensermcconnell/__Active_Code/atomize-hq/substrate fetch origin +Lane creation commands: -git -C /Users/spensermcconnell/__Active_Code/atomize-hq/substrate worktree add \ - /Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup/foundation-code \ - -b codex/chore-uaa-boundary-and-naming-cleanup-foundation \ - chore/uaa-boundary-and-naming-cleanup +### `T1/L1` durable attach truth from `BASE_HEAD` + +```bash +git -C "$REPO_ROOT" worktree add \ + "$WORKTREE_ROOT/t1-durable-attach-truth" \ + -b codex/feat-gateway-mediated-llm-fulfillment-t1-durable-attach-truth \ + "$BASE_HEAD" ``` -Do not create the parallel worktrees until `G1` is accepted. +### `T2/L2` policy overlay merge from accepted `G1` tip ```bash -git -C /Users/spensermcconnell/__Active_Code/atomize-hq/substrate worktree add \ - /Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup/scripts-release \ - -b codex/chore-uaa-boundary-and-naming-cleanup-scripts-release \ - chore/uaa-boundary-and-naming-cleanup - -git -C /Users/spensermcconnell/__Active_Code/atomize-hq/substrate worktree add \ - /Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/substrate-uaa-boundary-cleanup/docs-adrs \ - -b codex/chore-uaa-boundary-and-naming-cleanup-docs-adrs \ - chore/uaa-boundary-and-naming-cleanup +ACCEPTED_TIP_G1="$(git -C "$REPO_ROOT" rev-parse HEAD)" + +git -C "$REPO_ROOT" worktree add \ + "$WORKTREE_ROOT/t2-policy-overlay-merge" \ + -b codex/feat-gateway-mediated-llm-fulfillment-t2-policy-overlay-merge \ + "$ACCEPTED_TIP_G1" ``` -Concurrency contract: +### `T3/L3` capability override closeout from accepted `G2` tip -1. `P0` and `A1` / `A2` are serialized. -2. `B1` and `C1` are the only honest parallel window. -3. Peak concurrency is `2`, not `3`, because there is one shared foundation hotspot lane and two isolated follow-on lanes. +```bash +ACCEPTED_TIP_G2="$(git -C "$REPO_ROOT" rev-parse HEAD)" -## Parent-Owned Run-State Surface And Required Artifacts +git -C "$REPO_ROOT" worktree add \ + "$WORKTREE_ROOT/t3-capability-override-closeout" \ + -b codex/feat-gateway-mediated-llm-fulfillment-t3-capability-override-closeout \ + "$ACCEPTED_TIP_G2" +``` -Canonical run root: +### `T4/L4` retained member parity and `T4T/L4T` frozen-surface regression lane from accepted `G3` tip -- `/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/.runs/plan-uaa-boundary-and-naming-cleanup/` +```bash +ACCEPTED_TIP_G3="$(git -C "$REPO_ROOT" rev-parse HEAD)" -Required parent-owned top-level artifacts: +git -C "$REPO_ROOT" worktree add \ + "$WORKTREE_ROOT/t4-retained-member-parity" \ + -b codex/feat-gateway-mediated-llm-fulfillment-t4-retained-member-parity \ + "$ACCEPTED_TIP_G3" -- `run-state.json` -- `source-lock.json` -- `contract-freeze.json` -- `branch-map.json` -- `lane-ownership.json` -- `merge-order.json` -- `validation-wall.md` -- `session-log.md` -- `final-summary.md` -- `blocked.json` on blocked runs only -- `sentinels/` -- `tasks/` -- `gates/` +git -C "$REPO_ROOT" worktree add \ + "$WORKTREE_ROOT/t4t-frozen-surface-regression-tests" \ + -b codex/feat-gateway-mediated-llm-fulfillment-t4t-frozen-surface-regression-tests \ + "$ACCEPTED_TIP_G3" +``` -Required sentinels: +### `T5/L5` docs truth sync from accepted `G4` tip -- `sentinels/RUN_OPEN` -- `sentinels/RUN_BLOCKED` on blocked runs only -- `sentinels/RUN_COMPLETE` on successful closeout only +```bash +ACCEPTED_TIP_G4="$(git -C "$REPO_ROOT" rev-parse HEAD)" -Frozen gate directories: +git -C "$REPO_ROOT" worktree add \ + "$WORKTREE_ROOT/t5-docs-truth-sync" \ + -b codex/feat-gateway-mediated-llm-fulfillment-t5-docs-truth-sync \ + "$ACCEPTED_TIP_G4" +``` -- `gates/G0-parent-contract-freeze/` -- `gates/G1-foundation-accept-and-parallel-launch/` -- `gates/G2-parallel-window-integration/` -- `gates/G3-validation-launch/` -- `gates/G4-final-acceptance/` +## Parent-Owned Run-State Surface -Every gate directory must contain: +Canonical run root: -- `gate.json` -- `evidence.md` -- one sentinel exactly one of: - - `OPEN` - - `PASSED` - - `FAILED` - - `REOPENED` - -Task map: - -- `tasks/P0-parent-contract-freeze-and-run-init/` -- `tasks/A1-protocol-label-cutover/` -- `tasks/A2-family-rename-foundation-cutover/` -- `tasks/G1-foundation-accept-and-parallel-launch/` -- `tasks/B1-scripts-ci-release-cutover/` -- `tasks/C1-docs-adr-truth-convergence/` -- `tasks/G2-parallel-window-integration/` -- `tasks/P1-parent-parallel-integration/` -- `tasks/G3-validation-launch/` -- `tasks/P2-parent-validation-wall/` -- `tasks/P3-parent-closeout/` +- `/home/azureuser/__Active_Code/atomize-hq/substrate/.runs/slice-29-5-shared-dispatch-closeout-parity-hardening/` + +Canonical artifact layout: + +```text +.runs/slice-29-5-shared-dispatch-closeout-parity-hardening/ + run-state.json + queue.json + session-log.md + branch-map.json + lane-ownership.json + hotspot-freeze.json + merge-order.json + validation-wall.md + final-summary.md + blocked.json # blocked runs only + source-lock/ + PLAN.authoritative.md + ORCH_PLAN.authoritative.md + git-status.txt + sha256.json + sentinels/ + RUN_OPEN + RUN_BLOCKED # blocked runs only + RUN_COMPLETE # successful runs only + tasks/ + P0-parent-freeze/ + T1-durable-attach-truth/ + G1-phase1-accept/ + T2-policy-overlay-merge/ + G2-phase2-accept/ + T3-capability-override-closeout/ + G3-phase3-accept/ + T4-retained-member-parity/ + T4T-frozen-surface-regression-tests/ + G4-parallel-window-accept/ + T5-docs-truth-sync/ + G5-docs-accept/ + P1-validation-wall/ + G6-final-accept/ + P2-parent-closeout/ + gates/ + G0-run-open/ + G1-phase1-accept/ + G2-phase2-accept/ + G3-phase3-accept/ + G4-parallel-window-accept/ + G5-docs-accept/ + G6-final-accept/ +``` -Every task directory must contain: +Parent-only top-level files: -- `task.json` -- `owner.txt` -- `status.txt` -- `scope.txt` -- `deliverable.txt` -- `dependencies.json` -- `changed-files.txt` -- `commands.txt` -- `exit-codes.json` -- `impact-analysis-summary.md` -- `gitnexus-detect-changes.txt` -- `handoff-notes.md` -- `summary.md` -- `HEAD_SHA.txt` -- `blocker-notes.md` if blocked -- one sentinel exactly one of: - - `READY_FOR_REVIEW` - - `ACCEPTED` - - `REJECTED` - - `BLOCKED` - -Worker artifact rule: - -1. Workers do not write `.runs/**`. -2. Workers return handoff content to the parent. -3. Parent transcribes the handoff into `.runs/**`, including changed files, commands, exit codes, impact notes, detect-changes output, acceptance notes, and blocker notes. -4. Parent creates or replaces the task sentinel after transcription. Workers never create task sentinels. -5. No task is complete until the parent writes the artifacts and marks the task `ACCEPTED`. - -`contract-freeze.json` must record at minimum: - -1. `authoritative_branch: "chore/uaa-boundary-and-naming-cleanup"` -2. locked rename families: - - `uaa.agent.session -> substrate.agent.session` - - `world-agent* -> world-service*` - - `agent-api* -> transport-api*` -3. preserved names: - - upstream `agent_api` - - upstream `agent_api.*` - - `uaa_session_id` - - unchanged `world-api` -4. the fail-closed rule for renamed service discovery and stale protocol labels -5. the live rename boundary -6. the historical allowlist -7. the exact validation wall commands -8. the initial worker cap of `1` and peak worker cap of `2` - -## GitNexus Workflow And Required Impact Targets - -GitNexus is a required run control, not a best-effort check. - -### Source-Lock Stage - -1. Parent checks GitNexus availability and index freshness before any worker edits. -2. If the index is stale, parent runs `npx gitnexus analyze` from the authoritative checkout before launching `A1`. -3. Parent records the freshness result in `tasks/P0-parent-contract-freeze-and-run-init/impact-analysis-summary.md`. - -### Minimum Required Targets By Task - -`A1` protocol-label cutover - -1. Concrete symbol targets if present: - - `LOCAL_AGENT_PROTOCOL_FAMILY` - - the validator entrypoint that accepts or rejects local protocol labels in `crates/shell/src/execution/agent_runtime/validator.rs` - - the trace-emission seam in `crates/common/src/agent_events.rs` -2. File-level seam targets that must be analyzed if symbol names drift: - - `crates/shell/src/execution/agent_runtime/mapping.rs` - - `crates/shell/src/execution/agent_runtime/validator.rs` - - `crates/shell/src/execution/agent_runtime/session.rs` - - `crates/shell/src/execution/agent_runtime/orchestration_session.rs` - - `crates/shell/src/execution/agent_runtime/state_store.rs` - - `crates/shell/src/execution/routing/dispatch/world_ops.rs` - -`A2` family-rename foundation cutover - -1. Concrete symbol targets if present: - - the Linux world-routing or socket-activation resolution path that locates the in-world daemon - - the `world-agent` binary entrypoint crate target being renamed to `world-service` -2. File-level seam targets that must be analyzed if symbol names drift: - - [Cargo.toml](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/Cargo.toml) - - `crates/shell/src/repl/async_repl.rs` - - `crates/shell/src/execution/socket_activation.rs` - - `crates/shell/src/execution/platform/linux.rs` - - `crates/shell/src/execution/routing/world.rs` - - `crates/world-agent/**` or renamed equivalents - - `crates/agent-api-types/**`, `crates/agent-api-core/**`, and `crates/agent-api-client/**` or renamed equivalents - -`B1` scripts / CI / release cutover - -1. File-level seam targets: - - `scripts/linux/world-provision.sh` - - `scripts/substrate/install-substrate.sh` - - `scripts/substrate/dev-install-substrate.sh` - - `scripts/substrate/uninstall-substrate.sh` - - `scripts/mac/lima-warm.sh` - - `scripts/mac/smoke.sh` - - `scripts/windows/wsl-warm.ps1` - - `scripts/windows/wsl-smoke.ps1` - - `scripts/windows/wsl-doctor.ps1` - - `dist/scripts/assemble-release-bundles.sh` - - `.github/workflows/feature-smoke.yml` - - `.github/workflows/nightly.yml` -2. If GitNexus has script or workflow indexing for callable entrypoints in these files, analyze those entrypoints before editing; otherwise the file-level seam analysis is sufficient and must be stated explicitly in the handoff. - -`C1` docs / ADR / truth convergence - -1. File-level seam targets: - - `AGENT_ORCHESTRATION_GAP_MATRIX.md` - - `docs/WORLD.md` - - `docs/TRACE.md` - - `docs/CONFIGURATION.md` - - `docs/INSTALLATION.md` - - `docs/UNINSTALL.md` - - `docs/USAGE.md` - - `README.md` - - `AGENTS.md` - - relevant ADRs under `docs/project_management/adrs/**` -2. GitNexus impact is docs-truth only here. If no symbol-level targets are relevant, the worker must state that the lane is file-level and docs-only. - -### Escalation Rule - -1. Any `HIGH` or `CRITICAL` impact result stops that worker before edits. -2. The worker returns a blocker handoff instead of proceeding. -3. Parent records the blocker, decides whether the run still fits the frozen contract, and either relaunches with a narrower brief or blocks the run. - -## Workstream Plan With Parent-Owned Gates And Worker-Owned Lanes - -### Workstream Map - -| PLAN.md workstream | Orchestration tasks | Ownership | -| --- | --- | --- | -| Freeze vocabulary, rename matrix, grep wall, historical allowlist | `P0`, `G0` | Parent only | -| Cut over `substrate.agent.session` on runtime, validation, trace, config, and persistence surfaces | `A1` | Foundation lane | -| Rename `world-agent*` and `agent-api*` families on code and manifests; keep mixed-boundary imports clear | `A2` | Foundation lane | -| Sweep scripts, CI, release bundles, install/warm/smoke/uninstall, and operator helper text | `B1` | Scripts/release lane | -| Sweep docs, ADRs, repo truth docs, examples, operator guidance, and matrix language | `C1` | Docs/ADR lane | -| Integrate, validate, and close out | `G2`, `P1`, `G3`, `P2`, `G4`, `P3` | Parent only | +| File | Purpose | +| --- | --- | +| `run-state.json` | current gate, current task, run status, active lane count, accepted tip SHA | +| `queue.json` | ordered task queue, dependencies, ready/running/accepted/blocked states | +| `session-log.md` | chronological operator log of worker launches, merges, blockers, and gate decisions | +| `branch-map.json` | branch names, worktree paths, cut SHAs, merge SHAs, acceptance SHAs | +| `lane-ownership.json` | file ownership map and forbidden surfaces per lane | +| `hotspot-freeze.json` | hotspot files, freeze gate, reopen policy | +| `merge-order.json` | required merge order and actual merge order | +| `validation-wall.md` | final merged-tree proof record | +| `final-summary.md` | completion or blocked summary for the whole run | +| `blocked.json` | machine-readable stop artifact when blocked | + +Required `source-lock/` files: + +1. `PLAN.authoritative.md` +2. `ORCH_PLAN.authoritative.md` +3. `git-status.txt` +4. `sha256.json` + +Required sentinel semantics: + +1. `RUN_OPEN` exists after `G0`. +2. `RUN_BLOCKED` exists only when the run is stopped. +3. `RUN_COMPLETE` exists only after `G6` and `P2`. + +Every task directory must contain: -### Parent-Owned Gates +1. `task.json` +2. `scope.md` +3. `owned-files.txt` +4. `forbidden-surfaces.txt` +5. `commands.txt` +6. `acceptance.md` +7. `status.json` +8. `handoff.md` when work has been returned -`G0`: Contract freeze +Every gate directory must contain: -1. Parent locks the rename matrix, preserved-name list, live boundary, historical allowlist, validation wall, and branch map. -2. Parent confirms that no worker will need to guess names, scope, or final grep commands. +1. `gate.json` +2. `checklist.md` +3. `diff-summary.md` +4. `decision.md` +5. `next-step.md` +6. `status.json` -`G1`: Foundation acceptance and parallel launch +Parent-only `.runs/**` rule: -1. `A1` and `A2` are both merged or otherwise accepted into the authoritative branch. -2. Parent confirms that shared hotspots are stable: - - manifests - - `crates/shell/**` - - local protocol validation and persistence seams - - mixed-boundary imports -3. Parent confirms the new canonical names are real enough for downstream scripts and docs work. -4. Only after `G1` is accepted may `B1` and `C1` start. +1. Workers do not write these artifacts directly. +2. Workers return structured handoffs to the parent. +3. The parent writes the canonical record into `.runs/**`. +4. Full worker transcripts are not the source of truth; `.runs/**` artifacts are. -`G2`: Parallel-window integration +## Hotspot Ownership And Freeze Map -1. Parent receives both worker handoffs. -2. Parent verifies each lane stayed inside its ownership boundary. -3. Parent merges accepted outputs into `chore/uaa-boundary-and-naming-cleanup`. -4. Parent quarantines any lane that reopens foundation hotspots without approval. +| Surface | Owner task | Freeze gate | Reopen policy | +| --- | --- | --- | --- | +| `crates/shell/src/execution/agent_runtime/orchestration_session.rs` | `T1` | `G1` | forbidden | +| `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` | `T1`, `T2`, `T3` | `G3` | forbidden after `G3` | +| `crates/shell/src/execution/agents_cmd.rs` | `T1`, `T3` | `G3` | forbidden after `G3` | +| `crates/shell/src/execution/policy_model.rs` | `T2` | `G2` | forbidden | +| `crates/shell/src/execution/agent_inventory.rs` | `T2` | `G2` | forbidden | +| `crates/shell/src/execution/agent_runtime/state_store.rs` | `T3` | `G3` | forbidden after `G3` | +| `crates/shell/src/execution/agent_runtime/validator.rs` | `T3` | `G3` | forbidden after `G3` | +| `crates/shell/src/repl/async_repl.rs` | `T4` | `G4` | forbidden after `G4` | +| `crates/shell/src/execution/routing/dispatch/world_ops.rs` | `T4` if needed | `G4` | forbidden after `G4` | +| `crates/shell/tests/repl_world_first_routing_v1.rs` | `T4` | `G4` | forbidden after `G4` | +| `crates/shell/tests/agent_public_control_surface_v1.rs` | `T4T` | `G4` | test-only lane | +| `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` | `T4T` | `G4` | test-only lane | +| `llm-last-mile/29*.md`, `30*.md`, `31*.md` | `T5` | `G5` | docs only | -`G3`: Validation launch +Freeze rule: -1. Parent confirms the merged tree is the validation candidate. -2. Parent confirms no pending naming disputes remain. -3. Parent freezes the validation command list from the plan and starts `P2`. +1. If any later lane needs a frozen runtime hotspot, stop the run. +2. Do not silently shift frozen work into a later lane. +3. The safe parallel window exists only because `T4T` does not own runtime hotspots. -`G4`: Final acceptance +## Dependency And Parallel-Lanes Table -1. Grep wall is green outside the historical allowlist. -2. Cargo wall is green. -3. Operator surface gates are green. -4. Release gates are green. -5. Fail-closed checks are green. -6. Parent runs final GitNexus scope verification and writes closeout. +| Task | Description | Depends on | Safe to run in parallel with | +| --- | --- | --- | --- | +| `P0` | parent freeze and source lock | - | none | +| `T1` | durable attach truth | `P0`, `G0` | none | +| `T2` | policy overlay merge | `G1` | none | +| `T3` | capability override closeout | `G2` | none | +| `T4` | retained member parity | `G3` | `T4T` only | +| `T4T` | frozen-surface regression tests | `G3` | `T4` only | +| `T5` | docs truth sync | `G4` | none | +| `P1` | validation wall | `G5` | none | +| `P2` | closeout | `G6` | none | -### Worker-Owned Lanes +Parallel-lane rationale: -`A` Foundation code lane: serialized under one owner +1. `T1`, `T2`, and `T3` are serialized because they share the contract and durable-state hotspots. +2. `T4` is safe only after `G3` freezes shared contract vocabulary and runtime hotspots. +3. `T4T` is safe only because it is limited to external regression tests that validate already-frozen behavior. +4. If `T4T` needs any frozen runtime hotspot, the parallel window closes immediately and the parent absorbs follow-up work after `G4`. -1. `A1` protocol-label cutover -2. `A2` family rename cutover +## Task-Level Orchestration -`B` Scripts / CI / release lane: launch only after `G1` +### `P0` Parent Freeze -1. install and provision scripts -2. dev-install and uninstall scripts -3. Lima and WSL warm/smoke/doctor helpers -4. release bundle assembly -5. CI workflow package invocations and smoke checks -6. operator helper, remediation, and script output text in those surfaces +Goal: -`C` Docs / ADR / truth lane: launch only after `G1` +1. lock the dirty working-tree `PLAN.md` as authoritative; +2. lock this controller version; +3. publish lane ownership, hotspot freeze, branch protocol, and queue state. -1. live docs -2. repo truth docs -3. ADRs -4. README and AGENTS guidance -5. examples, diagrams, and operator remediation wording in docs +Required parent actions: -## Task Execution Contracts +1. snapshot `PLAN.md`, `ORCH_PLAN.md`, and `git status --short` into `.runs/**/source-lock/`; +2. initialize `queue.json`, `branch-map.json`, `lane-ownership.json`, `hotspot-freeze.json`, and `merge-order.json`; +3. create all task and gate directories; +4. set `run-state.json` to `status=open-pending-g0`. -### `P0-parent-contract-freeze-and-run-init` +Acceptance: -Primary owned surfaces: +1. source lock exists; +2. dirty-tree state is recorded; +3. workers can be launched without relying on mutable ambient context. -- [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md) -- [ORCH_PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/ORCH_PLAN.md) -- `.runs/plan-uaa-boundary-and-naming-cleanup/**` +### `G0` Run-Open Gate -Required actions: +Parent verifies: -1. Freeze the rename matrix, preserved-name list, live rename boundary, historical allowlist, and validation wall exactly from the current plan. -2. Create `branch-map.json`, `lane-ownership.json`, `merge-order.json`, and `contract-freeze.json`. -3. Record GitNexus freshness status and the concurrency contract. -4. Write the initial gate and task directories with `OPEN` and `READY_FOR_REVIEW` only where appropriate for parent-owned setup completion. +1. authoritative branch is `feat/gateway-mediated-llm-fulfillment`; +2. current dirty `PLAN.md` snapshot is captured; +3. worker ownership and forbidden surfaces are recorded; +4. `RUN_OPEN` sentinel can be created safely. -Verification commands: +Advance only if: -```bash -test -f /Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md -test -f /Users/spensermcconnell/__Active_Code/atomize-hq/substrate/ORCH_PLAN.md -``` +1. no required run artifacts are missing; +2. there is no unresolved ambiguity about the ordered execution shape. -Acceptance conditions: +### `T1 / L1` Durable Attach Truth First -1. No worker would need to guess names, ownership, or final validation commands. -2. `.runs/**` skeleton exists on paper with required artifacts and sentinel rules. -3. Parent has recorded whether GitNexus freshness work is needed before `A1`. +Scope: -### `A1-protocol-label-cutover` +1. derive `HostAttachContract` from resolved host launch truth; +2. persist attach-relevant capabilities and attach knobs from resolved truth; +3. keep sync continuity-only; +4. keep successor copy as generalized truth plus cleared continuity only. -Primary owned surfaces or file families: +Owned files: -- `crates/common/src/agent_events.rs` -- `crates/shell/src/execution/agent_runtime/**` -- `crates/shell/src/execution/routing/dispatch/world_ops.rs` -- `config/**` -- code-adjacent tests and fixtures for local protocol validation, trace emission, and persisted runtime state +1. `crates/shell/src/execution/agent_runtime/orchestration_session.rs` +2. `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` +3. `crates/shell/src/execution/agents_cmd.rs` -Required actions: +Forbidden surfaces: -1. Replace the canonical local protocol-family label from `uaa.agent.session` to `substrate.agent.session`. -2. Update validator success paths, rejection text, config examples, emitted traces, transport payloads, durable writes, and durable reload paths to the new label. -3. Rewrite supported fixtures and checked-in examples that still treat `uaa.agent.session` as supported canonical input. -4. Make stale old-label configs or persisted rows fail closed with explicit operator-readable errors. -5. Preserve upstream `agent_api.*` ids and `uaa_session_id` semantics unchanged. +1. `.runs/**` +2. `PLAN.md` +3. `ORCH_PLAN.md` +4. `policy_model.rs` +5. `agent_inventory.rs` +6. `state_store.rs` +7. `validator.rs` +8. `async_repl.rs` +9. `world_ops.rs` +10. docs -Verification commands: +Required commands/tests: ```bash -cargo test -p shell agents_validate -- --nocapture +cargo test -p shell dispatch_contract -- --nocapture cargo test -p shell agent_successor_contract_ahcsitc0 -- --nocapture -rg -n "substrate\.agent\.session|uaa\.agent\.session" crates/common crates/shell config ``` -Acceptance conditions: +Acceptance criteria: -1. `substrate.agent.session` is the only supported canonical local protocol-family label on live code and config surfaces owned by `A1`. -2. `uaa.agent.session` no longer succeeds silently on supported live paths. -3. Trace, status, validation, and persistence seams owned by `A1` all reflect the new label. -4. No upstream capability or session-correlation semantics changed. +1. birth-time host attach truth comes from resolved contract truth; +2. persisted attach planning no longer reconstructs permissive baseline truth from ambient participant state; +3. successor copy preserves generalized truth and clears only continuity-specific state; +4. handoff includes exact files changed, tests run, tests not run, and unresolved concerns. -### `A2-family-rename-foundation-cutover` +### `G1` Phase 1 Gate -Primary owned surfaces or file families: +Parent verifies: -- [Cargo.toml](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/Cargo.toml) -- `dist-workspace.toml` -- `crates/world-agent/**` to be renamed -- `crates/agent-api-types/**`, `crates/agent-api-core/**`, `crates/agent-api-client/**` to be renamed -- mixed-boundary code under `crates/shell/**` -- code-level service discovery and doctor logic coupled to renamed package, binary, socket, or unit names +1. only owned files changed; +2. `HostAttachContract` remains the only durable attach object; +3. no ambient-state fallback path survived in the touched attach flow; +4. committed accepted tip is recorded in `branch-map.json`. -Required actions: +Advance only if: -1. Rename `world-agent*` to `world-service*` across workspace members, package names, Rust crate ids, and binary names. -2. Rename `agent-api*` to `transport-api*` across workspace members, package names, Rust crate ids, and imports. -3. Update mixed-boundary consumers so upstream `agent_api` remains visually distinct from local `transport_api_*`. -4. Preserve `world-api` unchanged. -5. Keep service discovery fail closed when renamed binary, unit, or socket lookup breaks. +1. durable attach schema is frozen enough for later phases; +2. no `T2` or `T3` assumptions are required to explain `T1`. -Verification commands: +### `T2 / L2` Policy Overlay Merge Second -```bash -cargo build --workspace -cargo test -p world-service -- --nocapture -cargo test -p transport-api-types -- --nocapture -cargo test -p transport-api-core -- --nocapture -cargo test -p transport-api-client -- --nocapture -rg -n "world-agent|world_agent|substrate-world-agent|agent-api-types|agent-api-core|agent-api-client|agent_api_types|agent_api_core|agent_api_client" Cargo.toml dist-workspace.toml crates -``` +Scope: -Acceptance conditions: +1. expose or reuse one shared policy patch helper; +2. apply validated inventory `policy_overlay` into `ResolvedLaunchContract.effective_policy`; +3. return exact policy diagnostics and provenance. -1. Workspace manifests and crate imports compile with `world-service` and `transport-api*`. -2. Mixed-boundary consumers remain readable: upstream `agent_api` untouched, local transport crates clearly renamed. -3. `world-api` is unchanged. -4. Code-level service discovery and doctor logic no longer depend on stale canonical `world-agent` naming. +Owned files: -### `B1-scripts-ci-release-cutover` +1. `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` +2. `crates/shell/src/execution/policy_model.rs` +3. `crates/shell/src/execution/agent_inventory.rs` -Primary owned surfaces or file families: +Forbidden surfaces: -- `scripts/**` -- `.github/**` -- `dist/**` -- live packaging or launch helpers under `macos-hardening/**` if applicable +1. `.runs/**` +2. `PLAN.md` +3. `ORCH_PLAN.md` +4. `orchestration_session.rs` +5. `state_store.rs` +6. `validator.rs` +7. `agents_cmd.rs` +8. `async_repl.rs` +9. `world_ops.rs` +10. docs -Required actions: - -1. Rename package, binary, alias, socket, and systemd unit references to `world-service` and `substrate-world-service`. -2. Update install, dev-install, uninstall, provision, warm, smoke, and doctor flows to the new daemon and transport family names. -3. Update release bundle assembly and release template payload naming. -4. Update CI workflow package invocations and smoke steps. -5. Ensure upgrade-capable paths remove or stop legacy `substrate-world-agent` assets where the plan requires cleanup. - -Verification commands: +Required commands/tests: ```bash -rg -n "world-agent|substrate-world-agent|agent-api-types|agent-api-core|agent-api-client" scripts .github dist macos-hardening -rg -n "world-service|substrate-world-service|transport-api-types|transport-api-core|transport-api-client" scripts .github dist macos-hardening -./dist/scripts/assemble-release-bundles.sh +cargo test -p shell dispatch_contract -- --nocapture ``` -Acceptance conditions: +Acceptance criteria: -1. Live scripts, workflows, and release surfaces use the renamed service and transport families consistently. -2. No operator helper script or release script points canonical usage back to `substrate-world-agent`. -3. Release bundle assembly and CI naming surfaces prove the cutover beyond code compilation. +1. overlay-backed resolution returns materially narrower `effective_policy` where appropriate; +2. overlay-free resolution stays unchanged; +3. diagnostics explain overlay acceptance or denial at the policy layer; +4. no parallel policy semantics are introduced outside the shared resolver path. -### `C1-docs-adr-truth-convergence` +### `G2` Phase 2 Gate -Primary owned surfaces or file families: +Parent verifies: -- `docs/**` -- `README.md` -- `AGENTS.md` -- `AGENT_ORCHESTRATION_GAP_MATRIX.md` -- relevant ADRs under `docs/project_management/adrs/**` +1. only owned files changed; +2. one merge semantics source is used; +3. overlay behavior remains narrowing-only; +4. accepted `G2` tip SHA is recorded before cutting `T3`. -Required actions: +Advance only if: -1. Rewrite docs, ADRs, and truth docs so upstream UAA, local `transport-api*`, local `world-service*`, unchanged `world-api`, and local `substrate.agent.session` are clearly separated. -2. Update operator examples and remediation guidance to the new canonical names. -3. Remove live doc guidance that still teaches `uaa.agent.session` or `substrate-world-agent` as supported canonical names. -4. Keep historical references only where explicitly allowable as historical. +1. policy truth is frozen enough to support capability closeout and later parity; +2. no caller-specific overlay behavior survives. -Verification commands: +### `T3 / L3` Capability Override Closeout Third -```bash -rg -n "world-agent|substrate-world-agent|agent-api-types|agent-api-core|agent-api-client|uaa\.agent\.session" docs README.md AGENTS.md AGENT_ORCHESTRATION_GAP_MATRIX.md -rg -n "world-service|substrate-world-service|transport-api-types|transport-api-core|transport-api-client|substrate\.agent\.session|world-api|agent_api\." docs README.md AGENTS.md AGENT_ORCHESTRATION_GAP_MATRIX.md -``` +Scope: -Acceptance conditions: +1. implement field-by-field override handling; +2. support only narrowing-only overrides for: + `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, `event_stream`; +3. keep `session_start`, `llm`, and `mcp_client` rejected with field-scoped fail-closed diagnostics; +4. keep persisted attach launches rejecting dispatch-time capability overrides; +5. ensure persisted narrowed truth drives later state-store gates. -1. Live docs and ADRs tell the same boundary story as the merged code and scripts tree. -2. Canonical examples use `substrate.agent.session`, `world-service*`, and `transport-api*`. -3. No live operator doc instructs readers to inspect `substrate-world-agent` or configure `uaa.agent.session`. +Owned files: -### `P1-parent-parallel-integration` +1. `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` +2. `crates/shell/src/execution/agent_runtime/state_store.rs` +3. `crates/shell/src/execution/agent_runtime/validator.rs` +4. `crates/shell/src/execution/agents_cmd.rs` -Primary owned surfaces: +Forbidden surfaces: -- authoritative branch `chore/uaa-boundary-and-naming-cleanup` -- `.runs/**` +1. `.runs/**` +2. `PLAN.md` +3. `ORCH_PLAN.md` +4. `orchestration_session.rs` +5. `policy_model.rs` +6. `agent_inventory.rs` +7. `async_repl.rs` +8. `world_ops.rs` +9. docs -Required actions: - -1. Review `B1` and `C1` handoffs against ownership boundaries. -2. Merge accepted lane outputs in the frozen order. -3. Replay or quarantine lanes when overlap breaks the ownership contract. -4. Record integration outcomes, replays, and quarantines in `.runs/**`. - -Verification commands: +Required commands/tests: ```bash -git -C /Users/spensermcconnell/__Active_Code/atomize-hq/substrate status --short +cargo test -p shell dispatch_contract -- --nocapture +cargo test -p shell agent_public_control_surface_v1 -- --nocapture ``` -Acceptance conditions: - -1. The authoritative branch contains exactly the accepted outputs from `B1` and `C1`. -2. Any overlap is resolved by explicit replay or quarantine, not silent manual blending. -3. `.runs/**` reflects the actual merge and replay history. +Acceptance criteria: -### `P2-parent-validation-wall` +1. approved override family works as narrowing-only; +2. unsupported fields fail closed with exact field names and bounded reasons; +3. later attach and control paths observe persisted narrowed truth; +4. shared contract vocabulary is frozen enough for retained parity. -Primary owned surfaces: +### `G3` Phase 3 Gate -- merged authoritative tree -- `.runs/**` +Parent verifies: -Required actions: +1. only owned files changed; +2. approved support matrix matches the authoritative plan; +3. denial wording is field-scoped and truthful; +4. `dispatch_contract.rs`, `state_store.rs`, `validator.rs`, and `agents_cmd.rs` can be frozen; +5. accepted `G3` tip SHA is recorded before cutting the parallel window. -1. Run the grep gates. -2. Run the cargo gates. -3. Run the operator surface gates. -4. Run the release gates. -5. Record fail-closed proof points and environment blockers, if any. +Advance only if: -Verification commands: +1. shared contract vocabulary is stable; +2. persisted narrowed truth is already real; +3. any need to reopen these hotspots would be a blocker. -1. The exact grep, cargo, operator, release, and fail-closed commands already frozen in `Validation Wall`. +### `T4 / L4` Retained Member Parity -Acceptance conditions: +Scope: -1. The validation wall is green or explicitly blocked by environment availability with no ambiguity about follow-up. -2. The merged tree proves the rename across code, scripts, docs, release surfaces, and operator flows. -3. Parent records exact commands, exit codes, and evidence in `.runs/**`. +1. build the shared-contract-derived retained-turn subset; +2. feed typed transport from that subset; +3. keep retained follow-up turns off inventory and config re-resolution; +4. own parity-specific integration coverage. -### `P3-parent-closeout` +Owned files: -Primary owned surfaces: +1. `crates/shell/src/repl/async_repl.rs` +2. `crates/shell/src/execution/routing/dispatch/world_ops.rs` if additive wiring is required +3. `crates/shell/tests/repl_world_first_routing_v1.rs` -- `.runs/**` -- final authoritative branch state +Forbidden surfaces: -Required actions: +1. `.runs/**` +2. `PLAN.md` +3. `ORCH_PLAN.md` +4. all frozen `G1` to `G3` runtime hotspots +5. docs -1. Run final `gitnexus_detect_changes()` on the merged tree. -2. Confirm the changed scope matches the frozen plan. -3. Write `final-summary.md`, task and gate outcomes, and `RUN_COMPLETE`. -4. If the run cannot close honestly, write `blocked.json` instead. - -Verification commands: +Required commands/tests: ```bash -git -C /Users/spensermcconnell/__Active_Code/atomize-hq/substrate status --short +cargo test -p shell repl_world_first_routing_v1 -- --nocapture ``` -Acceptance conditions: +Acceptance criteria: -1. Final run-state artifacts match the actual merged result. -2. Parent has recorded final scope verification and residual risks. -3. The run ends with exactly one terminal sentinel: `RUN_COMPLETE` or `RUN_BLOCKED`. +1. retained follow-up turns no longer own launch selection semantics; +2. retained follow-up turns use shared-contract-derived subset truth; +3. typed transport remains intact; +4. no frozen hotspot was reopened. -## Exact Lane Ownership Boundaries By Directories / Modules +### `T4T / L4T` Frozen-Surface Regression Tests -### Lane A: Foundation Code Ownership +Scope: -Owns these surfaces end to end: +1. add or extend tests for durable attach truth, overlay merge, override persistence, and successor truth on already-frozen behavior; +2. validate the merged contract floor from outside the frozen runtime hotspots. -- [Cargo.toml](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/Cargo.toml) -- `dist-workspace.toml` -- `crates/common/**` where protocol-family trace identity is emitted -- `crates/shell/**` -- `crates/world-agent/**` to be renamed to `crates/world-service/**` -- `crates/agent-api-types/**` to be renamed to `crates/transport-api-types/**` -- `crates/agent-api-core/**` to be renamed to `crates/transport-api-core/**` -- `crates/agent-api-client/**` to be renamed to `crates/transport-api-client/**` -- code-adjacent tests and fixtures under those crates -- `config/**` for supported live protocol examples +Owned files: -Lane A owns both of these subproblems: +1. `crates/shell/tests/agent_public_control_surface_v1.rs` +2. `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` +3. additional external shell test files only if the parent pre-approves them in `lane-ownership.json` -1. protocol-label cutover: - - `uaa.agent.session -> substrate.agent.session` - - validators - - trace emission - - transport payloads - - durable writes and reload paths - - fail-closed handling for stale rows and configs -2. family renames: - - `world-agent* -> world-service*` - - `agent-api* -> transport-api*` - - workspace members - - package names - - Rust crate ids - - mixed-boundary imports - - in-code service discovery and doctor logic that depends on the renamed family +Forbidden surfaces: -Lane A may also touch: +1. every frozen runtime hotspot from `G1` to `G3` +2. `async_repl.rs` +3. `world_ops.rs` +4. docs +5. `.runs/**` +6. `PLAN.md` +7. `ORCH_PLAN.md` -- code-level doctor or remediation strings that are directly coupled to renamed runtime or service discovery logic +Why this lane is safe: -Lane A may not defer any shared-hotspot name choice to later lanes. +1. it branches from the accepted `G3` tip; +2. it touches only external regression test files; +3. it validates already-frozen semantics rather than defining new runtime behavior. -### Lane B: Scripts / CI / Release Ownership +If this lane needs a frozen runtime hotspot: -Owns these surfaces after `G1`: +1. stop the lane immediately; +2. mark the issue in `G4` as a blocker; +3. collapse the work back to parent-owned post-merge follow-up or re-plan the run. -- `scripts/**` -- `.github/**` -- `dist/**` -- `macos-hardening/**` if the file is part of live packaging, launch, or operator setup +Required commands/tests: -Lane B specifically owns: +```bash +cargo test -p shell agent_public_control_surface_v1 -- --nocapture +cargo test -p shell agent_successor_contract_ahcsitc0 -- --nocapture +``` -1. package invocation updates -2. binary, alias, socket, and systemd unit references in scripts -3. install, warm, smoke, doctor, provision, and uninstall flows -4. release payload staging and release-template references -5. upgrade cleanup of legacy `substrate-world-agent` units or binaries where required by the plan +Acceptance criteria: -Lane B may not edit: +1. only approved test files changed; +2. no frozen runtime hotspot changed; +3. test coverage closes Phase-1-to-3 proof gaps without mutating frozen semantics. -- crate code -- workspace manifests -- local protocol validation logic -- docs or ADR narrative except tiny inline comments inside its own scripts if needed +### `G4` Parallel Window Gate -### Lane C: Docs / ADR / Truth Ownership +Parent verifies: -Owns these surfaces after `G1`: +1. `T4` and `T4T` stayed inside disjoint ownership boundaries; +2. `T4` did not reopen frozen `G1` to `G3` hotspots; +3. `T4T` touched only approved external test files; +4. retained parity lands first if merge order matters; +5. compatible regression tests are replayed or rebased onto the accepted `G4` tip if necessary. -- `docs/**` -- `README.md` -- `AGENTS.md` -- `AGENT_ORCHESTRATION_GAP_MATRIX.md` -- `docs/project_management/adrs/**` +Advance only if: -Lane C specifically owns: +1. the only safe parallel window remained honest; +2. retained parity truth and frozen-surface regression coverage can coexist on one accepted tree. -1. upstream-vs-local boundary wording -2. examples using `substrate.agent.session` -3. docs and ADR references to `world-service*` -4. docs and ADR references to `transport-api*` -5. truth-table cleanup so no live doc implies local transport equals upstream UAA +### `T5 / L5` Docs Truth Sync Last -Lane C may not edit: +Scope: -- crate code -- scripts -- CI files -- release bundle assembly -- manifests +1. update slice docs so 29, 29.5, 30, and 31 agree on shipped semantics; +2. update nearby runtime comments or ASCII diagrams only if merged code made them stale; +3. do not change runtime semantics in this lane. -### Hotspot No-Split List +Owned files: -These surfaces must not be split across concurrent workers: +1. `llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md` +2. `llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md` +3. `llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md` +4. nearby comments or diagrams only if parent explicitly authorizes them -1. [Cargo.toml](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/Cargo.toml) -2. `dist-workspace.toml` -3. `crates/shell/**` -4. any file importing upstream `agent_api` and local transport crates in the same module -5. any validator, mapping, trace, routing, or persistence file that interprets the local protocol-family label -6. any code path that resolves the renamed `world-service` binary, unit, or socket +Forbidden surfaces: -## Merge / Integration Order +1. all runtime files +2. `.runs/**` +3. `PLAN.md` +4. `ORCH_PLAN.md` -Frozen integration order: +Required checks: -1. `P0-parent-contract-freeze-and-run-init` -2. `A1-protocol-label-cutover` -3. `A2-family-rename-foundation-cutover` -4. `G1-foundation-accept-and-parallel-launch` -5. parallel launch: - - `B1-scripts-ci-release-cutover` - - `C1-docs-adr-truth-convergence` -6. `G2-parallel-window-integration` -7. `P1-parent-parallel-integration` -8. `G3-validation-launch` -9. `P2-parent-validation-wall` -10. `G4-final-acceptance` -11. `P3-parent-closeout` +1. manual cross-check against accepted merged runtime semantics; +2. no runtime file drift. -Integration rule inside the parallel window: +Acceptance criteria: -1. `B1` merges before `C1` unless the parent explicitly records that there is zero overlap. -2. Reason: script, release, and operator helper names are execution surfaces; docs and ADRs should match the final merged operator vocabulary. -3. Parent must diff the `B1` and `C1` file sets before merge. This is mandatory, not optional. -4. If `B1` and `C1` overlap unexpectedly on any file outside an explicitly parent-approved shared file list, parent immediately rejects the later lane handoff and records `REJECTED` pending replay. -5. If `C1` was prepared before the final `B1` operator-visible naming settled, parent must replay or rebase `C1` on the accepted `B1` tree before `C1` can become `ACCEPTED`. -6. Parent may not manually cherry-pick prose fragments from a rejected `C1` handoff into the authoritative tree. The lane must be replayed as a coherent docs pass. -7. If `B1` reopens any foundation hotspot, parent quarantines the lane and either narrows the brief or blocks the run. +1. docs describe shipped semantics, not aspirational semantics; +2. downstream slices can cite 29.5 as the truthful floor; +3. no runtime hotspot is reopened. -## Validation Wall +### `G5` Docs Gate -Parent owns and runs the full validation wall on the merged tree only. +Parent verifies: -### Grep Gates +1. only docs or explicitly approved nearby comment surfaces changed; +2. docs match the merged runtime behavior from `G4`; +3. there is no new semantic claim unsupported by tests or code. -Zero-hit wall on live surfaces: +Advance only if: -```bash -rg -n "world-agent|world_agent|substrate-world-agent|agent-api-types|agent-api-core|agent-api-client|agent_api_types|agent_api_core|agent_api_client|uaa\.agent\.session" \ - Cargo.toml dist-workspace.toml crates scripts docs .github dist README.md AGENTS.md config macos-hardening -``` +1. docs are last and truthful; +2. the validation wall can run on the exact merged tree. -Positive guardrails that must still succeed: +### `P1` Parent Validation Wall -```bash -rg -n "agent_api\.run|agent_api\.session\.resume\.v1|agent_api\.session\.handle\.v1" crates docs config -rg -n "world-api" Cargo.toml crates docs -rg -n "world-service|substrate-world-service|transport-api-types|transport-api-core|transport-api-client|transport_api_types|transport_api_core|transport_api_client|substrate\.agent\.session" \ - Cargo.toml dist-workspace.toml crates scripts docs .github dist README.md AGENTS.md config macos-hardening -``` +Goal: + +1. prove the authoritative `PLAN.md` acceptance criteria on the merged tree; +2. record commands, results, and manual proof notes in `validation-wall.md`. -### Cargo Gates +Required commands: ```bash -cargo fmt --all -- --check -cargo clippy --workspace --all-targets -- -D warnings -cargo build --workspace -cargo test --workspace -- --nocapture +cargo test -p shell dispatch_contract -- --nocapture +cargo test -p shell agent_public_control_surface_v1 -- --nocapture +cargo test -p shell repl_world_first_routing_v1 -- --nocapture +cargo test -p shell agent_successor_contract_ahcsitc0 -- --nocapture cargo test -p shell -- --nocapture -cargo test -p world-service -- --nocapture -cargo test -p transport-api-types -- --nocapture -cargo test -p transport-api-core -- --nocapture -cargo test -p transport-api-client -- --nocapture -cargo test -p world-mac-lima -- --nocapture -cargo test -p world-windows-wsl -- --nocapture -cargo test -p substrate-replay -- --nocapture +cargo clippy -p shell --all-targets -- -D warnings +cargo fmt --all -- --check ``` -### Operator Surface Gates +Parent proof record must include: -Linux: +1. command results; +2. manual checks; +3. any skipped coverage with reason; +4. exact merged commit SHA used for validation. -```bash -systemctl status substrate-world-service.socket --no-pager -systemctl status substrate-world-service.service --no-pager -systemctl list-unit-files | rg "substrate-world-agent" -substrate host doctor --json | jq . -substrate world doctor --json | jq . -``` +### `G6` Final Acceptance Gate -macOS Lima: +Parent verifies the merged tree against the final acceptance matrix below and then either: -```bash -scripts/mac/lima-warm.sh --check-only -scripts/mac/smoke.sh -limactl shell substrate systemctl status substrate-world-service.socket -limactl shell substrate systemctl status substrate-world-service.service -``` +1. accepts the run and creates `RUN_COMPLETE`; or +2. blocks the run and creates `RUN_BLOCKED`. -WSL: +### `P2` Parent Closeout -```powershell -pwsh -File scripts/windows/wsl-warm.ps1 -DistroName substrate-wsl -ProjectPath (Resolve-Path .) -pwsh -File scripts/windows/wsl-smoke.ps1 -pwsh -File scripts/windows/wsl-doctor.ps1 -``` +Parent actions: -### Release Gates +1. finalize `run-state.json`, `queue.json`, `session-log.md`, `validation-wall.md`, and `final-summary.md`; +2. record accepted merge SHAs and final merged tip SHA; +3. create `RUN_COMPLETE` only if `G6` passed; +4. leave rejected or blocked worker branches unmerged. -```bash -./dist/scripts/assemble-release-bundles.sh -rg -n "world-service|substrate-world-service" dist/release-template.md dist/scripts/assemble-release-bundles.sh -``` +## Context-Control Rules + +Parent live context: + +1. authoritative dirty `PLAN.md` snapshot; +2. current controller snapshot; +3. current accepted gate tip SHA; +4. lane ownership map; +5. hotspot freeze map; +6. queue state and current blocker state. + +Every worker prompt should contain: + +1. task ID and lane ID; +2. goal and acceptance criteria; +3. owned files; +4. forbidden surfaces; +5. authoritative source-lock reminder; +6. required tests or commands; +7. explicit stop conditions. + +Every worker must return: -### Fail-Closed Checks - -1. `protocol: substrate.agent.session` validates. -2. Old `uaa.agent.session` live configs or persisted rows are rejected clearly after cutover. -3. World-required routing still hard-fails if renamed service discovery breaks. -4. No doctor, help, or remediation string points operators back to `substrate-world-agent`. - -### Manual Validation Proof Points - -Parent closeout must explicitly record proof of: - -1. canonical config and docs use `protocol: substrate.agent.session`, -2. live trace identity emits `substrate.agent.session`, -3. local host↔world contract crates are `transport-api-*`, -4. local in-world daemon surfaces are `world-service*`, -5. `world-api` remains unchanged, -6. stale old-name live surfaces are gone outside the historical allowlist, -7. world-required rename drift still fails closed, -8. upstream `agent_api.*` wording remains unchanged. - -## Blocked-Run Contract - -If the run blocks, parent writes: - -- `/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/.runs/plan-uaa-boundary-and-naming-cleanup/blocked.json` - -Required fields: - -- `run_id` -- `authoritative_branch` -- `timestamp` -- `current_task_id` -- `gate_state` -- `summary` -- `stop_condition_id` -- `worker_lane` -- `blocking_files` -- `accepted_outputs` -- `quarantined_outputs` -- `next_required_parent_action` - -Blocked-run rules: - -1. `blocked.json` is parent-written only. -2. It is written exactly once at the stop point. -3. No further worker launches occur after it is written. -4. Existing worker outputs are either accepted and recorded or quarantined and named explicitly. - -## Context-Control Rules For Parent And Workers - -### Parent Rules - -1. Parent keeps the canonical plan state in `.runs/**` only. -2. Parent is the only actor allowed to reopen a gate or reinterpret the rename contract. -3. Parent must keep the frozen rename matrix and live boundary visible in every worker brief. -4. Parent owns all cross-lane rebases, merges, and acceptance decisions. -5. Parent records GitNexus findings, handoffs, validation commands, and final acceptance artifacts. - -### Worker Rules - -1. Read [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md), this controller, and only the lane-relevant files before editing. -2. Do not widen scope beyond the lane boundary. -3. Run GitNexus impact analysis before editing symbols in the owned lane. -4. Stop and escalate on `HIGH` or `CRITICAL` impact. -5. Do not write `.runs/**`. -6. Do not edit [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md) or [ORCH_PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/ORCH_PLAN.md). -7. Handoffs must include: - - changed files - - commands run - - exit codes - - GitNexus impact summary - - `gitnexus_detect_changes()` output - - blockers or residual risks - -### Context Hygiene - -1. No worker loads broad unrelated surfaces just because the rename is repo-wide. -2. Lane A reads only the code, manifests, config, and code-adjacent tests it owns. -3. Lane B reads only scripts, CI, release, and packaging surfaces after `G1`. -4. Lane C reads only docs, ADRs, truth docs, and guidance surfaces after `G1`. -5. Archived or historical files are read only when needed to prove allowlist rationale. - -## Acceptance / Completion Criteria - -The run is complete only when all of the following are true on the merged tree: - -1. `P0`, `A1`, `A2`, `B1`, `C1`, `P1`, `P2`, and `P3` are accepted by the parent. -2. The rename contract in `.runs/**` matches [PLAN.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/PLAN.md) exactly. -3. The foundation lane completed before the parallel lanes started. -4. The live-surface grep wall is green outside the historical allowlist. -5. Positive guardrails still show upstream `agent_api.*`, unchanged `world-api`, and the new canonical local names. -6. Cargo fmt, clippy, build, workspace tests, and targeted renamed-package tests are green. -7. Linux, Lima, and WSL operator surface gates are green or explicitly recorded as environment-blocked with no ambiguity about required follow-up. -8. Release bundle assembly is green and stages the renamed payloads. -9. Fail-closed checks prove stale `uaa.agent.session` and broken renamed service discovery do not silently succeed. -10. Code, scripts, release bundles, CI, docs, ADRs, fixtures, and operator guidance all tell one coherent boundary story. -11. Parent runs final `gitnexus_detect_changes()` and confirms the changed scope matches the plan. -12. Parent writes `final-summary.md` and marks `sentinels/RUN_COMPLETE`. - -## Task Acceptance Checklist - -| Task | Done means | +1. files changed; +2. tests run; +3. tests not run; +4. assumptions made; +5. blockers encountered; +6. any evidence that the frozen gate assumptions are no longer valid. + +Context discipline: + +1. Workers get only the context needed for their lane. +2. Workers treat all frozen hotspots outside their ownership as read-only. +3. Workers stop on unexpected pre-existing edits in owned files. +4. Workers do not broaden scope, normalize unrelated formatting, or rewrite docs unless assigned. +5. Full worker transcripts are not the run ledger; parent-authored `.runs/**` artifacts are. + +## Validation Wall And Final Acceptance Matrix + +The merged tree must satisfy all of the following before the run is complete. + +| Final check | Mapped PLAN requirement | | --- | --- | -| `P0` | Rename contract, live boundary, historical allowlist, validation wall, branch map, lane ownership, and `.runs` artifact contract are frozen in parent-owned artifacts. | -| `A1` | `substrate.agent.session` is canonical on owned live code and config surfaces, stale `uaa.agent.session` fails closed, and owned tests or greps show the cutover. | -| `A2` | `world-service*` and `transport-api*` compile and test on owned code surfaces, `world-api` stays unchanged, and mixed-boundary imports remain clear. | -| `B1` | Scripts, CI, release, and operator helpers use the renamed service and transport families with no stale canonical `substrate-world-agent` guidance. | -| `C1` | Docs, ADRs, and truth docs teach the same boundary story as the merged code and script surfaces and use the new canonical names. | -| `P1` | Parent has integrated accepted parallel outputs, replayed or quarantined overlaps, and recorded the real integration history in `.runs/**`. | -| `P2` | Grep, cargo, operator, release, and fail-closed checks are executed and recorded against the merged validation candidate. | -| `P3` | Final GitNexus scope verification, final summary, terminal sentinel, and any residual-risk notes are written by the parent. | +| `FA1` host-scoped resolved launch truth persists into `HostAttachContract` | Acceptance criteria 1 | +| `FA2` persisted attach resolution reuses persisted capabilities and attach knobs instead of permissive defaults | Acceptance criteria 2 | +| `FA3` inventory-backed resolution merges validated `policy_overlay` into `effective_policy` as narrowing truth | Acceptance criteria 3 | +| `FA4` approved capability override family works as narrowing-only; unsupported fields fail closed with exact diagnostics | Acceptance criteria 4 | +| `FA5` retained member turns consume a shared-contract-derived subset and do not reconstruct hidden launch semantics | Acceptance criteria 5 | +| `FA6` equivalent human and orchestrator cold starts yield equivalent contract truth for backend, scope, capabilities, attach knobs, and policy | Acceptance criteria 6 | +| `FA7` merged contract floor is sufficient for slices 30 and 31 without inventing a second override or attach-truth model | Acceptance criteria 7 and 8 | +| `FA8` targeted shell tests, full `cargo test -p shell`, `clippy`, and `fmt --check` are green on the merged tree | Definition of done 8 | +| `FA9` docs for 29, 29.5, 30, and 31 match shipped semantics on the merged tree | Definition of done 9 | + +Final acceptance checklist: + +1. `FA1` through `FA9` are all satisfied on one merged tree. +2. No frozen hotspot was reopened after `G3`. +3. The only safe parallel window remained within disjoint ownership. +4. `PLAN.md` source lock remained authoritative for the duration of the run. +5. `validation-wall.md` and `final-summary.md` both point at the same merged tip SHA. ## Assumptions -1. The authoritative execution branch remains `chore/uaa-boundary-and-naming-cleanup`. -2. Fresh worker worktrees can be created under `/Users/spensermcconnell/__Active_Code/atomize-hq/.worktrees/`. -3. GitNexus is available or can be refreshed before worker edits begin. -4. The repo can tolerate a direct live-name cutover with fail-closed handling and no supported migration layer for stale `uaa.agent.session` runtime state. -5. The final validation wall may require Linux, Lima, and WSL prerequisites already described in repo docs; if a platform environment is unavailable, the parent records that explicitly instead of silently skipping it. -6. Historical files outside the live-surface wall may retain old tokens only when the parent records the allowlist rationale. -7. This controller supersedes the stale async persistent-session orchestration topic previously present in this file. +1. The current working-tree rewrite of `PLAN.md` is authoritative for this run. +2. Parent integration remains on `feat/gateway-mediated-llm-fulfillment`. +3. Worker worktrees can be created under `/home/azureuser/__Active_Code/atomize-hq/.worktrees/`. +4. The slice stays limited to shared dispatch contract closeout and parity hardening. +5. There is no public scope expansion or lazy-attach product work in this run. +6. Parent can preserve the dirty authoritative checkout while cutting clean worker worktrees from committed gate tips. + +## Completion Behavior + +1. Parent merges accepted lanes in gate order only. +2. Parent runs the validation wall only after docs are accepted. +3. Parent marks the run complete only after `G6`. +4. If blocked, parent writes `blocked.json`, leaves rejected worker branches unmerged, and records the exact stop condition in both `session-log.md` and `final-summary.md`. +5. Workers never self-certify completion, never write `.runs/**`, and never own final acceptance. + +## Completion Target + +The target outcome is one merged tree where durable attach truth, overlay truth, override truth, retained member parity, and downstream docs all agree, with no hidden second contract dialect left for slices 30 or 31 to rediscover. diff --git a/PLAN.md b/PLAN.md index 81beaabf9..85caccb8a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,847 +1,687 @@ -# PLAN: Gateway-Mediated LLM Fulfillment Without Lifecycle Regression +# PLAN: Authoritative Host Attach Truth And REPL Cold-Start Parity -Source SOW: [28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md) -Primary contract anchors: [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md), [ADR-0040](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0040-substrate-gateway-boundary-and-runtime-ownership.md), [ADR-0041](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0041-substrate-gateway-backend-adapter-contract.md), [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md), [docs/contracts/substrate-gateway-runtime-parity.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/contracts/substrate-gateway-runtime-parity.md), [docs/contracts/substrate-gateway-backend-adapter-protocol.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/contracts/substrate-gateway-backend-adapter-protocol.md) +Source SOW: [29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md](llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md) +Primary code anchors: [crates/shell/src/repl/async_repl.rs](crates/shell/src/repl/async_repl.rs), [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs), [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs), [crates/shell/src/execution/agent_runtime/state_store.rs](crates/shell/src/execution/agent_runtime/state_store.rs), [crates/shell/src/execution/agents_cmd.rs](crates/shell/src/execution/agents_cmd.rs), [crates/shell/src/execution/agent_runtime/control.rs](crates/shell/src/execution/agent_runtime/control.rs) +Primary test anchors: [crates/shell/tests/agent_public_control_surface_v1.rs](crates/shell/tests/agent_public_control_surface_v1.rs), [crates/shell/tests/repl_world_first_routing_v1.rs](crates/shell/tests/repl_world_first_routing_v1.rs), [crates/shell/tests/agent_successor_contract_ahcsitc0.rs](crates/shell/tests/agent_successor_contract_ahcsitc0.rs), unit tests in [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs), unit tests in [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +Downstream dependency docs: [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md), [30-public-world-scoped-agent-start-and-capability-flags.md](llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md), [31-lazy-host-attach-for-host-rooted-world-start.md](llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md) Execution branch: `feat/gateway-mediated-llm-fulfillment` Base branch: `main` -Plan type: runtime seam replacement with lifecycle freeze -Status: implementation-ready execution plan, unified to `/autoplan` plus `/plan-eng-review` rigor on 2026-05-22 +Plan type: implementation-ready residual contract-authority hardening slice, no UI scope, strong shell/runtime DX scope +Status: implementation-ready ## Objective -Move all production prompt-bearing LLM fulfillment onto the existing `substrate-gateway` adapter seam without changing the already-landed public lifecycle contract. +Finish the remaining contract-authority gaps so every host-rooted session birth path persists the same attach-relevant truth and every later attach path consumes that truth without regaining manifest-era defaults or permissive fallbacks. -After this slice lands: +This plan is complete only when all of the following are true: -1. host prompt-bearing execution no longer directly constructs `AgentWrapperGateway`, `CodexBackend`, or `ClaudeCodeBackend` in shell-owned runtime code, -2. world-member prompt-bearing execution no longer directly constructs those objects in `world-service`, -3. host first prompt, host follow-up prompt, world first targeted prompt, and world resumed follow-up all use one production fulfillment story: - - stable backend id selection first, - - gateway adapter dispatch second, - - lifecycle semantics unchanged, -4. integrated auth still uses the existing FD auth-bundle handoff, -5. `start`, `turn`, `reattach`, `stop`, `Accepted -> terminal`, `parked_resumable`, `awaiting_attention`, and typed `/v1/member_turn/stream` behavior remain unchanged from the operator's point of view. +1. REPL host cold start resolves through the same shared dispatch contract family already used by public start. +2. The first persisted host-rooted orchestration session stores `HostAttachContract` derived from resolved launch truth, not `HostAttachContract::from_manifest(...)`. +3. Persisted attach resolution treats `HostAttachContract` as the only durable attach baseline and applies only a bounded attach-time overlay. +4. Missing durable truth fails closed in modern steady-state paths instead of silently recovering through `Policy::default()` or manifest reconstruction. +5. Slices 30 and 31 can build on 29.75 without reopening attach-baseline semantics. -This is a seam replacement. It is not a public contract expansion, not a lifecycle redesign, and not a backend-matrix expansion project. +## Executive Summary -## Acceptance Criteria +This is a hardening slice, not a redesign. The repo already has the shared vocabulary, the durable attach structure, and most of the public-start authority flow. The remaining problem is that equivalent host-rooted sessions can still persist different attach truth depending on how they were born, and later attach code can still regain broader semantics than the durable baseline intended. -This slice is complete only when all of the following are true: +The implementation direction is therefore locked: -1. No production code under `crates/shell/src/execution/agent_runtime/`, `crates/shell/src/repl/`, or `crates/world-service/src/member_runtime.rs` directly instantiates `AgentWrapperGateway`, `CodexBackend`, or `ClaudeCodeBackend` for prompt-bearing execution. -2. Host initial prompt execution and host follow-up prompt execution both fulfill through the gateway adapter seam rather than shell-local backend registration. -3. World launch-time first targeted turn and world resumed follow-up turn both fulfill through the gateway adapter seam rather than world-local backend registration. -4. The effective adapter dispatch target remains the already-landed stable backend id chosen before execution begins. -5. The visible lifecycle contract does not regress: - - `start` uses the user prompt as the real first prompt, - - `turn` uses the user prompt as the real follow-up prompt, - - `reattach` remains recovery-only, - - `stop` remains canonical closeout, - - `Accepted` still terminates with an explicit terminal envelope. -6. World follow-up still routes through typed `MemberTurnSubmitRequestV1` plus `/v1/member_turn/stream`. -7. Detached world follow-up still fails closed. -8. Integrated auth still travels through the FD auth-bundle handoff and does not regress to child secret env vars. -9. The shell no longer prepares and discards a shell-local authoritative world-member gateway object before actual world dispatch. -10. Tests and invariants prove that host and world prompt-bearing execution no longer maintain separate direct backend-registration tables. +1. `ResolvedLaunchContract` is the only birth-time authority source. +2. `HostAttachContract` is the only durable attach baseline. +3. Reattach-style attach callers may only honor that baseline, narrow it where permitted, or select among explicitly baseline-permitted modes when the durable baseline already encodes multiple allowed realizations. They may never replace or broaden it. +4. Fork is not a caller overlay on the source session. It consumes source durable truth and derives a new successor baseline for a new successor session. +5. 29.75 is greenfield for durable attach truth: no legacy compatibility helper or manifest-backfill path is part of this slice. ## Locked Decisions -These decisions are already made. Implementation does not reopen them. +These are frozen for implementation. Do not keep multiple interpretations open in code or docs. | Topic | Locked decision | Why | | --- | --- | --- | -| Public lifecycle | Keep `start`, `turn`, `reattach`, and `stop` exactly as they are | ADR-0047 is already the lifecycle floor | -| Public routing selector | Keep exact `--backend ` routing on prompt-bearing follow-up | Selector widening would change operator meaning | -| World follow-up seam | Keep `MemberTurnSubmitRequestV1` plus `POST /v1/member_turn/stream` | That seam is already landed and frozen | -| Initial prompt semantics | The user prompt remains the true first prompt | Hidden bootstrap prompts are a regression | -| Detached world posture | Detached world follow-up remains fail-closed | No ownership-bypass recovery path is allowed | -| Auth carrier | Keep `SUBSTRATE_LLM_AUTH_BUNDLE_FD` and the existing auth bundle contract | Secure carrier already exists and is landed | -| Backend selection grammar | Keep stable backend ids as `:` | Selection and adapter contracts already own this | -| Gateway ownership | Substrate owns lifecycle and placement; `substrate-gateway` owns adapter dispatch and backend internals | ADR-0040 already fixed the boundary | -| Scope boundary | Do not absorb SOW 29 or SOW 30 | Shared dispatch envelope and public world-scoped start are separate slices | +| Durable attach object | `HostAttachContract` remains the only persisted attach object | A second durable model would recreate the split 29.75 exists to close | +| Birth-time authority | Host-rooted session birth persists attach truth from `ResolvedLaunchContract`, not from manifest defaults | Manifest defaults are runtime materialization detail, not contract authority | +| REPL cold start posture | REPL host cold start must join the shared dispatch contract family before the first session write | Public start and REPL cold start must stop producing different durable truth for the same backend | +| Modern missing truth | Missing or invalid durable attach truth fails closed in modern paths | Silent repair through permissive defaults broadens authority and hides corruption | +| Supported persisted-state posture | Supported persisted sessions must contain a present and valid `host_attach_contract` plus valid policy snapshot; absent, null, malformed, or incomplete durable truth fails closed | Greenfield 29.75 does not carry a compatibility classifier or repair surface | +| Attach overlay semantics | Overlay may honor baseline, narrow it where permitted, or select among explicitly baseline-permitted modes when the durable baseline already encodes multiple allowed realizations; it may not silently broaden or replace baseline semantics | Later callers cannot reinterpret the same durable session ad hoc | +| `requested_execution_scope` | For persisted attach, execution scope stays baseline-owned and immutable | 29.75 is not reopening scope selection | +| `host_execution_client_start` | Overlay may keep baseline, narrow `StartNow -> Defer`, or select among explicitly baseline-permitted modes. Because 29.75 does not add multi-mode durable encoding, current 29.75 implementations only exercise honor-or-narrow behavior. | This matches the SOW contract without letting this slice silently grow a second encoding model | +| `attach_mode_preference` | Overlay may keep baseline, narrow toward stricter continuity (`FreshAllowed -> ContinuityPreferred -> ContinuityRequired`), or select among explicitly baseline-permitted modes. Because 29.75 does not add multi-mode durable encoding, current 29.75 implementations only exercise honor-or-narrow behavior. | Continuity policy is durable baseline truth, and selection is only legal when the durable baseline explicitly says multiple realizations are allowed | +| Fork semantics | Fork may not broaden the source session by pretending to be an attach overlay. It must consume source durable truth, allocate a new successor session, and persist a new successor `HostAttachContract` through explicit successor-derivation rules. | This keeps the no-broadening overlay rule intact and prevents fork from becoming the loophole that collapses the model | +| Successor-only behavior scope | 29.75 freezes that fork uses explicit successor-baseline derivation rather than overlay broadening. It does not freeze the final successor attach-behavior policy for continuity-versus-fresh or lazy-attach defaults; that remains downstream slice 31 work unless the SOW is amended. | This keeps 29.75 focused on contract authority rather than absorbing 31's product-policy decisions | +| Retained member follow-up parity | Existing `MemberDispatchParitySubset` stays the retained-turn contract; 29.75 must not invent a second retained-member dialect | 29.5 already landed this architecture far enough to build on | +| Multi-mode baseline encoding | 29.75 does not extend `HostAttachContract` or persisted session schema to encode a set of allowed attach modes or client-start modes | This slice freezes authority semantics, not a new durable representation; “selection” remains a contract-level allowance that is dormant unless an explicit baseline encoding already exists | ## Scope ### In scope -1. Replacing direct host prompt-bearing fulfillment with gateway-mediated fulfillment. -2. Replacing direct world-member prompt-bearing fulfillment with gateway-mediated fulfillment. -3. Removing hidden synthetic bootstrap-prompt behavior from production world-member startup. -4. Collapsing duplicated backend-registration logic into one production fulfillment seam. -5. Reusing the landed gateway runtime, adapter, backend-selection, and auth-bundle contracts. -6. Tightening regression coverage so lifecycle invariants stay frozen while the seam moves. -7. Updating truth docs that would otherwise keep describing the bypass as acceptable steady-state behavior. - -## NOT in scope - -1. Public `--scope world` root `start`. -2. Shared dispatch-envelope and capability-override work from SOW 29. -3. Human capability flags and world-root public start work from SOW 30. -4. New backend-id grammar, new backend-selection semantics, or allowlist redesign. -5. Generic multi-backend integrated expansion beyond the already-wired bindings. -6. Moving the orchestrator into the world. -7. Durable-session redesign or new posture semantics. -8. New public config or policy surfaces already owned by ADR-0027. -9. Reintroducing secret-bearing process env vars as the normal auth path. +1. Carry resolved-contract truth through REPL host cold start before first orchestration-session persistence. +2. Stop seeding modern host attach truth from `HostAttachContract::from_manifest(...)`. +3. Freeze attach-time overlay rules for persisted attach resolution and enforce them in code, diagnostics, and tests. +4. Remove steady-state fallback to `Policy::default()` and silent manifest reconstruction in modern paths. +5. Update downstream SOWs and this root plan so 30 and 31 depend on 29.75 correctly. + +### NOT in scope + +1. Public `substrate agent start --scope world`. +2. Attach-worker behavior and lazy attach trigger policy from slice 31. +3. Widening capability override families beyond the already-landed bounded narrowing set. +4. Inventing a second durable attach structure or third baseline domain. +5. Changing retained world-member transport format. +6. Adding new crates, binaries, or packaging flows. +7. Extending persisted attach schema to represent multi-mode allowed-mode sets for `host_execution_client_start` or `attach_mode_preference`. ## Step 0: Scope Challenge -This section answers the `/plan-eng-review` questions before implementation starts. +### 0A. What already exists -### What already exists +The repo already contains the pieces this slice should reuse. -| Sub-problem | Existing code or contract | Reuse decision | +| Sub-problem | Existing code | Reuse decision | | --- | --- | --- | -| Lifecycle truth | `ADR-0047`, `HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md`, `crates/shell/tests/agent_public_control_surface_v1.rs`, `crates/shell/tests/repl_world_first_routing_v1.rs` | Reuse exactly. This slice must not reinterpret lifecycle meaning. | -| Gateway runtime lifecycle and status surface | `docs/contracts/substrate-gateway-runtime-parity.md`, `crates/shell/src/builtins/world_gateway.rs`, `crates/world-service/src/gateway_runtime.rs` | Reuse the typed runtime ownership split. Do not reintroduce raw wrapper construction as runtime truth. | -| Adapter protocol and backend selection | `docs/contracts/substrate-gateway-backend-adapter-selection.md`, `docs/contracts/substrate-gateway-backend-adapter-protocol.md` | Reuse exactly. Backend selection remains explicit and stable before execution. | -| Auth carrier | `crates/common/src/gateway_auth_bundle.rs`, `crates/world-service/src/gateway_runtime.rs`, `crates/gateway/src/server/mod.rs` | Reuse exactly. No second auth seam. | -| Launch-time world-member transport | `crates/shell/src/execution/routing/dispatch/world_ops.rs`, `crates/world-service/src/service.rs` | Reuse the typed transport. Replace only the fulfillment implementation behind it. | -| Resumed world-member transport | `MemberTurnSubmitRequestV1`, `/v1/member_turn/stream` | Reuse exactly. No version bump or selector redesign. | - -### Minimum honest change +| Shared launch resolution vocabulary | `DispatchRequestEnvelope`, `ResolvedLaunchContract`, `DispatchBaselineKind` in [dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs) | Reuse. Do not invent a REPL-only launch dialect. | +| Public start durable attach persistence | `persist_resolved_start_attach_contract(...)` in [agents_cmd.rs](crates/shell/src/execution/agents_cmd.rs) | Reuse semantics. Pull REPL cold start onto this same authority model. | +| Durable attach storage | `HostAttachContract`, `HostAttachLaunchKnobs`, `OrchestrationSessionRecord` in [orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs) | Reuse. Tighten constructors and recovery rules. | +| Persisted attach resolution | `resolve_persisted_host_attach_contract(...)` in [dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs) | Reuse entrypoint. Tighten overlay semantics and fail-closed behavior. | +| REPL cold start bootstrap | `resolve_host_orchestrator_bootstrap(...)` and `prepare_host_orchestrator_runtime_from_resolved(...)` in [async_repl.rs](crates/shell/src/repl/async_repl.rs) | Reuse flow. Change it to carry `ResolvedLaunchContract` instead of only `RuntimeSelectionDescriptor`. | +| Runtime materialization | `validate_runtime_realizability(...)` and `RuntimeSelectionDescriptor` | Reuse. Descriptor materialization stays downstream of contract resolution. | +| Retained member parity | `MemberDispatchParitySubset`, `retained_member_dispatch_parity_subset(...)`, `build_member_dispatch_transport_request(...)` in [async_repl.rs](crates/shell/src/repl/async_repl.rs) | Reuse. Keep parity subset intact while making parent host session birth truthful. | +| Public attach gating | `resolve_public_control_target(...)` in [state_store.rs](crates/shell/src/execution/agent_runtime/state_store.rs) | Reuse. It already enforces capability gates using durable attach truth. | -The minimum honest change is still cross-cutting: +### 0B. Exact gaps this plan closes -1. remove all production direct wrapper/backend registration from shell host prompt execution, -2. remove all production direct wrapper/backend registration from world-member prompt execution, -3. convert world bootstrap from a fake agent prompt into a control-plane concern rather than a user-visible turn, -4. route both first-turn and resumed-turn execution through one gateway-mediated production seam, -5. prove the seam change with tests that pin lifecycle behavior and auth carrier behavior. +The live code still leaves four blocking gaps: -Anything smaller leaves the repo in the current contradictory state: the contracts say gateway-mediated runtime ownership, but the main prompt-bearing runtime still does something else. +1. `OrchestrationSessionRecord::new(...)` seeds `host_attach_contract` via `HostAttachContract::from_manifest(...)`, and REPL cold start persists that first write unchanged. +2. `sync_host_attach_contract(...)` repopulates a missing contract from manifest truth instead of treating missing modern durable truth as an error. +3. `resolve_persisted_host_attach_contract(...)` deserializes `effective_policy` and then falls back to `unwrap_or_default()`, which silently regains `Policy::default()` in modern paths. +4. Persisted attach resolution still lets request-time callers replace parts of `host_execution_client_start` and `attach_mode_preference` too freely. -### Complexity check +### 0C. Minimum honest change -This slice touches more than 8 files and crosses shell plus world runtime boundaries. That is acceptable because the duplication already spans those modules. +The minimum honest change set is: -The constraint is not "touch fewer files at any cost." The constraint is: +1. Extend REPL bootstrap to compute and retain a `ResolvedLaunchContract`. +2. Add an explicit host-orchestrator session seed path that accepts durable attach truth up front. +3. Quarantine `from_manifest(...)` out of modern persistence and steady-state attach paths entirely. +4. Centralize persisted attach overlay validation in one helper owned by `resolve_persisted_host_attach_contract(...)`. +5. Extend unit and integration tests around the exact authority seams above. +6. Update downstream docs so the dependency floor is truthful. -1. no new public surface, -2. no new runtime framework, -3. no new schema version, -4. no new policy/config layer, -5. no second fulfillment seam hidden behind nicer naming. +Anything smaller leaves 29.75 half done and pushes the same ambiguity into 30 or 31. -The correct posture is narrow in contract, broad in implementation: +### 0D. Complexity check -1. narrow in contract because the lifecycle and selectors stay frozen, -2. broad in implementation because all production bypasses must move together. +This slice touches more than eight files and crosses REPL, runtime, state store, CLI, tests, and docs. That would normally be a smell. Here it is justified because: -### Search check +1. The work is narrow in concept even if it spans multiple seams. +2. The change is contract-hardening, not new product behavior. +3. Each touched file participates in the same authority chain. +4. There is still no need for a new crate or a new persisted state model. -This plan intentionally prefers existing repo seams over new architecture. It reuses: +### 0E. Search, completeness, and distribution -1. the existing gateway runtime, -2. the existing adapter contract, -3. the existing backend-id selection contract, -4. the existing auth-bundle carrier, -5. the existing typed member-turn transport. +This slice should do the complete version, not the doc-only shortcut. -That is a Layer 1 choice, not a custom-framework choice. +The shortcut is to narrow wording and accept current behavior. That saves little implementation time and guarantees later slices have to rediscover attach-authority bugs under more product pressure. -### Expected blast radius +The complete version is: -Treat this as the planned change budget. If implementation spills outside these surfaces, stop and justify the expansion before continuing. +1. Fix REPL birth-time persistence. +2. Fix persisted attach overlay semantics. +3. Fix modern fail-closed behavior. +4. Prove parity and failure cases in tests. +5. Align downstream docs. -| Surface class | Expected modules or files | Why this is the right boundary | -| --- | --- | --- | -| Production host runtime | `crates/shell/src/execution/agent_runtime/`, narrowly-targeted paths in `crates/shell/src/repl/async_repl.rs` | This is where host prompt submission and shell-owned bootstrap prep still treat local gateway construction as runtime truth. | -| Production world runtime | `crates/world-service/src/member_runtime.rs`, only minimum plumbing in `crates/world-service/src/service.rs` if needed | This is where launch-time and resumed world-member turns still directly build wrappers and backends. | -| Transport and lifecycle tests | `crates/shell/tests/agent_public_control_surface_v1.rs`, `crates/shell/tests/repl_world_first_routing_v1.rs`, `crates/shell/tests/agent_successor_contract_ahcsitc0.rs`, `crates/world-service/tests/*`, `crates/shell/tests/world_gateway.rs`, `crates/gateway/tests/openai_shared_parity.rs` | These are the proof surfaces for lifecycle freeze, seam convergence, and auth continuity. | -| Truth docs | `PLAN.md`, `llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md`, gateway contracts, usage docs, gap matrix | The runtime story changes here after code changes, not before. | - -### Explicit stop conditions - -If implementation requires any of the following, stop and revise the plan first: - -1. a new CLI flag or public verb, -2. a new JSON schema or route for world follow-up, -3. a new policy or config surface, -4. a new crate just to move the seam, -5. a synthetic prompt-bearing bootstrap step, -6. a fallback auth path through child secret env vars. - -## Current Repo Truth - -These facts are concrete in the repo today. This plan is anchored to them. - -1. The shell host runtime still builds a local `AgentWrapperGateway` and directly registers concrete backends in `crates/shell/src/execution/agent_runtime/registry.rs`. -2. Host follow-up prompt submission still rebuilds that direct gateway in `submit_host_prompt_turn()` inside `crates/shell/src/execution/agent_runtime/control.rs`. -3. `crates/shell/src/repl/async_repl.rs` still carries gateway-shaped local runtime state and bootstrap helpers that treat local wrapper construction as an execution concern. -4. The world member runtime still builds another direct `AgentWrapperGateway` in `crates/world-service/src/member_runtime.rs`. -5. The world member runtime still contains a synthetic runtime bootstrap prompt via `runtime_bootstrap_prompt()`. That is incompatible with the frozen "the real user prompt is the first prompt" rule. -6. The typed world-member follow-up seam is already real and should be preserved: - - request type: `MemberTurnSubmitRequestV1`, - - route: `/v1/member_turn/stream`. -7. The gateway runtime and auth-bundle seams are already real and already owned: - - auth bundle contract: `crates/common/src/gateway_auth_bundle.rs`, - - world-service gateway runtime handoff: `crates/world-service/src/gateway_runtime.rs`, - - gateway server consumption of the FD bundle: `crates/gateway/src/server/mod.rs`. -8. Current shell world dispatch already packages typed launch-time world-member requests with `member_dispatch.initial_prompt`. The transport seam is not what needs redesign. -9. The current contradiction is operational, not contractual: - - docs and ADRs already say gateway-mediated runtime ownership, - - production prompt-bearing execution still bypasses that seam. - -## Frozen Execution Contract - -If implementation wants to violate any rule below, stop and revise the plan first. - -1. `substrate agent start` remains host-only in v1. -2. `start` uses the user prompt as the true initial prompt. -3. `turn --session --backend --prompt ...` remains the exact public follow-up contract. -4. `reattach --session ` remains recovery-only and must not submit a prompt. -5. `stop --session ` remains canonical closeout for attached, parked, and attention-needed durable host sessions. -6. Once a public prompt request emits `Accepted`, the bridge still terminates with an explicit terminal envelope. -7. Durable postures remain authoritative: - - `parked_resumable` means detached, resumable, no pending inbox work, - - `awaiting_attention` means detached, resumable, pending inbox work, - - `terminal` remains the only non-routable posture family. -8. Detached world follow-up remains fail-closed until valid host ownership is restored. -9. Linux world follow-up remains on typed `MemberTurnSubmitRequestV1` plus `/v1/member_turn/stream`. -10. Stable backend ids remain selectors only. They do not become auth carriers or provider-quirk carriers. -11. Integrated auth remains on the FD auth-bundle contract. Partial env auth remains invalid; blocked envs do not fall back through a bypass path. -12. Production bootstrap mechanics must not surface as hidden user prompts, synthetic warm-up turns, or replay-visible fake agent prompts. - -## Target Architecture - -### Canonical runtime story after cutover +No new binary, package, image, or release channel is introduced, so distribution work is unchanged. -```text -Public shell lifecycle - substrate agent start|turn|reattach|stop - | - v - lifecycle + routing owner (shell) - | - stable backend-id selection + allowlist - | - v - gateway-mediated fulfillment boundary - (typed runtime surface + adapter protocol) - / \ - / \ - v v - host prompt-bearing path world-member prompt-bearing path - no local wrapper registry no member-local wrapper registry - | | - +-----------+ +-----------+ - | | - v v - substrate-gateway adapter dispatch - | - v - concrete backend/provider internals -``` +## Contract Model -The important rule is not "host and world must share identical transport." The important rule is "host and world must share the same production fulfillment boundary." Placement-specific transport is allowed below that seam. Duplicate backend-registration tables above that seam are not. +### Baseline domains -### Runtime ownership after cutover +After 29.75 there are still only two baseline domains: -| Surface | Owns | Must stop owning | +| Baseline domain | Used by | Authority source | | --- | --- | --- | -| `crates/shell/src/execution/agent_runtime/` | lifecycle state, prompt submission orchestration, posture persistence, trace publication | direct backend registration and local wrapper-gateway construction | -| `crates/shell/src/repl/async_repl.rs` | startup planning, world routing, typed member-dispatch request construction | preparing a shell-local authoritative world-member gateway for real execution | -| `crates/world-service/src/member_runtime.rs` | retained-member validation, typed member-turn handling, stream translation, placement checks | direct `AgentWrapperGateway` and concrete backend registration | -| `crates/world-service/src/gateway_runtime.rs` and gateway runtime contracts | runtime launch, auth-bundle handoff, managed runtime wiring | nothing new; these are reuse surfaces | -| `substrate-gateway` adapter protocol | backend resolution, capability validation, request normalization, normalized adapter execution | lifecycle and operator ownership | +| Inventory launch | public start, REPL host cold start, future world-scoped public start | `ResolvedLaunchContract` | +| Persisted host attach | `reattach`, detached-turn recovery, later lazy attach, plus source and successor sessions involved in `fork` | `HostAttachContract` | -### Canonical seam contract +There is no third domain for REPL-only launch semantics and no manifest-only fallback domain. -| Prompt-bearing path | Current state | Required post-slice state | -| --- | --- | --- | -| Host first prompt | shell-local runtime owns wrapper and backend construction | shell owns lifecycle and routing only; fulfillment is gateway-mediated | -| Host follow-up prompt | shell-local runtime rebuilds gateway and backend per turn | same gateway-mediated fulfillment seam as host first prompt | -| World first targeted turn | shell launches world flow, then world-member runtime owns wrapper and backend construction and synthetic bootstrap prompt | first targeted world prompt reaches gateway-mediated fulfillment with no synthetic user prompt | -| World resumed follow-up turn | typed `/v1/member_turn/stream` transport, but member runtime still owns backend construction | same gateway-mediated fulfillment seam as world first targeted turn | -| Gateway auth | existing FD bundle already available | same FD bundle, same fail-closed auth precedence | -| Backend selection | stable backend id already exists | same stable backend id; adapter dispatch happens after selection, not instead of selection | +### Data flow -The required convergence is: +```text +HOST COLD START +=============== +effective config + policy + inventory + | + v +shared dispatch resolver + | + +--> ResolvedLaunchContract + | | + | +--> RuntimeSelectionDescriptor (runtime materialization only) + | | + | `--> HostAttachContract::from_resolved_contract(...) + | + `--> OrchestrationSessionRecord persisted with authoritative attach truth + + +PERSISTED ATTACH +================ +DispatchRequestEnvelope + HostAttachContract + | + v +resolve_persisted_host_attach_contract(...) + | + +--> validate immutable baseline fields + +--> deserialize persisted policy snapshot + +--> apply bounded attach overlay + `--> ResolvedLaunchContract for attach/recovery + + +FORK SUCCESSOR DERIVATION +========================= +source HostAttachContract + | + v +explicit successor-derivation helper + | + +--> carry forward generalized durable truth + +--> clear continuity-specific state + +--> set successor attach defaults + `--> persist new successor HostAttachContract + + +RETAINED MEMBER TURN +==================== +existing member runtime + or pending replacement + or exact backend descriptor fallback + | + v +MemberDispatchParitySubset + | + v +build_member_dispatch_transport_request(...) +``` -1. one stable backend selection story, -2. one adapter dispatch story, -3. one auth carrier story, -4. two placement-specific transport stories at most, -5. zero production direct backend-registration tables above the gateway seam. +### Field ownership -### Helper constraints +#### `ResolvedLaunchContract` owns birth-time truth -Implementation is free to choose names, but it is not free to reopen the seam. +For host-rooted session birth, the following fields are authoritative only once they have passed through shared resolution: -If a new helper is needed, it may only do one of these jobs: +1. Backend identity and backend kind. +2. Protocol. +3. Execution scope. +4. Runtime launch descriptor. +5. Attach-relevant capabilities. +6. Effective policy snapshot. +7. Attach launch defaults. -1. translate already-selected runtime metadata into a gateway-mediated request, -2. normalize host and world resume metadata into one explicit input shape, -3. hide placement-specific transport details below the shared fulfillment boundary. +#### `HostAttachContract` owns durable attach truth -A new helper is not allowed to: +`HostAttachContract` is the persisted attach baseline for: -1. construct `AgentWrapperGateway`, -2. register concrete backends, -3. synthesize or rewrite the user prompt, -4. invent a second backend-selection path, -5. silently turn bootstrap work into a fake prompt-bearing run. +1. Backend identity. +2. Protocol. +3. Execution scope. +4. Resolved runtime descriptor. +5. Attach-relevant capabilities. +6. Effective policy snapshot. +7. Continuity selector. +8. Attach policy defaults: + - `requested_execution_scope` + - `host_execution_client_start` + - `attach_mode_preference` -### Bootstrap rule +#### Request envelope owns only bounded overlay inputs -The hidden `runtime_bootstrap_prompt()` path is not allowed to survive as production behavior. +For persisted attach, request-time input is not replacement truth. It is only: -The replacement rule is: +1. A no-op if it matches the durable baseline. +2. A narrowing request if the baseline semantics allow narrowing. +3. A selection among explicitly baseline-permitted modes when the durable baseline encodes more than one acceptable realization. +4. A hard error if it broadens or semantically replaces the durable baseline. -1. member runtime startup may still need a control-plane attach or readiness step, -2. that step must not consume or overwrite the user prompt, -3. that step must not appear as a synthetic agent turn in traces, persisted state, or captured stdin, -4. the first prompt-bearing agent execution must be the real user prompt. +### Fork is not an attach overlay -## Implementation Plan +Fork is a separate contract operation and must not be implemented as a broadening exception to persisted attach overlay rules. -### Execution order at a glance +1. `reattach`, detached-turn recovery, and later lazy attach consume an existing durable baseline through the bounded overlay model above. +2. `fork` consumes the source session's durable truth as input, but it does not reinterpret that source session through caller overlay. +3. `fork` allocates a new successor session and persists a new successor `HostAttachContract`. +4. Successor derivation may transform continuity-dependent fields because it is creating a new baseline for a new session, not broadening the old one. +5. 29.75 freezes the structural rule, not the final product-policy defaults: + - the successor path must be explicit and separate from persisted attach overlay; + - continuity-dependent fields must be handled by successor-only derivation logic, not by broadening the source session; + - the exact successor attach-behavior defaults remain downstream slice 31 work unless the SOW is explicitly amended. +6. Any implementation that tries to make `fork` legal by broadening the source session through `resolve_persisted_host_attach_contract(...)` is out of contract for this plan. -This slice should execute in exactly this order: +### Encoding rule for “selection among baseline-permitted modes” -1. freeze the contract and the grep wall, -2. cut over host prompt-bearing fulfillment, -3. cut over world-member prompt-bearing fulfillment, -4. reconverge in the shared conflict zone to remove bootstrap semantics and discarded shell-local gateway prep, -5. sync truth docs only after the runtime story is stable, -6. run the validation wall and publish one reviewer-facing proof artifact. +This plan makes the encoding rule explicit so 29.75 stays narrow. -The sequencing rule is strict: host and world cutovers may proceed in parallel for a short window, but `async_repl.rs`, bootstrap semantics, and shared lifecycle assertions are a serialized reconvergence step, not cleanup. +1. 29.75 does not introduce new persisted schema for “allowed mode sets” on `host_execution_client_start` or `attach_mode_preference`. +2. The current durable baseline in `HostAttachContract` remains single-valued for those fields unless the existing code already has an explicit multi-mode representation available in the same durable contract family. +3. Therefore the SOW’s “selection among baseline-permitted modes” rule remains part of the contract model, but it is intentionally dormant in 29.75 unless an explicit pre-existing durable encoding is already present. +4. In practical 29.75 implementation terms: if the durable baseline is single-valued, overlay behavior is honor-or-narrow only; any request that would require selecting among multiple baseline-permitted modes must fail closed rather than inventing new encoding semantics on the fly. +5. Fork does not use this dormant selection allowance. It gets its own explicit successor-baseline derivation rule above. +6. Any future slice that wants active selection behavior must first introduce and document an explicit durable encoding for multiple permitted realizations. That is not part of 29.75. -### Workstream summary +### Durable-state validity rule -| Workstream | Goal | Primary surfaces | Depends on | Exit gate | -| --- | --- | --- | --- | --- | -| 1 | Freeze lifecycle, seam contract, grep wall, and change budget | `PLAN.md`, truth docs, target tests | - | Later code work has one fixed contract and one fixed blast radius | -| 2 | Cut over host prompt-bearing fulfillment | `crates/shell/src/execution/agent_runtime/`, host bootstrap prep in `crates/shell/src/repl/async_repl.rs`, host tests | 1 | Host first prompt and follow-up no longer treat local gateway/backend construction as execution truth | -| 3 | Cut over world-member fulfillment | `crates/world-service/src/member_runtime.rs`, minimum required `crates/world-service/src/service.rs`, world routing tests | 1 | Launch-time first turn and resumed follow-up use one world-side fulfillment seam without changing typed transport | -| 4 | Reconverge shared bootstrap and startup semantics | `crates/shell/src/repl/async_repl.rs`, `crates/world-service/src/member_runtime.rs`, shared routing tests | 2, 3 | No production bootstrap prompt remains, and no discarded shell-local authoritative gateway prep remains | -| 5 | Sync truth docs and usage text | SOW, gap matrix, gateway contracts, usage docs | 4 | Live docs match the actual runtime story | -| 6 | Validation wall and closeout | static grep gates, focused tests, auth/runtime regression checks, full workspace gates | 2, 3, 4, 5 | Seam movement and lifecycle stability are proven together | +This plan freezes one simple greenfield rule so missing truth cannot drift back into repair semantics. -### Workstream 1: Freeze the lifecycle and fulfillment contract +1. Supported persisted sessions must carry a present and valid `host_attach_contract` and a valid serialized policy snapshot. +2. Therefore: + - absent `host_attach_contract` fails closed; + - present-but-null `host_attach_contract` fails closed; + - present-but-malformed `host_attach_contract` fails closed; + - present `host_attach_contract` plus missing `effective_policy`, missing required attach fields, or invalid serialized policy fails closed. +3. 29.75 does not introduce or preserve a migration, repair, or compatibility branch for these cases. -Primary surfaces: +## Engineering Guardrails -1. `PLAN.md` -2. `HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md` -3. `ADR-0047` -4. `docs/contracts/substrate-gateway-runtime-parity.md` -5. `docs/contracts/substrate-gateway-backend-adapter-protocol.md` -6. `crates/shell/tests/agent_public_control_surface_v1.rs` -7. `crates/shell/tests/repl_world_first_routing_v1.rs` +### DRY and boundary rules -Required changes: +1. Do not duplicate public-start persistence logic and REPL persistence logic; the REPL should reuse the same resolved-contract-to-durable-contract semantics as `persist_resolved_start_attach_contract(...)`. +2. Do not spread attach overlay rules across `dispatch_contract.rs`, `agents_cmd.rs`, and `state_store.rs`; one helper should own the monotonicity rules. +3. Do not let `RuntimeSelectionDescriptor` become a shadow contract object; it stays a materialization descriptor only. +4. Keep manifest-derived construction logic obviously out of modern steady-state logic and attach resolution. +5. Do not introduce new crates or a second durable persisted model for this slice. -1. Freeze the exact production runtime files allowed to change in this slice. -2. Freeze the behavioral invariants that later code work is not allowed to reopen. -3. Freeze the grep wall for forbidden production symbols: - - `AgentWrapperGateway`, - - `CodexBackend`, - - `ClaudeCodeBackend`, - - `runtime_bootstrap_prompt`. -4. Freeze the exact test files that carry the regression burden. +### Performance constraints -Must remain true: +This is not a throughput-driven slice, but there are still performance constraints: -1. no public lifecycle or selector change, -2. no new route or schema version, -3. no new auth carrier, -4. no widening of "what this slice is about." +1. Do not add repeated inventory reload or policy recomputation during steady-state attach; the durable snapshot should make attach cheaper, not more expensive. +2. Do not add raw-payload classification or compatibility-only branching to the hot path. +3. Avoid repeated serde round-trips beyond the single persisted policy deserialize already required by attach resolution. +4. Do not add REPL-path logic that re-runs full contract resolution for retained member follow-up turns. -Exit gate: +## Execution Plan -1. every later workstream can point back to one explicit contract, -2. implementation does not have to guess whether a behavior change is allowed, -3. reviewers can distinguish acceptable helper additions from forbidden seam drift. +The implementation order is intentionally strict. Phase A freezes the contract semantics first. Every later phase consumes that decision rather than reinterpreting it. -### Workstream 2: Cut over host prompt-bearing fulfillment +### Phase A: Freeze persisted attach baseline and overlay semantics -Primary surfaces: +Primary files: -1. `crates/shell/src/execution/agent_runtime/registry.rs` -2. `crates/shell/src/execution/agent_runtime/control.rs` -3. `crates/shell/src/execution/agent_runtime/mapping.rs` -4. `crates/shell/src/execution/agent_runtime/validator.rs` -5. `crates/shell/src/repl/async_repl.rs` -6. `crates/shell/tests/agent_public_control_surface_v1.rs` -7. `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` +1. [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +2. [crates/shell/src/execution/agents_cmd.rs](crates/shell/src/execution/agents_cmd.rs) +3. [crates/shell/src/execution/agent_runtime/state_store.rs](crates/shell/src/execution/agent_runtime/state_store.rs) Required changes: -1. Replace `submit_host_prompt_turn()` as a direct wrapper-construction path. Today it builds a local gateway via `build_gateway_for_descriptor()`. After this slice it should invoke the gateway-mediated seam using the already-selected backend id plus existing resume metadata. -2. Remove shell-local `gateway` and `agent_kind` from any host bootstrap state that still treats them as authoritative execution truth. `ResolvedHostOrchestratorBootstrap` and `PreparedAgentRuntime` may keep metadata needed for routing, but they must stop carrying a production local backend registry just to submit prompt-bearing work. -3. Preserve host public meaning exactly: - - `start` remains the real first user prompt, - - `turn` remains the real follow-up prompt, - - session resume state still threads through the execution path, - - completion, posture, and trace publication semantics stay unchanged. -4. Keep failure buckets coherent: - - invalid selection stays invalid selection, - - dependency unavailable stays dependency unavailable, - - policy denial stays policy denial. +1. Add one explicit helper that computes persisted attach launch knobs from durable baseline plus request overlay, and keep that helper scoped to reattach-style persisted attach consumers rather than fork successor creation. +2. Change `resolve_persisted_host_attach_contract(...)` so: + - `requested_execution_scope` always comes from the contract baseline; + - `host_execution_client_start` can honor baseline, narrow `StartNow -> Defer`, or select among explicitly baseline-permitted modes if and only if the durable baseline already encodes that allowance; otherwise it must fail closed rather than infer new multi-mode semantics; + - `attach_mode_preference` can honor baseline, narrow toward stricter continuity, or select among explicitly baseline-permitted modes if and only if the durable baseline already encodes that allowance; otherwise it must fail closed rather than infer new multi-mode semantics; + - invalid overlay attempts fail with field-specific baseline-truth diagnostics. +3. Replace `effective_policy.unwrap_or_default()` with explicit modern-path validation: + - valid snapshot -> use it; + - absent `host_attach_contract` at the session-record level -> fail closed; + - present `host_attach_contract` plus missing or invalid snapshot -> fail closed as modern durable-state corruption. +4. Preserve `field_provenance` truth so diagnostics still explain which fields came from persisted baseline versus accepted narrowing. +5. Add an explicit successor-derivation path for fork so `agents_cmd` and public control flows do not rely on caller-side broadening of the source session baseline. +6. Verify `agents_cmd` and public control flows consume the stricter resolved contract without reopening caller-side semantic replacement. -Must remain true: +Outputs: -1. no hidden warm-up prompt appears in stdin, traces, or runtime artifacts, -2. no host-only direct backend-registration table survives above the gateway seam, -3. host execution is still host-scoped in public meaning even if fulfillment is now gateway-mediated. +1. One shared persisted-attach overlay helper. +2. One explicit baseline-vs-overlay diagnostic posture. +3. One fail-closed policy-snapshot rule used by all modern persisted-attach paths. +4. One explicit fork successor-baseline derivation rule that is separate from persisted attach overlay. -Exit gate: +Definition of done: -1. no host prompt-bearing production path directly registers concrete backends, -2. host `start` and host `turn` still behave the same from the CLI surface, -3. session resume metadata still threads correctly through the new seam. +1. Persisted attach never broadens or replaces durable baseline semantics. +2. Modern missing policy or contract truth fails closed. +3. Error messages explain baseline corruption or unsupported broadening. +4. Fork successor creation is legal without weakening the no-broadening overlay rule. -### Workstream 3: Cut over world-member fulfillment for first-turn and resumed-turn execution +### Phase B: Thread resolved-contract truth into REPL host cold start -Primary surfaces: +Primary files: -1. `crates/world-service/src/member_runtime.rs` -2. `crates/world-service/src/service.rs` -3. `crates/world-service/src/lib.rs` -4. `crates/shell/src/execution/routing/dispatch/world_ops.rs` -5. `crates/shell/src/repl/async_repl.rs` -6. `crates/shell/tests/repl_world_first_routing_v1.rs` -7. `crates/world-service/tests/streamed_execute_cancel_v1.rs` -8. `crates/world-service/tests/member_runtime_world_placement_v1.rs` +1. [crates/shell/src/repl/async_repl.rs](crates/shell/src/repl/async_repl.rs) +2. [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +3. [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs) Required changes: -1. Replace both direct world execution call sites in `MemberRuntimeManager`: - - `launch()` currently builds a gateway with `build_gateway_for_backend()` and falls back to `runtime_bootstrap_prompt()` when `dispatch.initial_prompt` is absent, - - `submit_turn()` currently builds another direct gateway with `build_gateway_for_backend()` for resumed follow-up. -2. Make both call sites traverse one world-side gateway-mediated execution seam. The important outcome is not "same function name," it is "one execution boundary, one auth/runtime story, no duplicate backend registry." -3. Preserve the typed transport boundary exactly: - - launch-time world first prompt still enters through `member_dispatch.initial_prompt`, - - resumed world follow-up still enters through `MemberTurnSubmitRequestV1`, - - `POST /v1/member_turn/stream` stays unchanged, - - `service.rs` changes only if needed to preserve the existing typed request and response translation. -4. Preserve retained-member identity validation, world binding checks, participant/backend/world tuple validation, and detached-world fail-closed behavior. -5. Keep member-stream event translation and completion framing stable from the shell's perspective. +1. Change `ResolvedHostOrchestratorBootstrap` so it carries the resolved contract, not just the runtime descriptor. +2. Make `resolve_host_orchestrator_bootstrap(...)` compute a host-scoped `ResolvedLaunchContract` first, then derive `RuntimeSelectionDescriptor` from it. +3. Add an explicit session-construction path for modern host-orchestrator birth, for example `OrchestrationSessionRecord::new_host_orchestrator(...)` or a `HostAttachContractSeed`, so the first persisted session write already contains `HostAttachContract::from_resolved_contract(...)`. +4. Stop relying on `OrchestrationSessionRecord::new(...)` plus manifest seeding for REPL cold start. +5. Keep `RuntimeSelectionDescriptor` as runtime materialization input only. -Must remain true: +Outputs: -1. first-turn and resumed-turn world execution are visibly on one fulfillment seam, -2. no synthetic bootstrap prompt survives, -3. typed transport contracts remain unchanged, -4. retained-member invariants still hold. +1. REPL cold start and public start share the same birth-time authority story. +2. The first disk write for a modern REPL-born host session already contains authoritative attach truth. -Exit gate: +Definition of done: -1. world-member production execution no longer locally constructs wrappers or backends, -2. first-turn and resumed-turn world execution are visibly on one fulfillment seam, -3. retained-member validation and fail-closed behavior still hold. +1. Equivalent public start and REPL cold start persist equivalent host attach truth for the same backend. +2. No modern REPL cold-start write hits disk with a manifest-derived attach contract. -### Workstream 4: Reconverge bootstrap semantics and shell/world startup state +### Phase C: Remove manifest-era reconstruction from steady-state logic -This is the shared conflict zone. Treat it as a required merge step, not follow-up cleanup. +Primary files: -Primary surfaces: +1. [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs) +2. [crates/shell/src/execution/agent_runtime/state_store.rs](crates/shell/src/execution/agent_runtime/state_store.rs) +3. [crates/shell/src/execution/agent_runtime/control.rs](crates/shell/src/execution/agent_runtime/control.rs) -1. `crates/world-service/src/member_runtime.rs` -2. `crates/shell/src/repl/async_repl.rs` -3. `crates/shell/tests/repl_world_first_routing_v1.rs` -4. `crates/shell/tests/support/repl_world_service.rs` +Implementation note: + +Current runtime/tests still tolerate some partial persisted `HostAttachContract` rows because `HostAttachContract`, `HostAttachCapabilities`, and `HostAttachLaunchKnobs` deserialize with defaults. Phase C must tighten that behavior so incomplete attach-contract rows fail validation/load instead of silently inheriting defaults, and any tests that currently expect partial-contract backfill on load must be updated to expect fail-closed rejection instead. Required changes: -1. Delete or demote `runtime_bootstrap_prompt()` in both host and world startup logic so it is no longer part of production prompt semantics. -2. Ensure the first targeted world turn carries the real user prompt all the way to fulfillment. If any attach or readiness step remains, it must live as control-plane state, not as a fake prompt-bearing run. -3. Remove shell-local gateway prep in `async_repl.rs` that is created only to be discarded before real remote member execution. If a prepared runtime object still exists, it should carry routing metadata only, not a local authoritative gateway registry. -4. Update shared lifecycle assertions and prompt-capture tests in the same workstream so the bootstrap removal and seam cutover settle together. +1. Keep `HostAttachContract::from_manifest(...)` out of steady-state modern birth paths. +2. Change `sync_host_attach_contract(...)` so it only refreshes continuity selector state on an existing contract for modern sessions. +3. Treat absent, null, malformed, incomplete, or otherwise invalid `host_attach_contract` as unsupported/corrupt durable state that fails closed. +4. Ensure persistence helpers in control and parking paths do not silently repair missing durable truth during ordinary runtime snapshots. -Must remain true: +Outputs: -1. traces and captured transport payloads show only the real user prompt as the first prompt, -2. there is no production bootstrap prompt constant left on the execution path, -3. there is no production shell-local authoritative member gateway prep pattern left behind. +1. Zero steady-state codepaths that recreate authority from manifest defaults. +2. One clear fail-closed posture for unsupported or corrupt persisted attach truth. -Exit gate: +Definition of done: -1. the first prompt-bearing execution is always the real user prompt, -2. `async_repl.rs` no longer owns a discarded execution-time gateway for world members, -3. shared routing assertions prove the post-merge story end to end. +1. Modern missing durable attach truth is observable and rejected. +2. Any missing or invalid durable attach truth is observable and rejected. -### Workstream 5: Sync truth docs to the new steady state +### Phase D: Expand unit and integration coverage around the authority seams -Primary surfaces: +Primary files: -1. `llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md` -2. `AGENT_ORCHESTRATION_GAP_MATRIX.md` -3. `docs/contracts/substrate-gateway-runtime-parity.md` -4. `docs/contracts/substrate-gateway-backend-adapter-protocol.md` -5. `docs/USAGE.md` +1. [crates/shell/tests/repl_world_first_routing_v1.rs](crates/shell/tests/repl_world_first_routing_v1.rs) +2. [crates/shell/tests/agent_public_control_surface_v1.rs](crates/shell/tests/agent_public_control_surface_v1.rs) +3. [crates/shell/tests/agent_successor_contract_ahcsitc0.rs](crates/shell/tests/agent_successor_contract_ahcsitc0.rs) +4. Unit tests in [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs) +5. Unit tests in [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](crates/shell/src/execution/agent_runtime/dispatch_contract.rs) Required changes: -1. Update live docs so they describe direct wrapper and backend registration as historical bypass behavior, not steady-state architecture. -2. Update the gap matrix so this seam is no longer described as outstanding once code lands. -3. Keep ADR-0040, ADR-0041, and ADR-0047 stable in ownership and lifecycle meaning. Only sync descriptive wording where implementation evidence now exists. - -Must remain true: +1. Add birth-time parity tests proving REPL cold start and public start persist the same attach truth shape for the same backend. +2. Add modern fail-closed tests for missing and corrupt persisted policy snapshots. +3. Add monotonicity tests proving `host_execution_client_start` and `attach_mode_preference` can honor baseline, narrow where allowed, and only select when the durable baseline explicitly encodes multiple permitted modes. They must never broaden or infer multi-mode semantics that are not durably encoded. +4. Add fork-successor tests proving successor creation derives a new baseline instead of broadening the source session through persisted attach overlay. +5. Add regression tests proving runtime snapshot and parked/resumed flows do not silently recreate missing modern contracts. +6. Keep retained member parity coverage intact while ensuring the parent host session baseline is now truthful. -1. docs lag implementation by at most one PR, -2. ADR meaning stays stable, -3. no doc implies shell-local or member-local backend registration is acceptable steady-state production behavior. +Outputs: -Exit gate: +1. Unit tests for authoritative persistence and fail-closed behavior. +2. Integration tests for REPL/public parity and recovery semantics. +3. A targeted test matrix that future slices 30 and 31 can trust. -1. live docs tell the same runtime story as the code, -2. no truth doc implies the bypass is still the intended architecture. +Definition of done: -### Workstream 6: Validation and closeout +1. All critical authority seams have direct regression coverage. +2. Tests distinguish supported persisted-state behavior from unsupported or corrupt durable state. -Required changes: +### Phase E: Keep downstream docs truthful -1. Run the static grep wall on production runtime code. -2. Run focused runtime tests for shell host control and world-member routing. -3. Run gateway and auth regression tests that prove the FD auth-bundle path still holds. -4. Run full workspace checks after focused coverage is green. -5. Produce one compact validation artifact for reviewers naming: - - lifecycle invariants checked, - - world first-turn versus follow-up checks, - - auth-bundle checks, - - seam-removal grep checks. +Primary files: -Exit gate: +1. [llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md](llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) +2. [llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md](llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md) +3. [llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md](llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md) +4. [llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md](llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md) +5. [PLAN.md](PLAN.md) -1. seam movement and lifecycle stability are both proven, -2. auth, selection, and retained-member invariants still hold, -3. the repo no longer contains the production bypass in the targeted runtime surfaces. +Required changes: -## Code Quality Review +1. Make 29, 29.75, and this root plan say the same thing about the 29.75 contract floor. +2. Ensure 30 states it inherits durable attach truth and does not repair it. +3. Ensure 31 states attach-mode behavior consumes the 29.75 two-layer model and does not reopen baseline semantics. +4. Update lingering 29.5 language that still implies contract-authority gaps are already closed. -The implementation should be judged against these rules: +Outputs: -1. Prefer seam removal over seam layering. Delete duplicate backend-registration tables instead of hiding them behind more indirection. -2. Keep backend-kind mapping explicit and fail-closed, but move actual backend execution behind the gateway boundary. -3. Preserve typed request and response contracts wherever already landed. Do not widen stable schemas unless there is no internal-only alternative. -4. Any new helper must be named by responsibility, not by generic architecture vocabulary. -5. Update comments near changed control flow so future readers can see why direct backend registration is forbidden there. -6. Tests must prove the user-visible invariants, not just internal call graphs. -7. If a compatibility harness still needs direct wrapper instantiation, keep it in tests only and label it as non-production. -8. `async_repl.rs` is the biggest merge-conflict trap in the slice. Keep host-only and world-only prep out of that file unless the change is part of Workstream 4. +1. One consistent narrative from 29 -> 29.75 -> 30 -> 31. +2. No downstream doc that implies 30 or 31 will repair baseline contract truth left open by 29.x. -## Test Review +Definition of done: -Runtime tests, seam-removal invariants, and auth-carrier tests are the authoritative test layers for this slice. +1. A reader can move from 29 -> 29.75 -> 30 -> 31 without encountering contradictory contract ownership. -This is runtime and transport work, not prompt-quality work. No LLM output-quality eval suite is required. The burden is lifecycle, routing, auth, and seam-removal proof. +## Test and Validation Plan -### CODE PATH COVERAGE +### Code-path coverage diagram ```text -CODE PATH COVERAGE -================== -[+] Host prompt-bearing fulfillment - crates/shell/src/execution/agent_runtime/control.rs - crates/shell/src/execution/agent_runtime/registry.rs - crates/shell/tests/agent_public_control_surface_v1.rs - crates/shell/tests/agent_successor_contract_ahcsitc0.rs - Required proof: - - start uses real user prompt - - turn uses real user prompt - - no direct backend registration remains in production path - - Accepted -> terminal still holds - -[+] World first-turn fulfillment - crates/shell/src/repl/async_repl.rs - crates/shell/src/execution/routing/dispatch/world_ops.rs - crates/world-service/src/member_runtime.rs - crates/shell/tests/repl_world_first_routing_v1.rs - Required proof: - - launch-time initial_prompt carries the real user prompt - - no synthetic bootstrap prompt remains - - no local member-runtime backend registry remains - -[+] World resumed follow-up fulfillment - crates/world-service/src/member_runtime.rs - crates/world-service/src/lib.rs - crates/shell/tests/repl_world_first_routing_v1.rs - crates/world-service/tests/streamed_execute_cancel_v1.rs - Required proof: - - resumed follow-up still uses /v1/member_turn/stream - - retained-member tuple validation still holds - - same fulfillment seam as launch-time first turn - -[+] Gateway/auth/adapter continuity - crates/world-service/src/gateway_runtime.rs - crates/gateway/src/server/mod.rs - crates/shell/tests/world_gateway.rs - crates/gateway/tests/openai_shared_parity.rs - Required proof: - - FD auth bundle still used - - bundle is consumed once - - no child secret env fallback path appears - - backend selection remains stable and allowlist-driven +AUTHORITATIVE HOST BIRTH +[+] async_repl.rs + ├── resolve_host_orchestrator_bootstrap() + │ ├── [GAP] resolve shared contract before descriptor materialization + │ └── [GAP] persist resolved contract on first orchestration-session write + └── prepare_host_orchestrator_runtime_from_resolved() + └── [GAP] carry resolved contract through REPL cold start + +[+] orchestration_session.rs + ├── OrchestrationSessionRecord::new(...) + │ └── [GAP] modern host birth still seeds host_attach_contract from manifest + ├── sync_host_attach_contract(...) + │ ├── [GAP] missing contract silently recreated from manifest + │ └── [ADD] continuity refresh only on existing modern contract + └── HostAttachContract::from_resolved_contract(...) + └── [EXISTING ★★★] preserves runtime/capability/policy truth + +[+] dispatch_contract.rs + ├── resolve_persisted_host_attach_contract(...) + │ ├── [EXISTING ★★] persisted capabilities reused + │ ├── [GAP] missing policy snapshot falls back to Policy::default() + │ ├── [GAP] attach_mode_preference currently caller-replaced + │ ├── [GAP] host_execution_client_start currently caller-replaced + │ └── [ADD] bounded overlay helper rejects broadening + └── field provenance + └── [ADD] persisted baseline vs accepted narrowing provenance stays truthful + +[+] retained member parity + ├── retained_member_dispatch_parity_subset(...) + │ └── [EXISTING ★★★] prefers live runtime / pending replacement / descriptor fallback + └── build_member_dispatch_transport_request(...) + └── [EXISTING ★★★] uses shared-contract-derived parity subset + +COVERAGE TARGET + Current explicit coverage is strong around persisted capability reuse and retained member parity. + This slice must add missing parity tests for REPL cold-start birth truth, fail-closed policy snapshot handling, + and attach overlay monotonicity. ``` -### USER FLOW COVERAGE +Legend: `★★★` behavior + edge + error coverage already present, `★★` partial existing coverage, `[GAP]` new test required, `[ADD]` new assertion family to add. + +### Required unit tests + +1. `orchestration_session.rs` + - host-orchestrator session birth from resolved contract persists authoritative attach truth on the first write; + - modern `sync_host_attach_contract(...)` does not recreate a missing contract from manifest; + - absent, null, malformed, or incomplete persisted contracts fail closed. +2. `dispatch_contract.rs` + - missing modern `effective_policy` fails closed; + - invalid serialized policy fails closed with clear diagnostics; + - `host_execution_client_start` accepts honor, narrowing, and selection only when explicitly encoded by the durable baseline; with the current 29.75 single-valued baseline it therefore behaves as honor-or-narrow only; + - `attach_mode_preference` accepts honor, narrowing, and selection only when explicitly encoded by the durable baseline; with the current 29.75 single-valued baseline it therefore behaves as honor-or-narrow only; + - overlay broadening attempts fail with the correct `field`. +3. `agents_cmd.rs` and successor-focused tests + - fork must not call persisted attach resolution in a way that broadens the source baseline; + - successor allocation derives and persists a new successor `HostAttachContract`; + - successor sessions use explicit successor-only handling for continuity-dependent fields rather than inheriting behavior through overlay broadening; + - all non-successor durable truth carries forward unless an explicit successor-only rule says otherwise. +4. Durable-state validity tests + - rows with no `host_attach_contract` fail closed as unsupported/corrupt durable state; + - rows with present-but-null `host_attach_contract` fail closed as corrupted durable state; + - rows with malformed `host_attach_contract` fail closed as corrupted durable state; + - rows with a present but incomplete or otherwise invalid `host_attach_contract` fail closed as corrupted durable state. + +### Required integration tests + +1. `repl_world_first_routing_v1.rs` + - REPL host cold start persists the same attach-relevant truth shape as public start for the same backend; + - a REPL-born session that gets parked and later resumed still uses the persisted continuity selector and stored policy snapshot. +2. `agent_public_control_surface_v1.rs` + - reattach and turn recovery reject sessions missing modern durable policy truth; + - attach-time requests that attempt to broaden attach mode or client-start posture fail closed; + - fork succeeds through explicit successor derivation rather than overlay broadening of the source session. +3. `agent_successor_contract_ahcsitc0.rs` + - successor sessions preserve generalized attach truth and handle continuity-dependent fields through explicit successor derivation after the 29.75 changes; + - successor sessions do not require overlay broadening to achieve their successor-only behavior. + +### Test commands + +At minimum: -```text -USER FLOW COVERAGE -================== -[+] Host start - substrate agent start --prompt ... - - real user prompt is first prompt-bearing execution - - lifecycle events still close with explicit terminal envelope - -[+] Host follow-up - substrate agent turn --session ... --backend ... --prompt ... - - exact backend selector still required - - resumed metadata still threads through the run - - no local shell backend registry is reintroduced - -[+] World first targeted turn - shell routes member_dispatch.initial_prompt into world-service - - typed request survives unchanged - - first world prompt is the user prompt - - any attach/readiness step stays off the prompt-bearing path - -[+] World resumed follow-up - shell routes MemberTurnSubmitRequestV1 into /v1/member_turn/stream - - tuple validation still blocks detached or mismatched follow-up - - same fulfillment seam as first targeted turn - -[+] Failure-facing flows - invalid backend id / dependency unavailable / policy denial / detached world - - failures stay in the correct bucket - - no bypass path silently "helps" by routing around the gateway seam +```bash +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture +cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture +cargo test -p shell host_attach_contract_from_resolved_contract_preserves_truth -- --nocapture +cargo test -p shell persisted_attach_contract_is_explicit_baseline_domain -- --nocapture ``` -### Concrete test additions or tightening - -| Target | Test requirement | Type | -| --- | --- | --- | -| `crates/shell/tests/agent_public_control_surface_v1.rs` | Add or tighten assertions that `start`, `turn`, `reattach`, `stop`, posture transitions, and `Accepted -> terminal` remain unchanged after the seam move. | focused integration | -| `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` | Prove no hidden bootstrap or synthetic prompt appears in host prompt-bearing execution artifacts. | focused integration | -| `crates/shell/tests/repl_world_first_routing_v1.rs` | Tighten first-targeted-world-turn assertions so the real user prompt is the only first prompt-bearing input, and launch-time first turn plus resumed follow-up prove the same seam. | focused integration | -| `crates/world-service/tests/streamed_execute_cancel_v1.rs` | Preserve cancel and completion behavior while removing member-local backend registration. | focused integration | -| `crates/world-service/tests/member_runtime_world_placement_v1.rs` | Preserve retained-member tuple and world placement validation while the fulfillment seam moves. | focused integration | -| `crates/shell/tests/world_gateway.rs` and `crates/gateway/tests/openai_shared_parity.rs` | Prove the FD auth-bundle path, one-time consumption, and fail-closed auth precedence remain intact. | focused integration | -| Static repo invariant | Add one grep-backed invariant or test-time assertion that the targeted production runtime surfaces no longer instantiate `AgentWrapperGateway`, `CodexBackend`, or `ClaudeCodeBackend`. | static validation | -| Static repo invariant | Add one grep-backed invariant or test-time assertion that `runtime_bootstrap_prompt` or equivalent synthetic prompt-bearing bootstrap behavior is gone from production runtime code. | static validation | - -### QA handoff artifact +If helper APIs change substantially, also run: -Implementation is not done when the tests compile. The PR or implementation notes must also leave one compact handoff artifact that names: - -1. the exact lifecycle commands exercised, -2. the exact world first-turn and resumed-follow-up scenarios exercised, -3. the auth-bundle checks performed, -4. the static seam-removal checks performed, -5. any remaining test-only direct wrapper uses that are intentionally non-production. +```bash +cargo test -p shell --lib execution::agent_runtime::dispatch_contract -- --nocapture +cargo test -p shell --lib execution::agent_runtime::orchestration_session -- --nocapture +``` -### Regression rule for this slice +### Manual validation -These are mandatory blockers: +Manual validation must prove: -1. any path that causes `start` or `turn` to send a hidden synthetic prompt before the real user prompt, -2. any path that breaks `Accepted -> terminal`, -3. any path that widens or changes the typed world follow-up contract, -4. any path that restores direct backend registration in the targeted production runtime files, -5. any path that reopens auth delivery through secret-bearing env vars. +1. A REPL-born host session persists the same attach-relevant truth shape as a public-start-born host session for the same backend. +2. Later attach planning reuses the full durable baseline from `HostAttachContract`, including attach policy defaults, without regaining `Policy::default()` or manifest-default capabilities. +3. Missing or invalid persisted durable truth fails closed with no repair or compatibility branch. +4. Bounded overlay behavior is explicit and auditable, with no caller-side semantic replacement of persisted attach policy. +5. 30 and 31 can be read straight through after 29.75 without contradicting the actual runtime contract floor. ## Failure Modes Registry -| Failure mode | Where it happens | Required handling | Test requirement | Critical gap if missing | +| Code path | Realistic production failure | Test required | Error handling required | User-visible outcome | | --- | --- | --- | --- | --- | -| Host fulfillment still routes through shell-local wrapper construction after refactor | `crates/shell/src/execution/agent_runtime/` | fail review; remove remaining bypass | static seam check plus host integration tests | Yes | -| World first-turn and resumed-turn drift onto different fulfillment seams | `crates/world-service/src/member_runtime.rs`, world dispatch tests | refactor until both paths converge | world first-turn plus resumed-turn regression tests | Yes | -| Synthetic bootstrap prompt survives as hidden runtime behavior | `member_runtime.rs`, REPL startup flow | delete or demote to non-prompt control-plane step | prompt-capture regression tests | Yes | -| Detached world follow-up reopens through a bypass path | member-turn validation | fail closed with current error posture | retained-member and detached-world tests | Yes | -| Stable backend-id selection is bypassed by local backend mapping logic | shell or world helper code | fail closed and restore selection-before-dispatch | focused host/world integration plus static inspection | Yes | -| FD auth-bundle handoff regresses to child env or duplicate secret path | gateway runtime or server startup | fail closed | gateway parity and auth tests | Yes | -| Docs still describe direct wrapper construction as acceptable steady state | SOW, gap matrix, gateway contracts, usage docs | update in same slice | doc review | No, but unacceptable at ship | - -Any failure mode with silent prompt-semantic drift or silent auth drift is a release blocker for this slice. - -## Performance And Complexity Review +| REPL host cold start | session is persisted with manifest-derived defaults instead of resolved truth | Yes | Yes | Later attach behaves differently from public start | +| Persisted attach resolution | stored `effective_policy` is missing or corrupt on a session that already has `host_attach_contract` | Yes | Yes | Clear corruption error, not silent permissive attach or accidental legacy fallback | +| Durable-state validation | a row with missing or incomplete `host_attach_contract` is mistakenly accepted or silently repaired | Yes | Yes | Corrupt or unsupported sessions slip past the contract floor | +| Attach overlay | caller broadens `attach_mode_preference` or `host_execution_client_start` | Yes | Yes | Clear "baseline truth rejected field" error | +| Fork successor allocation | fork is implemented as a broadening overlay on the source session instead of successor derivation | Yes | Yes | Fork fails unexpectedly or weakens the overlay contract for every other caller | +| Runtime snapshot sync | missing contract silently recreated from manifest during parking or snapshot write | Yes | Yes | Hidden authority drift that only appears on later resume | +| Retained member parity | REPL retains old member parity while parent host session baseline changed | Existing coverage plus regression check | Yes | Follow-up turn routes with stale runtime truth | -1. This slice should not add new hot-path layers beyond the minimum gateway-mediated handoff needed to remove duplicate backend registration. -2. Do not add per-turn discovery work that can be derived from already-selected backend ids and existing runtime wiring. -3. The dominant cost center here is behavioral complexity, not CPU. Optimize for one obvious runtime story. -4. If a helper can only exist by smuggling placement-specific behavior into generic terminology, do not add it. -5. The implementation should spend zero innovation tokens on new architecture. This is a convergence slice. Boring is correct. +### Critical gaps -## Deferred Follow-Ups / TODO Candidates +These are the critical gaps this plan must close before implementation can be called done: -These are real follow-ups, but they are not part of this slice: - -1. Shared dispatch-envelope and capability-override work from SOW 29. -2. Public world-scoped root start and capability flags from SOW 30. -3. Any generic integrated backend matrix expansion beyond the already-supported bindings. -4. Any future cleanup that further normalizes gateway runtime wiring once this seam replacement is complete. +1. First-write REPL cold-start parity is currently unproven and likely false. +2. Modern missing policy snapshot currently broadens through defaults. +3. Attach-time caller input currently replaces durable attach knobs too freely. +4. Current fork allocation broadens `ContinuityRequired` source sessions by asking for `FreshAllowed` through persisted attach overlay. ## Worktree Parallelization Strategy +This plan has real parallelization opportunities, but only after the contract rules are frozen. Before that point, parallel work would just create rebase churn and semantic drift. + ### Dependency table | Step | Modules touched | Depends on | | --- | --- | --- | -| A0. Freeze lifecycle and seam contract | truth docs, target tests, grep wall definitions | - | -| A1. Host fulfillment cutover | `crates/shell/src/execution/agent_runtime/`, host-facing paths in `crates/shell/src/repl/async_repl.rs`, host control tests | A0 | -| A2. World-member fulfillment cutover | `crates/world-service/src/member_runtime.rs`, minimum `crates/world-service/src/service.rs`, world routing tests | A0 | -| A3. Shared reconvergence and bootstrap removal | `crates/shell/src/repl/async_repl.rs`, `crates/world-service/src/member_runtime.rs`, shared routing tests | A1, A2 | -| B. Truth-doc sync | SOW, gap matrix, gateway contracts, usage docs | A3 | -| C. Validation wall and closeout | repo root, targeted tests, auth tests | A1, A2, A3, B | +| A. Freeze attach baseline and overlay rules | `crates/shell/src/execution/agent_runtime/`, `crates/shell/src/execution/` | — | +| B. Thread resolved contract into REPL cold start | `crates/shell/src/repl/`, `crates/shell/src/execution/agent_runtime/` | A plus the shared `orchestration_session.rs` session-seed prerequisite landed from Lane C | +| C. Remove manifest fallback from steady-state paths | `crates/shell/src/execution/agent_runtime/`, `crates/shell/src/execution/agent_runtime/control.rs` | A | +| D. Expand unit and integration coverage | `crates/shell/src/execution/agent_runtime/`, `crates/shell/tests/`, `crates/shell/src/repl/` | A for resolver semantics, B and C for parity and recovery cases | +| E. Align downstream docs | `llm-last-mile/`, repo root planning docs | A complete enough to freeze wording, final pass after B and C | ### Parallel lanes -Lane 0: A0 -Reason: there is no useful parallelism until the contract, grep wall, and change budget are frozen. - -Lane H: A1 -Reason: host cutover work is mostly concentrated in `crates/shell/src/execution/agent_runtime/` plus host lifecycle tests. - -Lane W: A2 -Reason: world cutover work is mostly concentrated in `crates/world-service/src/member_runtime.rs` plus world routing tests. - -Lane R: A3 -Reason: reconvergence touches the exact shared conflict zone, `async_repl.rs` plus bootstrap semantics plus shared routing assertions, so it must wait for H and W to land. - -Lane D: B -Reason: docs should not move until the runtime story has stopped changing. - -Lane V: C -Reason: validation is the merge gate and must run after code and docs converge. - -### Safe parallel split - -The only honest parallel split in this slice is: - -1. one owner or worktree handles host fulfillment cutover, -2. one owner or worktree handles world-member fulfillment cutover, -3. one reconvergence owner handles bootstrap removal, `async_repl.rs`, and the shared lifecycle test updates after both cutovers merge. +Lane A: Step A +Reason: this freezes the contract and helper shapes every other lane consumes. -This works because: +Lane B: Step B REPL bootstrap wiring plus Step D subset for REPL parity +Reason: REPL cold-start work can proceed once the baseline and overlay helper shape is frozen, but it consumes a shared session-seed prerequisite owned outside Lane B. -1. host seam work is concentrated in `crates/shell/src/execution/agent_runtime/`, -2. world seam work is concentrated in `crates/world-service/src/member_runtime.rs` and world routing tests, -3. the conflict zone is `crates/shell/src/repl/async_repl.rs`, bootstrap semantics, and shared lifecycle assertions, which is why A3 is serialized after A1 and A2. +Lane C: Step C plus the shared `orchestration_session.rs` session-seed prerequisite, then Step D subset for fail-closed persistence validation +Reason: steady-state manifest-reconstruction removal already owns `agent_runtime/` and `orchestration_session.rs`, so it is the right lane to land the shared constructor or seed primitive that Lane B then consumes. -### Suggested worktree ownership - -| Lane | Owns | Should avoid | -| --- | --- | --- | -| H | `crates/shell/src/execution/agent_runtime/`, host lifecycle tests, host resume metadata plumbing | world-member execution semantics, typed member-turn transport, doc sync | -| W | `crates/world-service/src/member_runtime.rs`, world execution tests, minimal service plumbing | host bootstrap state, shell lifecycle semantics, doc sync | -| R | `crates/shell/src/repl/async_repl.rs`, shared routing tests, bootstrap removal, final seam wording validation | inventing new runtime abstractions after H and W already converged | - -### Conflict flags - -1. Do not split A3 across worktrees. It touches the exact boundary where host and world behavior becomes one user-visible prompt story. -2. `crates/shell/src/repl/async_repl.rs` is not fair game during both A1 and A2 except for the narrowest unavoidable plumbing. Major edits there belong to A3. -3. Do not start doc sync before A3. The plan depends on the final bootstrap decision and the exact post-cutover seam wording. -4. If A1 introduces helper shapes that A2 needs, rebase A2 onto A1 before final integration rather than cloning the helper differently in both lanes. +Lane D: Step E +Reason: doc rewrites can begin once Step A lands conceptually, but final wording waits for B and C behavior to settle. ### Execution order -1. Land A0 first. -2. Launch H and W in parallel worktrees. -3. Merge H and W back to the main branch. -4. Run R as the serialized reconvergence pass. -5. Run D after the runtime story is stable. -6. Run V last, and use that output as the ship gate. - -### Parallelization verdict - -This slice has: - -1. one required contract-freeze step, -2. two short parallel execution lanes for host and world seam cutover, -3. one serialized reconvergence step for bootstrap removal and REPL cleanup, -4. one doc lane, -5. one final validation lane. - -Peak honest parallelism is `A1 + A2`. Everything after that should be sequenced. - -## Validation Commands - -### Static seam-removal gates - -These commands are expected to be green in the targeted production runtime surfaces after implementation: - -```bash -rg -n "AgentWrapperGateway|CodexBackend|ClaudeCodeBackend" \ - crates/shell/src/execution/agent_runtime \ - crates/shell/src/repl \ - crates/world-service/src/member_runtime.rs -``` - -Expected result after the slice: - -1. no production hits in the targeted runtime files, -2. any remaining hits must be in tests or explicitly documented non-production harnesses. - -Synthetic bootstrap prompt gate: - -```bash -rg -n "runtime_bootstrap_prompt|Enter persistent Substrate world-scoped member mode" \ - crates/world-service/src/member_runtime.rs \ - crates/shell/src/repl \ - crates/shell/tests \ - crates/world-service/tests -``` - -Expected result after the slice: - -1. no production hits, -2. test fixtures may refer to the old behavior only if the test is explicitly historical or transitional. - -### Focused cargo gates - -```bash -cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture -cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture -cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture -cargo test -p shell --test world_gateway -- --nocapture -cargo test -p world-service --test streamed_execute_cancel_v1 -- --nocapture -cargo test -p world-service --test member_runtime_world_placement_v1 -- --nocapture -cargo test -p substrate-gateway --test openai_shared_parity -- --nocapture -``` - -### Full workspace gates - -```bash -cargo fmt --all -- --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -- --nocapture -``` - -### Manual validation proof points +1. Land Lane A first. Nothing else starts until the baseline and overlay semantics are frozen. +2. After Lane A stabilizes, Lane C lands the shared `orchestration_session.rs` session-seed prerequisite plus the steady-state reconstruction removal in the same files. +3. Once that prerequisite is in place, launch the remaining Lane B and Lane C work in parallel worktrees if ownership is still clean. +4. Merge B and C, then run the full targeted test matrix. +5. Finish with Lane D once runtime behavior is stable enough that docs can be written once and stay true. -Manual validation for this slice must explicitly confirm: - -1. host `start` uses the real user prompt as the first prompt, -2. host `turn` uses the real user prompt as the follow-up prompt, -3. world first targeted turn uses the real user prompt and not a bootstrap prompt, -4. resumed world follow-up still travels through `/v1/member_turn/stream`, -5. detached world follow-up still fails closed, -6. gateway startup still consumes the FD auth bundle, -7. runtime artifacts or traces provide evidence that fulfillment is gateway-mediated rather than shell-local or member-local wrapper construction. - -## Completion Summary +### Conflict flags -This plan is implementation-ready because it now freezes the entire execution story: +1. Lanes B and C both touch `crates/shell/src/execution/agent_runtime/`, and Phase B requires a new session-construction path in `orchestration_session.rs`. That prerequisite belongs to Lane C, not Lane B. +2. If two workers are used, assign ownership explicitly: + - Worker B owns `async_repl.rs` plus REPL-facing tests and consumes the already-landed session-seed API; + - Worker C owns `dispatch_contract.rs`, `orchestration_session.rs`, `state_store.rs`, `control.rs`, and attach-control tests, including the session-seed constructor or helper Phase B depends on. +3. If Lane C cannot land that prerequisite first, run B and C sequentially. +4. Lane D should not finalize any dependency wording until B and C land, or the docs will drift again. -1. Objective: gateway-mediated fulfillment replaces the production bypass without changing lifecycle meaning. -2. Locked decisions: public lifecycle, backend-id selection, typed world-member transport, and FD auth carrier remain fixed. -3. Step 0: the minimum honest change, blast radius, stop conditions, and reuse surfaces are explicit. -4. Current repo truth: the exact bypass points and bootstrap regression points are named. -5. Architecture review: ownership is explicit and the target seam is singular. -6. Implementation plan: each workstream has inputs, boundaries, constraints, and exit gates. -7. Test review: the exact runtime paths, proof obligations, and regression blockers are defined. -8. Failure modes: the dangerous regressions are named and treated as blockers. -9. Parallelization: one freeze step, one honest host/world parallel window, one serialized reconvergence step, then docs and validation. -10. Validation: grep, focused tests, auth checks, manual proof points, and full workspace gates are all specified. +## Acceptance Criteria -After this slice lands, the runtime story should read as if it were intentional from the start: +This slice is complete only when all of the following are true: -1. shell owns lifecycle and routing, -2. typed runtime surfaces own transport and world attachment, -3. stable backend ids are selected before execution, -4. `substrate-gateway` owns adapter dispatch and backend internals, -5. no production prompt-bearing path bypasses that seam anymore. +1. REPL host cold start and public start persist equivalent `HostAttachContract` truth for equivalent host-backed launches. +2. No steady-state host session birth path relies on `HostAttachContract::from_manifest(...)` as its primary truth source. +3. Persisted attach resolution reuses the full durable attach baseline from `HostAttachContract`, including attach policy defaults for `requested_execution_scope`, `host_execution_client_start`, and `attach_mode_preference`. +4. Any attach-time request variation is modeled explicitly as a bounded overlay constrained by that baseline, with no silent caller-side replacement of persisted attach policy. +5. Missing durable policy or attach truth fails closed in modern steady-state paths instead of silently broadening through defaults. +6. Code, tests, and docs all describe the same overlay rules: honor baseline, narrow where permitted, or select among baseline-permitted modes, but never broaden or replace the durable baseline silently. +7. Fork is implemented as successor-baseline derivation, not as a broadening overlay on the source session. +8. Missing, null, malformed, or incomplete persisted durable attach truth fails closed with no compatibility helper or repair branch in this slice. +9. 30 and 31 can both move forward after 29.75 without reopening baseline attach semantics. + +## Implementation Tasks + +Synthesized from the scope challenge, contract model, and execution plan above. Each task is buildable and directly tied to one of the contract gaps this slice owns. + +- [ ] **T1 (P1, human: ~2h / CC: ~20min)** — `agent_runtime/` — Add an authoritative host-orchestrator session seed path that persists `HostAttachContract` from `ResolvedLaunchContract` on the first write. + - Surfaced by: Step 0 / Phase B — REPL cold start currently persists manifest-derived attach truth. + - Files: `crates/shell/src/execution/agent_runtime/orchestration_session.rs`, `crates/shell/src/execution/agents_cmd.rs` + - Verify: unit tests for first-write host attach truth plus public-start parity checks. + +- [ ] **T2 (P1, human: ~2h / CC: ~20min)** — `repl/` — Make `resolve_host_orchestrator_bootstrap(...)` carry resolved-contract truth and make REPL cold start persist it before runtime launch. + - Surfaced by: Phase B — REPL bootstrap still materializes runtime without durable contract authority. + - Files: `crates/shell/src/repl/async_repl.rs` + - Verify: `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` + +- [ ] **T3 (P1, human: ~2h / CC: ~15min)** — `dispatch_contract` — Add one bounded overlay helper for persisted attach launch knobs, scope it to reattach-style consumers, and reject broadening attempts. + - Surfaced by: Step 0 / Phase A — attach-mode and client-start semantics are too caller-owned today, and fork must not become a broadening loophole. + - Files: `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` + - Verify: targeted persisted-attach resolver unit tests. + +- [ ] **T4 (P1, human: ~1h / CC: ~10min)** — `dispatch_contract` — Remove modern `Policy::default()` fallback from persisted attach resolution and fail closed on missing or invalid policy snapshots. + - Surfaced by: Step 0 / Failure modes — missing durable truth currently broadens silently. + - Files: `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` + - Verify: new unit tests plus `agent_public_control_surface_v1`. + +- [ ] **T5 (P1, human: ~90min / CC: ~15min)** — `orchestration_session/control` — Remove steady-state manifest reconstruction, stop `sync_host_attach_contract(...)` from silently recreating missing modern contracts, and add an explicit successor-baseline derivation path for fork. + - Surfaced by: Step 0 / Phase C — steady-state semantics and successor derivation both need clear fail-closed boundaries. + - Files: `crates/shell/src/execution/agent_runtime/orchestration_session.rs`, `crates/shell/src/execution/agent_runtime/control.rs`, `crates/shell/src/execution/agent_runtime/state_store.rs`, `crates/shell/src/execution/agents_cmd.rs` + - Verify: modern fail-closed tests and successor-contract tests. + +- [ ] **T6 (P2, human: ~2h / CC: ~20min)** — `shell tests` — Add REPL/public-start parity coverage, attach overlay monotonicity tests, and regression coverage for parked and resumed host sessions. + - Surfaced by: Test and validation plan — core authority paths still have coverage holes. + - Files: `crates/shell/tests/repl_world_first_routing_v1.rs`, `crates/shell/tests/agent_public_control_surface_v1.rs`, `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` + - Verify: full targeted test matrix in this plan. + +- [ ] **T7 (P2, human: ~45min / CC: ~10min)** — `docs` — Update 29, 29.75, 30, and 31 wording so every slice describes the same contract floor and dependency order. + - Surfaced by: Phase E — downstream docs already point at 29.75 and must stay truthful. + - Files: `llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md`, `llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md`, `llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md`, `llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md`, `PLAN.md` + - Verify: manual read-through of 29 -> 29.75 -> 30 -> 31. diff --git a/SHARED_DISPATCH_CLOSEOUT_AUDIT_2026-05-25.md b/SHARED_DISPATCH_CLOSEOUT_AUDIT_2026-05-25.md new file mode 100644 index 000000000..043abf425 --- /dev/null +++ b/SHARED_DISPATCH_CLOSEOUT_AUDIT_2026-05-25.md @@ -0,0 +1,210 @@ +# Shared Dispatch Closeout Audit + +Date: 2026-05-25 + +Scope audited: + +- [PLAN.md](/home/azureuser/__Active_Code/atomize-hq/substrate/PLAN.md) +- [ORCH_PLAN.md](/home/azureuser/__Active_Code/atomize-hq/substrate/ORCH_PLAN.md) +- [llm-last-mile/29.5-shared-dispatch-contract-closeout-and-parity-hardening.md](/home/azureuser/__Active_Code/atomize-hq/substrate/llm-last-mile/29.5-shared-dispatch-contract-closeout-and-parity-hardening.md) +- [llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/home/azureuser/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) + +## Verdict + +The 29.5 closeout should be treated as partially landed, not fully complete. + +What appears landed correctly: + +- inventory-backed dispatch now merges `policy_overlay` into resolved `effective_policy` +- bounded capability narrowing is implemented for the approved family +- public persisted-attach control flows are wired through the shared resolver +- successor attach truth is copied forward with continuity cleared +- docs for 29, 30, and 31 were updated to describe the narrowed 29.5 floor + +What blocks calling the slice complete: + +- REPL host-orchestrator cold start still bypasses the shared dispatch contract and persists manifest-default attach truth instead of resolved-contract truth +- persisted attach resolution still does not fully treat persisted attach knobs as authoritative baseline truth +- fallback behavior can still recreate permissive/default attach truth in paths that the plan said should be durable and authoritative + +## Primary Findings + +### Finding 1: REPL host cold start still bypasses the shared contract + +Severity: High + +This is the main reason the slice should not yet be considered complete. + +The public/start side persists durable attach truth from `ResolvedLaunchContract`: + +- [crates/shell/src/execution/agents_cmd.rs:1159](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs:1159) + +That path rewrites `session.host_attach_contract` from: + +- `HostAttachContract::from_resolved_contract(...)` + +The REPL host-orchestrator cold-start path does not do that. It still: + +1. resolves only to a `RuntimeSelectionDescriptor` +2. constructs the manifest directly +3. constructs `OrchestrationSessionRecord::new(...)` +4. relies on `HostAttachContract::from_manifest(...)` + +Key references: + +- REPL bootstrap object only carries `RuntimeSelectionDescriptor`: + - [crates/shell/src/repl/async_repl.rs:1746](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs:1746) +- REPL host bootstrap resolves via inventory selection + runtime realizability, not shared contract resolution: + - [crates/shell/src/repl/async_repl.rs:2266](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs:2266) + - [crates/shell/src/repl/async_repl.rs:2291](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs:2291) +- REPL host startup persists a new orchestration session directly from the manifest: + - [crates/shell/src/repl/async_repl.rs:2204](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs:2204) +- `OrchestrationSessionRecord::new(...)` still seeds durable attach truth from `from_manifest(...)`: + - [crates/shell/src/execution/agent_runtime/orchestration_session.rs:349](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:349) +- `HostAttachContract::from_manifest(...)` still hardcodes default attach capabilities and no persisted policy snapshot: + - [crates/shell/src/execution/agent_runtime/orchestration_session.rs:152](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:152) + - [crates/shell/src/execution/agent_runtime/orchestration_session.rs:177](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:177) + - [crates/shell/src/execution/agent_runtime/orchestration_session.rs:182](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:182) + +Why this matters: + +- the 29.5 plan and SOW require one truthful contract floor across human and orchestrator-controlled launches +- this path still gives REPL/orchestrator cold starts a different durable attach truth shape than the public/start path +- the most visible drift is in attach-relevant capabilities and persisted `effective_policy` + +Practical consequence: + +- equivalent human and REPL cold starts are not yet guaranteed to persist equivalent host attach truth +- that invalidates the parity acceptance claim in 29.5 + +### Finding 2: persisted attach resolution only partially trusts persisted attach knobs + +Severity: Medium + +The resolver now reuses persisted capabilities and persisted policy snapshot, which is good. But attach-knob authority is still partial. + +Key reference: + +- [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:314](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:314) + +What it currently does: + +- trusts persisted `requested_execution_scope` +- trusts persisted attach-relevant capabilities +- trusts persisted policy snapshot if present +- takes `host_execution_client_start` from the caller envelope +- takes `attach_mode_preference` from the caller envelope + +Exact lines: + +- persisted policy snapshot load: + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:390](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:390) +- reconstructed attach knobs: + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:452](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:452) + +Why this matters: + +- the plan text says persisted attach resolution should trust persisted `attach_launch_knobs` baseline and then apply attach-mode request checks +- the landed code instead treats two of those knobs as caller-supplied runtime inputs rather than durable baseline truth + +This is not necessarily a wrong runtime design, but it does not match the contract language that says persisted attach knobs are authoritative baseline truth. + +### Finding 3: missing attach state can still regain permissive/default truth + +Severity: Medium + +The closeout docs and plan describe birth-time durable attach truth as non-negotiable. There are still fallback paths that recreate weaker/default truth. + +Key references: + +- if a session has no host attach contract, `sync_host_attach_contract(...)` recreates one from manifest defaults: + - [crates/shell/src/execution/agent_runtime/orchestration_session.rs:388](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:388) +- if persisted policy is absent, persisted attach resolution falls back to `Policy::default()`: + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:390](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:390) + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:403](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:403) + +Why this matters: + +- 29.5 explicitly called out the need to stop regaining permissive defaults from ambient/runtime state +- these fallback branches mean the durable truth is still not fully fail-closed in every path + +## Items That Look Correct + +### Inventory `policy_overlay` merge + +This appears to be landed correctly. + +Key references: + +- inventory resolution applies overlay into `effective_policy`: + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:475](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:475) + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:653](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:653) +- overlay validation remains restriction-only: + - [crates/shell/src/execution/agent_inventory.rs:616](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:616) + +### Bounded capability narrowing + +This also appears landed correctly for the explicitly approved family. + +Key references: + +- unsupported families fail closed: + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:663](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:663) +- supported family only narrows from `true` to `false`: + - [crates/shell/src/execution/agent_runtime/dispatch_contract.rs:760](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:760) + +### Retained member follow-up parity subset + +This looks substantially aligned with the 29.5 intent. + +Key references: + +- retained parity helper: + - [crates/shell/src/repl/async_repl.rs:4186](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs:4186) +- typed follow-up dispatch uses parity subset instead of live descriptor drift: + - [crates/shell/src/repl/async_repl.rs:4677](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs:4677) + +## Test and Validation Notes + +Commands run during audit: + +```bash +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture +cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture +cargo test -p shell --test repl_world_first_routing_v1 c3_world_restart_invalidates_stale_member_generation_before_publish -- --nocapture +``` + +Observed results: + +- `agent_public_control_surface_v1`: passed +- `agent_successor_contract_ahcsitc0`: passed +- `repl_world_first_routing_v1`: initially showed one failure when large suites were run in parallel, but the failing case passed when rerun alone + +Important environment note: + +- later extra filtered `cargo test -p shell ...` attempts hit local environment failures including `No space left on device` and linker/tempdir failures inside `target/debug` +- those environment failures should not be interpreted as product regressions, but they did limit additional verification passes + +## Recommended Remediation Focus + +If a fresh session is planning the fix, the highest-value remediation target is: + +1. route REPL host-orchestrator cold start through the same shared dispatch contract used by the public/start path +2. carry a `ResolvedLaunchContract` or equivalent resolved-contract truth into REPL host session birth +3. persist `HostAttachContract` from `HostAttachContract::from_resolved_contract(...)` in that path +4. remove or quarantine fallback `from_manifest(...)` reconstruction for steady-state birth paths +5. then decide whether persisted attach knobs are truly durable baseline truth or caller-supplied attach-mode inputs, and align code plus docs one way or the other + +## Suggested Questions For The Next Session + +1. Should REPL host cold start produce a `ResolvedLaunchContract` directly, or should it wrap existing selection logic with the shared resolver before runtime materialization? +2. Is `attach_mode_preference` supposed to be persisted baseline truth, caller intent at attach time, or a split between baseline plus caller narrowing? +3. Should `effective_policy` be mandatory in persisted `HostAttachContract`, with missing policy treated as corruption instead of defaulting? +4. Can `HostAttachContract::from_manifest(...)` be restricted to compatibility/recovery-only usage so steady-state launch paths cannot silently bypass resolved-contract truth? + +## Bottom Line + +The slice is close, but it is not yet honest enough to call fully complete. + +The largest remaining issue is not in the public control path. It is in the REPL/orchestrator cold-start path, which still persists durable attach truth from manifest-era defaults instead of from the shared resolved contract that 29.5 says should now be authoritative everywhere. diff --git a/UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md b/UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md new file mode 100644 index 000000000..ce36fb9cf --- /dev/null +++ b/UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md @@ -0,0 +1,355 @@ +# UAA Promptless Resume/Fork Synthesis For Substrate + +Status: post-28.5 runtime synthesis, not a normative UAA spec +Date: 2026-05-24 UTC +Scope: record what the current Substrate runtime actually does after the control-only attach and honest successor-allocation split landed + +Unified Agent API repo referenced in this synthesis: + +- repo path: `/home/azureuser/__Active_Code/atomize-hq/unified-agent-api` +- branch observed during synthesis: `feat/promptless-resume` +- previously inspected commit during this line of work: `3b7a4ef` + +That UAA branch still matters because it contains promptless resume/fork behavior Substrate +evaluated during this execution slice. But the current Substrate runtime no longer depends on blank +prompt semantics to satisfy its public `reattach` and `fork` contracts. + +## Purpose + +This document captures the current answer to a narrower question than earlier drafts: + +Does the landed Substrate runtime still need Unified Agent API promptless resume or promptless fork +as part of its live architecture? + +Current answer: + +- not for public `reattach`, +- not for public `fork`, +- and not as the architectural meaning of hidden owner-helper attach either. + +Future UAA contract work may still choose to publish an explicit control-only resume surface if +that proves broadly useful. But blank prompt is no longer the mechanism Substrate needs in order to +honor its current durable-session model. + +## Current Recommendation + +1. Treat promptless UAA resume/fork as implementation behavior under evaluation, not as required + Substrate architecture. +2. Keep Substrate public semantics frozen: + - `start` is prompt-taking root start, + - `turn` is prompt-taking follow-up, + - `reattach` is control-only attached-owner recovery for the same durable session, + - `fork` is control-only successor durable-session allocation, + - detached-world follow-up stays fail-closed until host ownership returns. +3. Do not revive `prompt: ""` or `InitialExecPromptPlan::NoPromptRecovery` as the meaning of + public `reattach` or `fork`. +4. If UAA eventually needs a published control-only contract, prefer an explicit surface over + empty-prompt signaling. + +## Executive Summary + +Earlier drafts in this area were driven by a real mismatch: + +- Substrate needed control-only host reattachment and honest successor allocation, +- the old hidden owner-helper convergence reused prompt-bearing run shapes, +- and the local UAA branch had landed promptless resume/fork behavior. + +That mismatch is no longer the live runtime story. + +The current Substrate runtime now separates the three concerns explicitly: + +1. prompt-bearing launch and prompt-bearing resumed turn submission still go through prompt-bearing + run control; +2. control-only host attach uses a dedicated attach path that consumes persisted continuity without + inventing a blank prompt; +3. public `fork` allocates durable successor state directly inside Substrate and does not launch a + backend process at allocation time. + +As a result: + +- promptless UAA resume is no longer required to implement current public `reattach`, +- promptless UAA fork is not required for current public `fork`, +- and the strongest remaining argument for a future explicit UAA control-only resume surface is + architectural cleanliness, not an immediate Substrate blocker. + +## Relevant Substrate Truth + +### Durable authority is the orchestration session + +The durable authority is the Substrate-owned orchestration session, not one currently attached +backend process. + +Primary truth anchors: + +- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) +- [ADR-0047](docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) +- [23-host-orchestrator-durable-session-and-parked-resumable-ownership.md](llm-last-mile/23-host-orchestrator-durable-session-and-parked-resumable-ownership.md) +- [24-fix-host-bootstrap-readiness-and-clean-detach-parking.md](llm-last-mile/24-fix-host-bootstrap-readiness-and-clean-detach-parking.md) +- [25-host-durable-session-closeout-and-qa-hardening.md](llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) + +Important consequences: + +- a clean prompt-driven backend exit must not automatically invalidate the durable host session; +- `parked_resumable` is valid session truth, not an owner-loss error; +- world-originated work may outlive the foreground host attachment; +- later control actions must reconstruct exact launch truth from durable state rather than from + whichever participant happened to be most recent. + +### Public semantics remain intentionally narrow + +Canonical public meaning: + +- `substrate agent start --backend ... --prompt ...` is root prompt-taking start; +- `substrate agent turn --session ... --backend ... --prompt ...` is prompt-taking follow-up on + the same durable session; +- `substrate agent reattach --session ...` is attached-owner recovery only and does not submit a + prompt; +- `substrate agent fork --session ...` allocates a successor durable session and returns it parked, + unattached, and truthfully non-active; +- detached-world follow-up stays fail-closed until host ownership is attached again. + +Primary anchors: + +- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) +- [docs/USAGE.md](docs/USAGE.md) +- [PLAN.md](PLAN.md) +- [ORCH_PLAN.md](ORCH_PLAN.md) + +## Where Substrate Persists Attach Truth + +Substrate now persists attach-relevant truth under the orchestration session itself. + +Key places: + +- [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [crates/shell/src/execution/agent_runtime/state_store.rs](crates/shell/src/execution/agent_runtime/state_store.rs) +- [crates/shell/src/execution/agent_runtime/control.rs](crates/shell/src/execution/agent_runtime/control.rs) +- [crates/shell/src/execution/agents_cmd.rs](crates/shell/src/execution/agents_cmd.rs) +- [crates/shell/src/repl/async_repl.rs](crates/shell/src/repl/async_repl.rs) + +Important internal rules: + +- `host_attach_contract` is the durable host attach truth carried by the orchestration session; +- `host_attach_contract.continuity_uaa_session_id` is private continuity state, not a public + selector; +- successor allocation copies attach-contract shape but clears inherited + `continuity_uaa_session_id`; +- public selectors still reject `internal.uaa_session_id`. + +## What The Current Substrate Runtime Actually Does + +### Prompt-bearing follow-up still uses prompt-bearing resume + +Normal public follow-up `turn` remains prompt-bearing and still fits ordinary resume semantics. + +Host path: + +- [submit_host_prompt_turn(...)](crates/shell/src/execution/agent_runtime/control.rs) + +World-member follow-up path: + +- [submit_turn(...)](crates/world-service/src/member_runtime.rs) +- [MemberTurnSubmitRequestV1 transport use](crates/shell/src/repl/async_repl.rs) + +Those paths carry a real prompt. They are not the problem this synthesis is about. + +### Control-only attach is now a dedicated runtime path + +The hidden owner-helper retained-owner flow now has an explicit attach mode instead of reusing +blank-prompt run shaping. + +Important flow: + +- attach planning uses the persisted `host_attach_contract` from the orchestration session; +- `build_attach_launch_plan(...)` carries forward the exact durable session id plus private + continuity selector when present; +- `start_host_orchestrator_runtime_with_prepared_prompt(...)` calls + `PromptFulfillmentBridge::run_attach_control(...)` when the startup mode is control-only attach; +- `run_attach_control(...)` maps backend-native attach/resume events into the existing control + stream and synthesizes canonical session-handle facets so readiness/persistence code can observe + attachment honestly. + +Primary anchors: + +- [build_attach_launch_plan(...)](crates/shell/src/execution/agents_cmd.rs) +- [start_host_orchestrator_runtime_with_prepared_prompt(...)](crates/shell/src/repl/async_repl.rs) +- [PromptFulfillmentBridge::run_attach_control(...)](crates/shell/src/execution/prompt_fulfillment.rs) + +This means current control-only reattach is implemented as explicit attach behavior, not as +`prompt: ""`. + +### Public `fork` is now durable successor allocation, not backend launch + +Public `fork` no longer needs promptless backend fork semantics in order to preserve honest +Substrate truth. + +Current behavior: + +- `allocate_fork_successor(...)` allocates the successor orchestration session and successor + participant directly in Substrate; +- the successor copies attach-contract shape from the source session; +- the successor clears inherited `continuity_uaa_session_id`; +- the successor is persisted as `parked_resumable` with `attached_participant_id = null`; +- public `run_fork(...)` returns the parked successor immediately and does not attach a live owner + loop. + +Primary anchors: + +- [allocate_fork_successor(...)](crates/shell/src/execution/agents_cmd.rs) +- [fork_successor_attach_contract(...)](crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [public_reattach_and_fork_preserve_exact_session_and_lineage_contracts](crates/shell/tests/agent_public_control_surface_v1.rs) + +This is a durable-state allocation action, not a hidden prompted run and not a backend-native +control-only fork. + +## What No Longer Matches Earlier Drafts + +The following older substrate-side assumptions are no longer live architecture: + +- `InitialExecPromptPlan::NoPromptRecovery` as the meaning of public `reattach` or `fork`; +- blank prompt being converted into UAA run requests to recover attached ownership; +- public `fork` needing `agent_api.session.resume.v1` or `agent_api.session.fork.v1` in order to + allocate a truthful successor session; +- hidden owner-helper `Fork` as a shared convergence mode that proved promptless fork pressure. + +The current runtime has replaced that convergence with: + +- dedicated control-only attach, +- prompt-bearing resumed-turn launch, +- and local successor allocation. + +## Relevant Unified Agent API Change + +The local UAA branch under evaluation still contains promptless resume/fork behavior: + +- `agent_api.session.resume.v1` +- `agent_api.session.fork.v1` + +Key implementation files in the local UAA checkout: + +- [../unified-agent-api/crates/agent_api/src/backend_harness/normalize.rs](../unified-agent-api/crates/agent_api/src/backend_harness/normalize.rs) +- [../unified-agent-api/crates/agent_api/src/backends/session_selectors.rs](../unified-agent-api/crates/agent_api/src/backends/session_selectors.rs) +- [../unified-agent-api/crates/agent_api/src/backends/codex/exec.rs](../unified-agent-api/crates/agent_api/src/backends/codex/exec.rs) +- [../unified-agent-api/crates/agent_api/src/backends/codex/fork.rs](../unified-agent-api/crates/agent_api/src/backends/codex/fork.rs) +- [../unified-agent-api/crates/agent_api/src/backends/codex/harness.rs](../unified-agent-api/crates/agent_api/src/backends/codex/harness.rs) +- [../unified-agent-api/crates/agent_api/src/backends/claude_code/util.rs](../unified-agent-api/crates/agent_api/src/backends/claude_code/util.rs) + +That behavior is still real in the UAA implementation tree. + +What changed on the Substrate side is the conclusion: + +- Substrate no longer needs blank-prompt resume/fork as the mechanism for its current public + control surfaces; +- the UAA implementation can still be interesting evidence for future contract design; +- but it is not a live dependency for this runtime slice. + +## Why Promptless Resume Is No Longer An Immediate Substrate Requirement + +There is still a meaningful architectural question about whether UAA should eventually publish an +explicit control-only resume surface. + +But the old immediate Substrate pressure has been relieved because: + +1. Substrate can now reattach through a dedicated attach-control path; +2. continuity stays private under the persisted host attach contract; +3. prompt-bearing follow-up remains on prompt-bearing `turn`; +4. public success truth is checked against actual attachment, not inferred from synthetic prompt + completion. + +So the current question is no longer "how do we make blank prompt safe enough?" It is "would an +explicit UAA control-only attach/resume surface still be worth standardizing later?" + +## Why Promptless Fork Is Not Needed For Current Substrate + +Current Substrate evidence is now much stronger than earlier drafts: + +1. public `fork` is satisfied by local durable successor allocation; +2. successor truth is honest without attaching a live owner loop; +3. inherited continuity is intentionally cleared on the successor; +4. no prompt-bearing follow-up is smuggled into `fork`; +5. the runtime no longer needs backend-native promptless fork to preserve public semantics. + +That makes promptless UAA fork unnecessary for the current architecture. + +## Recommended Direction + +### Direction A: keep current Substrate architecture and decouple it from blank prompt + +This is the active recommendation. + +- keep control-only attach inside the dedicated attach path; +- keep prompt-bearing follow-up on prompt-bearing `turn`; +- keep successor allocation as durable-state work; +- do not describe blank prompt as the architectural substrate for public `reattach` or `fork`. + +### Direction B: explore future explicit UAA control-only resume only if it buys clarity + +If future cross-backend work wants one published UAA control-only contract, prefer an explicit +surface rather than empty-prompt signaling. + +Examples to consider later: + +- an explicit attach/resume control API; +- a versioned resume extension with a first-class "no turn" mode; +- a gateway/private adapter seam if Substrate remains the only consumer. + +### Direction C: do not pursue promptless UAA fork unless requirements materially change + +There is no current Substrate requirement that justifies making promptless fork part of stable +published semantics. + +## Important Document Pointers + +### Substrate truth anchors + +- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) +- [ADR-0047](docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) +- [23-host-orchestrator-durable-session-and-parked-resumable-ownership.md](llm-last-mile/23-host-orchestrator-durable-session-and-parked-resumable-ownership.md) +- [24-fix-host-bootstrap-readiness-and-clean-detach-parking.md](llm-last-mile/24-fix-host-bootstrap-readiness-and-clean-detach-parking.md) +- [25-host-durable-session-closeout-and-qa-hardening.md](llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) +- [PLAN.md](PLAN.md) +- [ORCH_PLAN.md](ORCH_PLAN.md) + +### Substrate runtime files + +- [crates/shell/src/repl/async_repl.rs](crates/shell/src/repl/async_repl.rs) +- [crates/shell/src/execution/agent_runtime/control.rs](crates/shell/src/execution/agent_runtime/control.rs) +- [crates/shell/src/execution/agent_runtime/orchestration_session.rs](crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [crates/shell/src/execution/agent_runtime/state_store.rs](crates/shell/src/execution/agent_runtime/state_store.rs) +- [crates/shell/src/execution/agents_cmd.rs](crates/shell/src/execution/agents_cmd.rs) +- [crates/shell/src/execution/prompt_fulfillment.rs](crates/shell/src/execution/prompt_fulfillment.rs) +- [crates/world-service/src/member_runtime.rs](crates/world-service/src/member_runtime.rs) +- [docs/USAGE.md](docs/USAGE.md) + +### UAA normative docs + +Local UAA checkout under review: + +- repo: `/home/azureuser/__Active_Code/atomize-hq/unified-agent-api` +- branch: `feat/promptless-resume` +- commit previously observed in this line of work: `3b7a4ef` + +- [../unified-agent-api/docs/specs/unified-agent-api/run-protocol-spec.md](../unified-agent-api/docs/specs/unified-agent-api/run-protocol-spec.md) +- [../unified-agent-api/docs/specs/unified-agent-api/extensions-spec.md](../unified-agent-api/docs/specs/unified-agent-api/extensions-spec.md) +- [../unified-agent-api/docs/specs/claude-code-session-mapping-contract.md](../unified-agent-api/docs/specs/claude-code-session-mapping-contract.md) +- [../unified-agent-api/docs/specs/codex-wrapper-coverage-scenarios-v1.md](../unified-agent-api/docs/specs/codex-wrapper-coverage-scenarios-v1.md) +- [../unified-agent-api/docs/specs/codex-app-server-jsonrpc-contract.md](../unified-agent-api/docs/specs/codex-app-server-jsonrpc-contract.md) +- [../unified-agent-api/docs/specs/unified-agent-api/capability-matrix.md](../unified-agent-api/docs/specs/unified-agent-api/capability-matrix.md) + +## Questions To Keep Answering + +1. Would a published explicit UAA control-only resume surface simplify multi-backend adapter work + enough to justify the extra contract surface? +2. Should that future surface live in UAA proper, in the gateway adapter seam, or in a + Substrate-private control plane? +3. Do any future world-start or lazy-attach slices create a real new requirement that changes the + current "no promptless fork needed" conclusion? + +## Bottom Line + +Based on the current Substrate runtime: + +- promptless UAA resume is no longer required for live public `reattach`, +- promptless UAA fork is not required for live public `fork`, +- blank prompt should not be described as current Substrate architecture, +- and any future UAA control-only contract should be explicit rather than empty-prompt-shaped. diff --git a/crates/common/src/gateway_auth_bundle.rs b/crates/common/src/gateway_auth_bundle.rs index e0f52b5f8..93f4e79c2 100644 --- a/crates/common/src/gateway_auth_bundle.rs +++ b/crates/common/src/gateway_auth_bundle.rs @@ -34,9 +34,12 @@ pub const SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN: &str = "SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN"; pub const SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY: &str = "SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY"; +pub const SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY: &str = + "SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY"; pub const GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION: u32 = 1; pub const GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX: &str = "cli:codex"; +pub const GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE: &str = "cli:claude_code"; pub const GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI: &str = "api:openai"; pub const CLI_CODEX_GATEWAY_AUTH_ALLOWED_FIELDS: &[&str] = &[ @@ -45,6 +48,10 @@ pub const CLI_CODEX_GATEWAY_AUTH_ALLOWED_FIELDS: &[&str] = &[ ]; pub const CLI_CODEX_GATEWAY_AUTH_REQUIRED_FIELDS: &[&str] = &[SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN]; +pub const CLI_CLAUDE_CODE_GATEWAY_AUTH_ALLOWED_FIELDS: &[&str] = + &[SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY]; +pub const CLI_CLAUDE_CODE_GATEWAY_AUTH_REQUIRED_FIELDS: &[&str] = + &[SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY]; pub const API_OPENAI_GATEWAY_AUTH_ALLOWED_FIELDS: &[&str] = &[SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY]; pub const API_OPENAI_GATEWAY_AUTH_REQUIRED_FIELDS: &[&str] = @@ -141,6 +148,9 @@ pub fn validate_gateway_auth_bundle(bundle: &GatewayAuthBundleV1) -> Result<(), pub fn required_gateway_auth_fields(backend_id: &str) -> Result<&'static [&'static str], String> { match backend_id { GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX => Ok(CLI_CODEX_GATEWAY_AUTH_REQUIRED_FIELDS), + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE => { + Ok(CLI_CLAUDE_CODE_GATEWAY_AUTH_REQUIRED_FIELDS) + } GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI => Ok(API_OPENAI_GATEWAY_AUTH_REQUIRED_FIELDS), other => Err(format!( "unsupported gateway auth bundle backend_id '{}'", @@ -152,6 +162,9 @@ pub fn required_gateway_auth_fields(backend_id: &str) -> Result<&'static [&'stat pub fn allowed_gateway_auth_fields(backend_id: &str) -> Result<&'static [&'static str], String> { match backend_id { GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX => Ok(CLI_CODEX_GATEWAY_AUTH_ALLOWED_FIELDS), + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE => { + Ok(CLI_CLAUDE_CODE_GATEWAY_AUTH_ALLOWED_FIELDS) + } GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI => Ok(API_OPENAI_GATEWAY_AUTH_ALLOWED_FIELDS), other => Err(format!( "unsupported gateway auth bundle backend_id '{}'", @@ -194,6 +207,20 @@ mod tests { bundle.validate().unwrap(); } + #[test] + fn validate_accepts_cli_claude_code_bundle_with_required_field() { + let bundle = GatewayAuthBundleV1 { + schema_version: GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION, + backend_id: GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE.to_string(), + fields: HashMap::from([( + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY.to_string(), + "sk-ant-test".to_string(), + )]), + }; + + bundle.validate().unwrap(); + } + #[test] fn validate_rejects_unknown_backend() { let mut bundle = cli_codex_bundle(); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b0a98805c..241058e27 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -20,10 +20,13 @@ pub use fs_diff::FsDiff; pub use gateway_auth_bundle::{ allowed_gateway_auth_fields, gateway_auth_bundle_schema_version, required_gateway_auth_fields, validate_gateway_auth_bundle, GatewayAuthBundleV1, API_OPENAI_GATEWAY_AUTH_ALLOWED_FIELDS, - API_OPENAI_GATEWAY_AUTH_REQUIRED_FIELDS, CLI_CODEX_GATEWAY_AUTH_ALLOWED_FIELDS, + API_OPENAI_GATEWAY_AUTH_REQUIRED_FIELDS, CLI_CLAUDE_CODE_GATEWAY_AUTH_ALLOWED_FIELDS, + CLI_CLAUDE_CODE_GATEWAY_AUTH_REQUIRED_FIELDS, CLI_CODEX_GATEWAY_AUTH_ALLOWED_FIELDS, CLI_CODEX_GATEWAY_AUTH_REQUIRED_FIELDS, GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, - GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION, - SUBSTRATE_LLM_AUTH_BUNDLE_FD, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE, GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, + GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION, SUBSTRATE_LLM_AUTH_BUNDLE_FD, + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, + SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCOUNT_ID, }; @@ -195,9 +198,11 @@ pub mod prelude { allowed_gateway_auth_fields, gateway_auth_bundle_schema_version, required_gateway_auth_fields, validate_gateway_auth_bundle, GatewayAuthBundleV1, API_OPENAI_GATEWAY_AUTH_ALLOWED_FIELDS, API_OPENAI_GATEWAY_AUTH_REQUIRED_FIELDS, + CLI_CLAUDE_CODE_GATEWAY_AUTH_ALLOWED_FIELDS, CLI_CLAUDE_CODE_GATEWAY_AUTH_REQUIRED_FIELDS, CLI_CODEX_GATEWAY_AUTH_ALLOWED_FIELDS, CLI_CODEX_GATEWAY_AUTH_REQUIRED_FIELDS, - GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, - GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION, SUBSTRATE_LLM_AUTH_BUNDLE_FD, + GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE, + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION, + SUBSTRATE_LLM_AUTH_BUNDLE_FD, SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCOUNT_ID, diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 6d249df93..a166503b8 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -76,6 +76,7 @@ chrono = { workspace = true } # Timestamps url = "2" # URL parsing secrecy = "0.8" # Secure secret handling substrate-common = { path = "../common" } +agent_api = { package = "unified-agent-api", version = "=0.3.5", default-features = false, features = ["codex", "claude_code"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal"] } # Unix signals diff --git a/crates/gateway/src/adapter_runtime.rs b/crates/gateway/src/adapter_runtime.rs new file mode 100644 index 000000000..29703716f --- /dev/null +++ b/crates/gateway/src/adapter_runtime.rs @@ -0,0 +1,79 @@ +use std::{path::PathBuf, sync::Arc}; + +use agent_api::{ + backends::{ + claude_code::{ClaudeCodeBackend, ClaudeCodeBackendConfig}, + codex::{CodexBackend, CodexBackendConfig}, + }, + AgentWrapperError, AgentWrapperGateway, AgentWrapperKind, AgentWrapperRunControl, + AgentWrapperRunRequest, +}; +use anyhow::{anyhow, Result}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GatewayAdapterBackendKind { + Codex, + ClaudeCode, +} + +impl GatewayAdapterBackendKind { + fn agent_kind(self) -> Result { + let value = match self { + Self::Codex => "codex", + Self::ClaudeCode => "claude_code", + }; + AgentWrapperKind::new(value) + } +} + +pub struct GatewayAdapterRuntime { + gateway: AgentWrapperGateway, + agent_kind: AgentWrapperKind, +} + +impl GatewayAdapterRuntime { + pub fn for_backend( + backend_kind: GatewayAdapterBackendKind, + binary_path: PathBuf, + ) -> Result { + let mut gateway = AgentWrapperGateway::new(); + let binary = Some(binary_path); + + let agent_kind = match backend_kind { + GatewayAdapterBackendKind::Codex => { + gateway + .register(Arc::new(CodexBackend::new(CodexBackendConfig { + binary, + ..Default::default() + }))) + .map_err(map_wrapper_error)?; + backend_kind.agent_kind().map_err(map_wrapper_error)? + } + GatewayAdapterBackendKind::ClaudeCode => { + gateway + .register(Arc::new(ClaudeCodeBackend::new(ClaudeCodeBackendConfig { + binary, + ..Default::default() + }))) + .map_err(map_wrapper_error)?; + backend_kind.agent_kind().map_err(map_wrapper_error)? + } + }; + + Ok(Self { + gateway, + agent_kind, + }) + } + + pub async fn run_control( + &self, + request: AgentWrapperRunRequest, + ) -> Result { + self.gateway.run_control(&self.agent_kind, request).await + } +} + +fn map_wrapper_error(err: impl std::fmt::Display) -> anyhow::Error { + anyhow!("substrate-gateway adapter runtime: {err}") +} diff --git a/crates/gateway/src/cli/mod.rs b/crates/gateway/src/cli/mod.rs index 9fda6cf10..5bf2e1a2e 100644 --- a/crates/gateway/src/cli/mod.rs +++ b/crates/gateway/src/cli/mod.rs @@ -6,7 +6,9 @@ use crate::providers::ProviderConfig; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use substrate_common::SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY; +use substrate_common::{ + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, +}; /// Application configuration #[derive(Debug, Clone, Deserialize, Serialize)] @@ -77,6 +79,7 @@ fn referenced_env_var(value: &str) -> Option<&str> { } const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; +const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; impl Default for ServerConfig { fn default() -> Self { @@ -374,7 +377,9 @@ default = "placeholder-model" self.resolve_env_vars_with_blocked_secrets(&[ SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCOUNT_ID, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN, + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, + ANTHROPIC_API_KEY_ENV, OPENAI_API_KEY_ENV, ]) } diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index a0d81d7f2..e9d239fef 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -1,3 +1,4 @@ +pub mod adapter_runtime; pub mod auth; pub mod cli; pub mod core; diff --git a/crates/gateway/src/server/mod.rs b/crates/gateway/src/server/mod.rs index a122a440c..31dc736b9 100644 --- a/crates/gateway/src/server/mod.rs +++ b/crates/gateway/src/server/mod.rs @@ -36,18 +36,25 @@ use std::pin::Pin; use std::sync::Arc; use substrate_common::{ GatewayAuthBundleV1, GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, - GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, SUBSTRATE_LLM_AUTH_BUNDLE_FD, + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE, GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, + SUBSTRATE_LLM_AUTH_BUNDLE_FD, SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, }; use tokio::net::TcpListener; use tracing::{debug, error, info}; const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; +const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; #[derive(Debug, Clone)] pub enum IntegratedGatewayAuthContext { CliCodex(CodexIntegratedAuthHandoff), - ApiOpenAI { api_key: String }, + ApiKey { + backend_id: &'static str, + env_var: &'static str, + bundle_field: &'static str, + api_key: String, + }, } impl IntegratedGatewayAuthContext { @@ -64,21 +71,26 @@ impl IntegratedGatewayAuthContext { GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX => Ok(Self::CliCodex( CodexIntegratedAuthHandoff::from_fields(&bundle.fields)?, )), - GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI => { - let api_key = bundle - .fields - .get(SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - anyhow::anyhow!( - "Integrated gateway startup received invalid gateway auth bundle: gateway auth bundle for '{}' is missing required field '{}'", - GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, - SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY - ) - })?; - Ok(Self::ApiOpenAI { api_key }) - } + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE => Ok(Self::ApiKey { + backend_id: GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE, + env_var: ANTHROPIC_API_KEY_ENV, + bundle_field: SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, + api_key: read_required_bundle_field( + &bundle, + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE, + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, + )?, + }), + GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI => Ok(Self::ApiKey { + backend_id: GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, + env_var: OPENAI_API_KEY_ENV, + bundle_field: SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, + api_key: read_required_bundle_field( + &bundle, + GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, + SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, + )?, + }), other => Err(anyhow::anyhow!( "Integrated gateway startup received invalid gateway auth bundle: unsupported gateway auth bundle backend_id '{}'", other @@ -94,11 +106,35 @@ impl IntegratedGatewayAuthContext { install_integrated_codex_auth_handoff(Some(handoff.clone()))?; Ok(()) } - Self::ApiOpenAI { api_key } => overlay_api_openai_auth(config, api_key), + Self::ApiKey { + backend_id, + env_var, + bundle_field, + api_key, + } => overlay_api_key_auth(config, backend_id, env_var, bundle_field, api_key), } } } +fn read_required_bundle_field( + bundle: &GatewayAuthBundleV1, + backend_id: &str, + field_name: &str, +) -> anyhow::Result { + bundle + .fields + .get(field_name) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "Integrated gateway startup received invalid gateway auth bundle: gateway auth bundle for '{}' is missing required field '{}'", + backend_id, + field_name + ) + }) +} + /// Reloadable components - rebuilt on config reload pub struct ReloadableState { pub config: AppConfig, @@ -183,7 +219,13 @@ fn prepare_startup_config( Ok(()) } -fn overlay_api_openai_auth(config: &mut AppConfig, api_key: &str) -> anyhow::Result<()> { +fn overlay_api_key_auth( + config: &mut AppConfig, + backend_id: &str, + env_var: &str, + bundle_field: &str, + api_key: &str, +) -> anyhow::Result<()> { let mut applied = 0usize; for provider in &mut config.providers { @@ -193,8 +235,7 @@ fn overlay_api_openai_auth(config: &mut AppConfig, api_key: &str) -> anyhow::Res match provider.api_key.as_deref() { Some(value) - if value == format!("${OPENAI_API_KEY_ENV}") - || value == format!("${SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY}") => + if value == format!("${env_var}") || value == format!("${bundle_field}") => { provider.api_key = Some(api_key.to_string()); applied += 1; @@ -205,7 +246,10 @@ fn overlay_api_openai_auth(config: &mut AppConfig, api_key: &str) -> anyhow::Res if applied == 0 { anyhow::bail!( - "Integrated gateway startup could not apply api:openai auth bundle: no provider api_key matched $OPENAI_API_KEY or $SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY" + "Integrated gateway startup could not apply {} auth bundle: no provider api_key matched ${} or ${}", + backend_id, + env_var, + bundle_field ); } diff --git a/crates/gateway/tests/openai_shared_parity.rs b/crates/gateway/tests/openai_shared_parity.rs index 705a76ea1..b0849f79d 100644 --- a/crates/gateway/tests/openai_shared_parity.rs +++ b/crates/gateway/tests/openai_shared_parity.rs @@ -17,7 +17,8 @@ use std::path::PathBuf; #[cfg(unix)] use substrate_common::{ GatewayAuthBundleV1, GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI, - GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, SUBSTRATE_LLM_AUTH_BUNDLE_FD, + GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE, GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CODEX, + SUBSTRATE_LLM_AUTH_BUNDLE_FD, SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, }; use substrate_gateway::auth::CodexAuthSource; @@ -1058,7 +1059,15 @@ async fn integrated_gateway_startup_reads_openai_bundle_without_secret_env_fallb .expect("read api:openai auth bundle") .expect("in-world startup should produce integrated auth context"); match context { - IntegratedGatewayAuthContext::ApiOpenAI { api_key } => { + IntegratedGatewayAuthContext::ApiKey { + backend_id, + env_var, + bundle_field, + api_key, + } => { + assert_eq!(backend_id, GATEWAY_AUTH_BUNDLE_BACKEND_API_OPENAI); + assert_eq!(env_var, "OPENAI_API_KEY"); + assert_eq!(bundle_field, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY); assert_eq!(api_key, "sk-openai-bundle"); } other => panic!("unexpected integrated auth context: {other:?}"), @@ -1074,6 +1083,51 @@ async fn integrated_gateway_startup_reads_openai_bundle_without_secret_env_fallb ); } +#[cfg(unix)] +#[tokio::test] +async fn integrated_gateway_startup_reads_claude_bundle_without_secret_env_fallback() { + let _guard = ENV_LOCK.lock().await; + let _anthropic = EnvVarGuard::unset("ANTHROPIC_API_KEY"); + let _bundle = GatewayAuthBundleFdGuard::install(GatewayAuthBundleV1 { + schema_version: 1, + backend_id: GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE.to_string(), + fields: std::collections::HashMap::from([( + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY.to_string(), + "sk-ant-bundle".to_string(), + )]), + }); + + let context = IntegratedGatewayAuthContext::from_launch_mode(GatewayMode::InWorld) + .expect("read cli:claude_code auth bundle") + .expect("in-world startup should produce integrated auth context"); + match context { + IntegratedGatewayAuthContext::ApiKey { + backend_id, + env_var, + bundle_field, + api_key, + } => { + assert_eq!(backend_id, GATEWAY_AUTH_BUNDLE_BACKEND_CLI_CLAUDE_CODE); + assert_eq!(env_var, "ANTHROPIC_API_KEY"); + assert_eq!( + bundle_field, + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY + ); + assert_eq!(api_key, "sk-ant-bundle"); + } + other => panic!("unexpected integrated auth context: {other:?}"), + } + + assert!( + env::var("ANTHROPIC_API_KEY").is_err(), + "integrated startup should not require ANTHROPIC_API_KEY in process env" + ); + assert!( + env::var(SUBSTRATE_LLM_AUTH_BUNDLE_FD).is_err(), + "pointer env should be consumed on first read" + ); +} + #[tokio::test] async fn integrated_codex_missing_handoff_does_not_fallback_to_local_auth_files_at_route_boundary() { diff --git a/crates/lift/tests/support/repo_support.rs b/crates/lift/tests/support/repo_support.rs index 080f8625c..bf30258e2 100644 --- a/crates/lift/tests/support/repo_support.rs +++ b/crates/lift/tests/support/repo_support.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use jsonschema::{Retrieve, Uri, Validator}; @@ -14,12 +15,15 @@ pub(crate) struct TempDir { path: PathBuf, } +static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + impl TempDir { pub(crate) fn new(prefix: &str) -> Self { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time before unix epoch") .as_nanos(); + let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); let base = Path::new("/tmp"); let path = if base.exists() { base.to_path_buf() @@ -27,10 +31,12 @@ impl TempDir { std::env::temp_dir() } .join(format!( - "substrate-lift-{prefix}-{}-{suffix}", + "substrate-lift-{prefix}-{}-{suffix}-{counter}", std::process::id(), )); - fs::create_dir_all(&path).expect("temp dir should be creatable"); + // macOS can produce identical wall-clock timestamps across parallel test threads, + // so include a per-process counter and require the final directory to be new. + fs::create_dir(&path).expect("temp dir should be creatable"); Self { path } } diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 7e8892740..a3f6169bb 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -59,8 +59,11 @@ base64 = "0.22" url = "2" rand = "0.8" agent_api = { package = "unified-agent-api", version = "=0.3.5", default-features = false, features = ["codex", "claude_code"] } +claude_code = { package = "unified-agent-api-claude-code", version = "=0.3.5" } +codex = { package = "unified-agent-api-codex", version = "=0.3.5" } transport-api-client = { version = "0.2.8", path = "../transport-api-client" } transport-api-types = { version = "0.2.8", path = "../transport-api-types" } +substrate-gateway = { version = "0.2.8", path = "../gateway" } http-body-util = "0.1" hyper = "1" diff --git a/crates/shell/src/builtins/world_gateway.rs b/crates/shell/src/builtins/world_gateway.rs index 71a9e8d86..a360d47b3 100644 --- a/crates/shell/src/builtins/world_gateway.rs +++ b/crates/shell/src/builtins/world_gateway.rs @@ -29,6 +29,7 @@ const EXIT_INVALID_INTEGRATION: i32 = 2; const EXIT_TRANSIENT_FAILURE: i32 = 3; const EXIT_COMPONENT_UNAVAILABLE: i32 = 4; const EXIT_POLICY_FAILURE: i32 = 5; +const CLI_CLAUDE_CODE_BACKEND: &str = "cli:claude_code"; const CLI_CODEX_BACKEND: &str = "cli:codex"; const API_OPENAI_BACKEND: &str = "api:openai"; const API_ANTHROPIC_BACKEND: &str = "api:anthropic"; @@ -380,7 +381,7 @@ fn derive_gateway_identity_tuple( ) -> anyhow::Result { let protocol = match selected_backend { CLI_CODEX_BACKEND | API_OPENAI_BACKEND => "openai.responses", - API_ANTHROPIC_BACKEND => "anthropic.messages", + CLI_CLAUDE_CODE_BACKEND | API_ANTHROPIC_BACKEND => "anthropic.messages", other => { return Err(gateway_invalid_integration_error(format!( "unsupported backend '{}' for gateway identity tuple publication", @@ -390,7 +391,7 @@ fn derive_gateway_identity_tuple( }; let provider = match selected_backend { CLI_CODEX_BACKEND | API_OPENAI_BACKEND => Some("openai".to_string()), - API_ANTHROPIC_BACKEND => Some("anthropic".to_string()), + CLI_CLAUDE_CODE_BACKEND | API_ANTHROPIC_BACKEND => Some("anthropic".to_string()), _ => None, }; let tuple = IdentityTuple { @@ -657,6 +658,9 @@ fn resolve_integrated_auth_payload( api_env: None, })) } + agent_inventory::AgentConfigKind::Cli if selected_backend == CLI_CLAUDE_CODE_BACKEND => { + resolve_claude_code_integrated_auth(selected_backend, effective_policy) + } agent_inventory::AgentConfigKind::Cli => Ok(None), agent_inventory::AgentConfigKind::Api => { resolve_api_env_integrated_auth(selected_backend, backend_entry, effective_policy) @@ -664,6 +668,27 @@ fn resolve_integrated_auth_payload( } } +fn resolve_claude_code_integrated_auth( + selected_backend: &str, + effective_policy: &substrate_broker::Policy, +) -> anyhow::Result> { + let Some(api_key) = read_trimmed_env(ANTHROPIC_API_KEY_ENV) + .map_err(|err| gateway_invalid_integration_error(err.to_string()))? + else { + return Ok(None); + }; + + ensure_env_name_allowed(effective_policy, ANTHROPIC_API_KEY_ENV)?; + + Ok(Some(GatewayIntegratedAuthPayloadV1 { + backend_id: selected_backend.to_string(), + cli_codex: None, + api_env: Some(GatewayApiEnvIntegratedAuthV1 { + env: HashMap::from([(ANTHROPIC_API_KEY_ENV.to_string(), api_key)]), + }), + })) +} + fn resolve_cli_codex_integrated_auth( effective_policy: &substrate_broker::Policy, ) -> anyhow::Result { diff --git a/crates/shell/src/execution/agent_inventory.rs b/crates/shell/src/execution/agent_inventory.rs index 9d03ddf7b..ddbeb726f 100644 --- a/crates/shell/src/execution/agent_inventory.rs +++ b/crates/shell/src/execution/agent_inventory.rs @@ -83,7 +83,7 @@ pub(crate) struct AgentApiAuthConfigV1 { pub env: Vec, } -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub(crate) struct AgentCapabilitiesV1 { pub session_start: bool, @@ -102,6 +102,36 @@ pub(crate) struct AgentInventoryEntryV1 { pub file: AgentFileV1, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentInventoryBaselineOrigin { + GlobalInventory, + WorkspaceInventory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectedInventoryValueOrigin { + InventoryExplicit, + EffectiveConfigDefault, +} + +#[derive(Debug, Clone)] +pub(crate) struct ProjectedInventoryEntryV1 { + pub origin: AgentInventoryBaselineOrigin, + pub path: PathBuf, + pub agent_id: String, + pub backend_id: String, + pub kind: AgentConfigKind, + pub protocol: Option, + pub execution_scope: crate::execution::config_model::AgentExecutionScope, + pub execution_scope_origin: ProjectedInventoryValueOrigin, + pub cli_mode: crate::execution::config_model::AgentCliMode, + pub cli_mode_origin: ProjectedInventoryValueOrigin, + pub cli_binary: Option, + pub capabilities: AgentCapabilitiesV1, + #[allow(dead_code)] + pub policy_overlay: Option, +} + impl AgentFileV1 { pub(crate) fn derived_backend_id(&self) -> String { derive_agent_backend_id(self.config.kind.as_str(), &self.id) @@ -163,6 +193,92 @@ impl AgentInventoryEntryV1 { } } +pub(crate) fn inventory_entry_origin( + cwd: &Path, + entry: &AgentInventoryEntryV1, +) -> AgentInventoryBaselineOrigin { + let Some(workspace_root) = workspace::find_workspace_root(cwd) else { + return AgentInventoryBaselineOrigin::GlobalInventory; + }; + let workspace_agents_root = normalize_inventory_origin_path( + &workspace_root + .join(workspace::SUBSTRATE_DIR_NAME) + .join("agents"), + ); + let entry_path = normalize_inventory_origin_path(&entry.path); + + if entry_path.starts_with(&workspace_agents_root) { + AgentInventoryBaselineOrigin::WorkspaceInventory + } else { + AgentInventoryBaselineOrigin::GlobalInventory + } +} + +fn normalize_inventory_origin_path(path: &Path) -> PathBuf { + let mut current = path; + let mut suffix = Vec::new(); + + loop { + if let Ok(canonical) = current.canonicalize() { + let mut normalized = canonical; + for component in suffix.iter().rev() { + normalized.push(component); + } + return normalized; + } + + let Some(name) = current.file_name() else { + return path.to_path_buf(); + }; + let Some(parent) = current.parent() else { + return path.to_path_buf(); + }; + + suffix.push(name.to_os_string()); + current = parent; + } +} + +pub(crate) fn project_inventory_entry( + cwd: &Path, + entry: &AgentInventoryEntryV1, + effective_config: &crate::execution::config_model::SubstrateConfig, +) -> ProjectedInventoryEntryV1 { + let execution_scope_origin = if entry.file.config.execution.scope.is_some() { + ProjectedInventoryValueOrigin::InventoryExplicit + } else { + ProjectedInventoryValueOrigin::EffectiveConfigDefault + }; + let cli_mode_origin = if entry + .file + .config + .cli + .as_ref() + .and_then(|cli| cli.mode) + .is_some() + { + ProjectedInventoryValueOrigin::InventoryExplicit + } else { + ProjectedInventoryValueOrigin::EffectiveConfigDefault + }; + + ProjectedInventoryEntryV1 { + origin: inventory_entry_origin(cwd, entry), + path: entry.path.clone(), + agent_id: entry.file.id.clone(), + backend_id: entry.derived_backend_id(), + kind: entry.file.config.kind, + protocol: entry.file.config.protocol.clone(), + execution_scope: entry.effective_scope(effective_config), + execution_scope_origin, + cli_mode: entry.effective_cli_mode(effective_config), + cli_mode_origin, + cli_binary: entry.effective_cli_binary().map(ToOwned::to_owned), + capabilities: entry.file.config.capabilities.clone(), + policy_overlay: entry.file.policy_overlay.clone(), + } +} + fn default_true() -> bool { true } @@ -712,3 +828,52 @@ fn validate_overlay_subset( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + inventory_entry_origin, AgentCapabilitiesV1, AgentCliConfigV1, AgentConfigKind, + AgentConfigV1, AgentExecutionConfigV1, AgentFileV1, AgentInventoryBaselineOrigin, + AgentInventoryEntryV1, + }; + use crate::execution::workspace::{workspace_marker_path, SUBSTRATE_DIR_NAME}; + use tempfile::tempdir; + + #[test] + fn inventory_entry_origin_treats_noncanonical_workspace_paths_as_workspace_inventory() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let workspace_agents = workspace_root.join(SUBSTRATE_DIR_NAME).join("agents"); + std::fs::create_dir_all(&workspace_agents).expect("workspace agents"); + std::fs::write(workspace_marker_path(&workspace_root), "version: 1\n") + .expect("workspace marker"); + + let cwd = workspace_root.join("src"); + std::fs::create_dir_all(&cwd).expect("cwd"); + let entry = AgentInventoryEntryV1 { + path: workspace_agents.join("codex.yaml"), + file: AgentFileV1 { + version: 1, + id: "codex".to_string(), + config: AgentConfigV1 { + enabled: true, + kind: AgentConfigKind::Cli, + protocol: Some("transport.iframe.v1".to_string()), + execution: AgentExecutionConfigV1::default(), + cli: Some(AgentCliConfigV1 { + binary: "codex".to_string(), + mode: None, + }), + api: None, + capabilities: AgentCapabilitiesV1::default(), + }, + policy_overlay: None, + }, + }; + + assert_eq!( + inventory_entry_origin(&cwd, &entry), + AgentInventoryBaselineOrigin::WorkspaceInventory + ); + } +} diff --git a/crates/shell/src/execution/agent_runtime/control.rs b/crates/shell/src/execution/agent_runtime/control.rs index 52db8bef2..f8b2e4716 100644 --- a/crates/shell/src/execution/agent_runtime/control.rs +++ b/crates/shell/src/execution/agent_runtime/control.rs @@ -30,10 +30,13 @@ use uuid::Uuid; #[cfg(unix)] use crate::execution::agent_events::format_event_line; -use crate::execution::agent_runtime::orchestration_session::StartupPromptStreamState; +use crate::execution::agent_runtime::orchestration_session::{ + HostAttachContract, StartupPromptStreamState, +}; #[cfg(target_os = "linux")] use crate::execution::build_agent_client_and_pending_diff_request; use crate::execution::config_model::AgentExecutionScope; +use crate::execution::prompt_fulfillment::PromptFulfillmentCancelHandle; use super::{ mapping::AgentRuntimeBackendKind, session::AgentRuntimeSessionManifest, @@ -63,9 +66,8 @@ const STARTUP_PROMPT_STREAM_ACCEPT_TIMEOUT: Duration = Duration::from_secs(10); #[serde(rename_all = "snake_case")] pub(crate) enum OwnerHelperMode { Start, - Resume, + Attach, ResumeOneTurn, - Fork, } #[allow(dead_code)] @@ -73,9 +75,8 @@ impl OwnerHelperMode { pub(crate) fn as_str(self) -> &'static str { match self { Self::Start => "start", - Self::Resume => "resume", + Self::Attach => "attach", Self::ResumeOneTurn => "resume_one_turn", - Self::Fork => "fork", } } } @@ -268,6 +269,8 @@ pub(crate) struct HiddenOwnerHelperLaunchPlan { pub session: HiddenOwnerHelperSessionPlan, pub participant: HiddenOwnerHelperParticipantPlan, #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_attach_contract: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub startup_prompt: Option, pub source_orchestration_session_id: Option, } @@ -285,7 +288,7 @@ impl HiddenOwnerHelperLaunchPlan { pub(crate) fn requires_internal_session_id(&self) -> bool { matches!( self.mode, - OwnerHelperMode::Resume | OwnerHelperMode::ResumeOneTurn | OwnerHelperMode::Fork + OwnerHelperMode::Attach | OwnerHelperMode::ResumeOneTurn ) } } @@ -576,15 +579,38 @@ where pub(crate) fn run_hidden_owner_helper_startup_prompt_stream( listener: StartupPromptTransportListener, json: bool, +) -> Result<()> { + run_hidden_owner_helper_startup_prompt_stream_with_action( + listener, + json, + PublicPromptAction::Start, + ) +} + +#[cfg(unix)] +pub(crate) fn run_hidden_owner_helper_startup_prompt_stream_with_action( + listener: StartupPromptTransportListener, + json: bool, + action: PublicPromptAction, ) -> Result<()> { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .context("failed to initialize startup prompt transport runtime")?; let mut renderer = PublicPromptRenderer::new(json); + let mut stream_started = false; + let mut saw_terminal = false; let result = rt.block_on(async { consume_hidden_owner_helper_startup_prompt_stream(listener, |envelope| { - renderer.render(envelope) + stream_started = true; + if matches!( + envelope, + PublicPromptEnvelope::Completed { .. } | PublicPromptEnvelope::Failed { .. } + ) { + saw_terminal = true; + } + let rewritten = rewrite_startup_prompt_envelope_action(envelope, action); + renderer.render(&rewritten) }) .await }); @@ -594,10 +620,68 @@ pub(crate) fn run_hidden_owner_helper_startup_prompt_stream( Ok(code) => Err(anyhow::Error::new(PublicPromptRenderedExit { exit_code: code, })), + Err(err) if stream_started && action == PublicPromptAction::Turn => { + if !saw_terminal { + renderer.render(&failed_prompt_envelope( + "bridge", + "owner_unreachable", + err.to_string(), + ))?; + } + Err(anyhow::Error::new(PublicPromptRenderedExit { + exit_code: 1, + })) + } Err(err) => Err(err), } } +#[cfg(unix)] +fn rewrite_startup_prompt_envelope_action( + envelope: &PublicPromptEnvelope, + action: PublicPromptAction, +) -> PublicPromptEnvelope { + match envelope { + PublicPromptEnvelope::Accepted { + version, + orchestration_session_id, + backend_id, + participant_id, + scope, + .. + } => PublicPromptEnvelope::Accepted { + version: *version, + action, + orchestration_session_id: orchestration_session_id.clone(), + backend_id: backend_id.clone(), + participant_id: participant_id.clone(), + scope: scope.clone(), + }, + PublicPromptEnvelope::Completed { + version, + orchestration_session_id, + backend_id, + participant_id, + turn_outcome, + session_posture, + state, + warnings, + .. + } => PublicPromptEnvelope::Completed { + version: *version, + action, + orchestration_session_id: orchestration_session_id.clone(), + backend_id: backend_id.clone(), + participant_id: participant_id.clone(), + turn_outcome: turn_outcome.clone(), + session_posture: *session_posture, + state: state.clone(), + warnings: warnings.clone(), + }, + _ => envelope.clone(), + } +} + #[allow(dead_code)] pub(crate) fn persist_hidden_owner_helper_launch_plan( store: &AgentRuntimeStateStore, @@ -668,18 +752,20 @@ pub(crate) fn wait_for_hidden_owner_helper_readiness( plan.participant_id(), plan.requires_internal_session_id(), )?; - let startup_prompt_ready = if plan.mode == OwnerHelperMode::Start { + let startup_prompt_ready = if plan.startup_prompt.is_some() { start_launch_startup_prompt_is_accepted_or_terminal(store, plan)? } else { true }; if startup_prompt_ready && (readiness == super::state_store::HiddenOwnerHelperLaunchReadiness::ReadyAttached - || (plan.mode == OwnerHelperMode::Start - && matches!( - readiness, - super::state_store::HiddenOwnerHelperLaunchReadiness::ReadyDetached(_) - ))) + || (matches!( + plan.mode, + OwnerHelperMode::Start | OwnerHelperMode::ResumeOneTurn + ) && matches!( + readiness, + super::state_store::HiddenOwnerHelperLaunchReadiness::ReadyDetached(_) + ))) { return Ok(()); } @@ -781,6 +867,103 @@ pub(crate) fn reconcile_hidden_owner_helper_start_timeout( Ok(HiddenOwnerHelperStartTimeoutReconciliation::FailureUnchanged) } +#[cfg(unix)] +pub(crate) fn reconcile_resumed_public_turn_detach_timeout( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + resumed_participant_id: &str, +) -> Result { + if store + .resumed_public_turn_detach_posture(orchestration_session_id, resumed_participant_id)? + .is_some() + { + return Ok(true); + } + + let Some(session) = store.load_orchestration_session(orchestration_session_id)? else { + return Ok(false); + }; + let Some(participant) = store.load_participant(resumed_participant_id)? else { + return Ok(false); + }; + + let Some((next_session, next_participant)) = + build_resumed_public_turn_detach_reconciliation(&session, &participant) + else { + return Ok(false); + }; + + if next_session != session { + store.persist_orchestration_session(&next_session)?; + } + if next_participant != participant { + store.persist_participant(&next_participant)?; + } + + Ok(store + .resumed_public_turn_detach_posture(orchestration_session_id, resumed_participant_id)? + .is_some()) +} + +#[cfg(unix)] +fn build_resumed_public_turn_detach_reconciliation( + session: &OrchestrationSessionRecord, + participant: &AgentRuntimeSessionManifest, +) -> Option<(OrchestrationSessionRecord, AgentRuntimeSessionManifest)> { + build_detached_start_reconciliation(session, participant, true) +} + +#[cfg(unix)] +pub(crate) fn reconcile_start_prompt_completion_timeout( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + participant_id: &str, +) -> Result { + if matches!( + store.classify_hidden_owner_helper_launch_readiness( + orchestration_session_id, + participant_id, + true, + )?, + super::state_store::HiddenOwnerHelperLaunchReadiness::ReadyDetached(_) + ) { + return Ok(true); + } + + let Some(session) = store.load_orchestration_session(orchestration_session_id)? else { + return Ok(false); + }; + let Some(participant) = store.load_participant(participant_id)? else { + return Ok(false); + }; + + if !startup_prompt_is_terminal_for_participant(&session, participant_id) { + return Ok(false); + } + + let Some((next_session, next_participant)) = + build_detached_start_reconciliation(&session, &participant, true) + else { + return Ok(false); + }; + + if next_session != session { + store.persist_orchestration_session(&next_session)?; + } + if next_participant != participant { + store.persist_participant(&next_participant)?; + } + + Ok(matches!( + store.classify_hidden_owner_helper_launch_readiness( + orchestration_session_id, + participant_id, + true, + )?, + super::state_store::HiddenOwnerHelperLaunchReadiness::ReadyDetached(_) + )) +} + fn start_launch_startup_prompt_is_accepted_or_terminal( store: &AgentRuntimeStateStore, plan: &HiddenOwnerHelperLaunchPlan, @@ -968,7 +1151,9 @@ pub(crate) fn persist_runtime_snapshots( orchestration_session: &OrchestrationSessionRecord, manifest: &AgentRuntimeSessionManifest, ) -> Result<()> { - store.persist_orchestration_session(orchestration_session)?; + let mut orchestration_snapshot = orchestration_session.clone(); + orchestration_snapshot.sync_host_attach_contract(manifest); + store.persist_orchestration_session(&orchestration_snapshot)?; store.persist_participant(manifest) } @@ -1188,7 +1373,7 @@ pub(crate) fn spawn_local_private_stop_owner( orchestration_session: Arc>, manifest: Arc>, shutdown_requested: Arc, - cancel: agent_api::AgentWrapperCancelHandle, + cancel: PromptFulfillmentCancelHandle, mut stop_rx: PrivateStopRequestReceiver, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { @@ -1257,11 +1442,8 @@ pub(crate) async fn submit_host_prompt_turn( where F: FnMut(SubmittedPromptStreamEvent), { - let gateway = super::build_gateway_for_descriptor(&runtime.descriptor) + let prompt_fulfillment = super::build_gateway_for_descriptor(&runtime.descriptor) .context("build host targeted-turn gateway")?; - let agent_kind = - agent_api::AgentWrapperKind::new(runtime.descriptor.backend_kind.as_agent_kind_str()) - .map_err(|err| anyhow::anyhow!("substrate: error: {err}"))?; let request = agent_api::AgentWrapperRunRequest { prompt: prompt.to_string(), @@ -1273,15 +1455,14 @@ where build_session_resume_extension(&runtime.uaa_session_handle_id), )]), }; - let control = gateway - .run_control(&agent_kind, request) + let control = prompt_fulfillment + .run_control(request) .await .map_err(|err| anyhow::anyhow!("substrate: error: {}", err))?; - let agent_api::AgentWrapperRunControl { handle, cancel: _ } = control; let agent_api::AgentWrapperRunHandle { mut events, completion, - } = handle; + } = control.handle; while let Some(wrapper_event) = events.next().await { let (orchestration_snapshot, manifest_snapshot, event) = { @@ -2483,15 +2664,19 @@ mod tests { use super::{ handle_private_prompt_connection, private_prompt_request_channel, PublicPromptEnvelope, }; + use crate::execution::agent_runtime::orchestration_session::HostAttachContract; use crate::execution::agent_runtime::{ mapping::AgentRuntimeBackendKind, orchestration_session::{ OrchestrationSessionPosture, OrchestrationSessionRecord, OrchestrationSessionState, StartupPromptStreamState, }, - session::{AgentRuntimeParticipantRecord, AgentRuntimeSessionState}, + session::{ + AgentRuntimeParticipantRecord, AgentRuntimeReplacementParticipantInit, + AgentRuntimeSessionState, + }, validator::RuntimeSelectionDescriptor, - AgentRuntimeStateStore, + AgentRuntimeStateStore, ORCHESTRATOR_ROLE, }; use crate::execution::config_model::AgentExecutionScope; use std::path::PathBuf; @@ -2537,6 +2722,46 @@ mod tests { resumed_from_participant_id: None, internal_uaa_session_id: None, }, + host_attach_contract: None, + startup_prompt: Some(HiddenOwnerHelperStartupPromptPlan { + prompt_text: "hello".to_string(), + stream_path: PathBuf::from("/tmp/startup.sock"), + }), + source_orchestration_session_id: None, + } + } + + fn resumed_turn_test_plan( + orchestration_session_id: &str, + participant_id: &str, + resumed_from_participant_id: &str, + internal_uaa_session_id: &str, + ) -> HiddenOwnerHelperLaunchPlan { + HiddenOwnerHelperLaunchPlan { + mode: OwnerHelperMode::ResumeOneTurn, + descriptor: ResolvedRuntimeDescriptor { + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: ResolvedRuntimeBackendKind::Codex, + protocol: PURE_AGENT_PROTOCOL.to_string(), + execution_scope: AgentExecutionScope::Host, + binary_path: "/usr/bin/codex".to_string(), + }, + session: HiddenOwnerHelperSessionPlan { + orchestration_session_id: orchestration_session_id.to_string(), + shell_trace_session_id: "trace_session".to_string(), + workspace_root: "/workspace".to_string(), + world_id: None, + world_generation: None, + }, + participant: HiddenOwnerHelperParticipantPlan { + participant_id: participant_id.to_string(), + lease_token: format!("lease_{participant_id}"), + run_id: "run_resume_one_turn".to_string(), + resumed_from_participant_id: Some(resumed_from_participant_id.to_string()), + internal_uaa_session_id: Some(internal_uaa_session_id.to_string()), + }, + host_attach_contract: None, startup_prompt: Some(HiddenOwnerHelperStartupPromptPlan { prompt_text: "hello".to_string(), stream_path: PathBuf::from("/tmp/startup.sock"), @@ -2548,9 +2773,8 @@ mod tests { #[test] fn owner_helper_modes_remain_internal_and_exact() { assert_eq!(OwnerHelperMode::Start.as_str(), "start"); - assert_eq!(OwnerHelperMode::Resume.as_str(), "resume"); + assert_eq!(OwnerHelperMode::Attach.as_str(), "attach"); assert_eq!(OwnerHelperMode::ResumeOneTurn.as_str(), "resume_one_turn"); - assert_eq!(OwnerHelperMode::Fork.as_str(), "fork"); } #[test] @@ -2589,6 +2813,7 @@ mod tests { "trace_session".to_string(), "/workspace".to_string(), &manifest, + HostAttachContract::from_manifest_for_test(&manifest), ); orchestration.transition_state(OrchestrationSessionState::Active); orchestration.bind_active_session_handle(manifest.handle.participant_id.clone()); @@ -2670,6 +2895,7 @@ mod tests { "trace_session".to_string(), "/workspace".to_string(), &participant, + HostAttachContract::from_manifest_for_test(&participant), ); orchestration.transition_state(OrchestrationSessionState::Active); orchestration.bind_active_session_handle(participant.handle.participant_id.clone()); @@ -2752,6 +2978,7 @@ mod tests { "trace_session".to_string(), "/workspace".to_string(), &participant, + HostAttachContract::from_manifest_for_test(&participant), ); orchestration.transition_state(OrchestrationSessionState::Active); orchestration.bind_active_session_handle(participant.handle.participant_id.clone()); @@ -2773,6 +3000,72 @@ mod tests { }); } + #[test] + #[serial_test::serial] + fn helper_readiness_accepts_resume_one_turn_after_fast_detach_once_prompt_is_terminal() { + with_store(|store| { + let descriptor = RuntimeSelectionDescriptor { + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: AgentRuntimeBackendKind::Codex, + protocol: PURE_AGENT_PROTOCOL.to_string(), + execution_scope: AgentExecutionScope::Host, + binary_path: PathBuf::from("/usr/bin/codex"), + }; + let mut participant = AgentRuntimeParticipantRecord::new_replacement_participant( + &descriptor, + AgentRuntimeReplacementParticipantInit { + orchestration_session_id: "sess_resume_turn_ready".to_string(), + participant_id: "ash_resume_turn_ready".to_string(), + role: ORCHESTRATOR_ROLE.to_string(), + orchestrator_participant_id: None, + parent_participant_id: None, + resumed_from_participant_id: "ash_source".to_string(), + world: None, + lease_token: "lease_resume_turn_ready".to_string(), + }, + ) + .expect("replacement participant"); + participant.transition_state(AgentRuntimeSessionState::Ready); + participant.set_uaa_session_id("thread-test"); + participant.mark_client_detached("owner detached cleanly"); + + let mut orchestration = OrchestrationSessionRecord::new( + "sess_resume_turn_ready".to_string(), + "trace_session".to_string(), + "/workspace".to_string(), + &participant, + HostAttachContract::from_manifest_for_test(&participant), + ); + orchestration.transition_state(OrchestrationSessionState::Active); + orchestration.bind_active_session_handle(participant.handle.participant_id.clone()); + orchestration.mark_parked_resumable("owner detached cleanly"); + orchestration.initialize_startup_prompt(participant.handle.participant_id.clone()); + orchestration.mark_startup_prompt_completed( + participant.handle.participant_id.as_str(), + "success", + ); + + store + .persist_orchestration_session(&orchestration) + .expect("persist orchestration"); + store + .persist_participant(&participant) + .expect("persist participant"); + + super::wait_for_hidden_owner_helper_readiness( + store, + &resumed_turn_test_plan( + "sess_resume_turn_ready", + "ash_resume_turn_ready", + "ash_source", + "thread-test", + ), + ) + .expect("resume_one_turn readiness should accept a terminalized fast-detach handoff"); + }); + } + #[test] #[serial_test::serial] fn start_timeout_reconciliation_marks_terminal_failure_when_startup_prompt_is_not_terminal() { @@ -2800,6 +3093,7 @@ mod tests { "trace_session".to_string(), "/workspace".to_string(), &participant, + HostAttachContract::from_manifest_for_test(&participant), ); orchestration.transition_state(OrchestrationSessionState::Active); orchestration.bind_active_session_handle(participant.handle.participant_id.clone()); diff --git a/crates/shell/src/execution/agent_runtime/dispatch_contract.rs b/crates/shell/src/execution/agent_runtime/dispatch_contract.rs new file mode 100644 index 000000000..35a93dddd --- /dev/null +++ b/crates/shell/src/execution/agent_runtime/dispatch_contract.rs @@ -0,0 +1,1675 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use substrate_broker::{validate_backend_id, Policy}; + +use crate::execution::agent_inventory::{ + project_inventory_entry, AgentCapabilitiesV1, AgentConfigKind, AgentInventoryBaselineOrigin, + AgentInventoryEntryV1, ProjectedInventoryEntryV1, ProjectedInventoryValueOrigin, +}; +use crate::execution::agent_runtime::orchestration_session::{ + HostAttachContract, HostAttachExecutionClientStart, HostAttachModePreference, +}; +use crate::execution::config_model::{AgentCliMode, AgentExecutionScope, SubstrateConfig}; +use crate::execution::policy_model::{apply_policy_patch, PolicyPatch}; + +use super::mapping::{ + orchestrator_backend_kind, protocol_validation_error, AgentRuntimeBackendKind, + PURE_AGENT_PROTOCOL, +}; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DispatchCallerKind { + HumanStart, + HumanTurn, + HumanReattach, + HumanFork, + OrchestratorMemberStart, + OrchestratorMemberTurn, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DispatchBaselineKind { + InventoryLaunch, + PersistedHostAttach, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FieldBaselineOrigin { + GlobalInventory, + WorkspaceInventory, + PersistedHostAttachContract, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FieldValueOrigin { + InventoryExplicit, + EffectiveConfigDefault, + DispatchOverrideAccepted, + DispatchOverrideNarrowedByPolicy, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FieldProvenance { + pub baseline_origin: FieldBaselineOrigin, + pub value_origin: FieldValueOrigin, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct DispatchCapabilityOverrideSet { + pub session_start: Option, + pub session_resume: Option, + pub session_fork: Option, + pub session_stop: Option, + pub status_snapshot: Option, + pub event_stream: Option, + pub llm: Option, + pub mcp_client: Option, +} + +impl DispatchCapabilityOverrideSet { + pub(crate) fn is_empty(&self) -> bool { + [ + self.session_start, + self.session_resume, + self.session_fork, + self.session_stop, + self.status_snapshot, + self.event_stream, + self.llm, + self.mcp_client, + ] + .into_iter() + .all(|value| value.is_none()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HostExecutionClientStart { + StartNow, + Defer, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AttachModePreference { + ContinuityRequired, + ContinuityPreferred, + FreshAllowed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AttachLaunchKnobs { + pub requested_execution_scope: AgentExecutionScope, + pub host_execution_client_start: HostExecutionClientStart, + pub attach_mode_preference: AttachModePreference, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DispatchRequestEnvelope { + pub caller_kind: DispatchCallerKind, + pub baseline_kind: DispatchBaselineKind, + pub backend_id: Option, + pub orchestration_session_id: Option, + pub requested_execution_scope_override: Option, + pub capability_overrides: DispatchCapabilityOverrideSet, + pub attach_launch_knobs: AttachLaunchKnobs, + pub has_prompt_payload: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResolvedLaunchRuntime { + pub kind: AgentConfigKind, + pub cli_mode: AgentCliMode, + pub cli_binary: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BaselineSourceMetadata { + pub baseline_kind: DispatchBaselineKind, + pub baseline_origin: FieldBaselineOrigin, + pub inventory_path: Option, + pub orchestration_session_id: Option, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct ResolvedLaunchContract { + pub caller_kind: DispatchCallerKind, + pub baseline_kind: DispatchBaselineKind, + pub agent_id: String, + pub backend_id: String, + pub backend_kind: AgentRuntimeBackendKind, + pub protocol: String, + pub execution_scope: AgentExecutionScope, + pub runtime: ResolvedLaunchRuntime, + pub capabilities: AgentCapabilitiesV1, + pub attach_launch_knobs: AttachLaunchKnobs, + pub effective_policy: Policy, + pub baseline_source: BaselineSourceMetadata, + pub field_provenance: BTreeMap, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DispatchResolutionErrorKind { + UnknownOverrideFamily, + OverrideNotSupportedForCaller, + OverrideExceedsBaseline, + InvalidPolicyOverlay, + OverrideDeniedByPolicy, + RuntimeUnrealizableAfterResolution, + MissingRequiredAttachContinuity, + BaselineNotFound, + AmbiguousBaselineSelection, + BaselineIneligible, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DispatchRejectingLayer { + CallerContract, + BaselineTruth, + Policy, + RuntimeMaterialization, +} + +impl DispatchRejectingLayer { + fn as_str(self) -> &'static str { + match self { + Self::CallerContract => "caller contract", + Self::BaselineTruth => "baseline truth", + Self::Policy => "policy", + Self::RuntimeMaterialization => "runtime materialization", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DispatchResolutionError { + pub kind: DispatchResolutionErrorKind, + pub field: &'static str, + pub rejecting_layer: DispatchRejectingLayer, + pub reason: String, +} + +impl std::fmt::Display for DispatchResolutionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} rejected field '{}': {}", + self.rejecting_layer.as_str(), + self.field, + self.reason + ) + } +} + +impl std::error::Error for DispatchResolutionError {} + +pub(crate) fn resolve_inventory_contract_for_exact_backend( + cwd: &Path, + effective_config: &SubstrateConfig, + inventory: &BTreeMap, + base_policy: &Policy, + envelope: &DispatchRequestEnvelope, + scope: AgentExecutionScope, +) -> Result, DispatchResolutionError> { + let backend_id = envelope + .backend_id + .as_deref() + .ok_or_else(|| DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineNotFound, + field: "backend_id", + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: "exact inventory-backed dispatch requires backend_id".to_string(), + })?; + validate_backend_id(backend_id).map_err(|err| DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineNotFound, + field: "backend_id", + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: err.to_string(), + })?; + + let mut matches = inventory + .values() + .map(|entry| project_inventory_entry(cwd, entry, effective_config)) + .filter(|entry| entry.execution_scope == scope && entry.backend_id == backend_id) + .collect::>(); + + match matches.len() { + 0 => Ok(None), + 1 => resolve_inventory_projected_contract( + base_policy, + envelope, + matches.pop().expect("single projected match"), + ) + .map(Some), + _ => { + let agent_ids = matches + .iter() + .map(|entry| entry.agent_id.as_str()) + .collect::>() + .join(", "); + Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::AmbiguousBaselineSelection, + field: "backend_id", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "ambiguous exact backend selection: multiple {} runtime entries advertise backend '{}' ({agent_ids})", + runtime_scope_label(scope), + backend_id, + ), + }) + } + } +} + +pub(crate) fn resolve_inventory_contract_for_unique_scope( + cwd: &Path, + effective_config: &SubstrateConfig, + inventory: &BTreeMap, + base_policy: &Policy, + envelope: &DispatchRequestEnvelope, + scope: AgentExecutionScope, +) -> Result, DispatchResolutionError> { + let mut selected = Vec::new(); + + for entry in inventory.values() { + let projected = project_inventory_entry(cwd, entry, effective_config); + if projected.execution_scope != scope { + continue; + } + selected.push(resolve_inventory_projected_contract( + base_policy, + envelope, + projected, + )?); + } + + match selected.len() { + 0 => Ok(None), + 1 => Ok(selected.into_iter().next()), + _ => { + let agent_ids = selected + .iter() + .map(|entry| entry.agent_id.as_str()) + .collect::>() + .join(", "); + Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::AmbiguousBaselineSelection, + field: "execution_scope", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "ambiguous world member selection: multiple eligible {} members found ({agent_ids})", + runtime_scope_label(scope), + ), + }) + } + } +} + +#[allow(dead_code)] +pub(crate) fn resolve_persisted_host_attach_contract( + envelope: &DispatchRequestEnvelope, + contract: &HostAttachContract, +) -> Result { + if envelope.baseline_kind != DispatchBaselineKind::PersistedHostAttach { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideNotSupportedForCaller, + field: "baseline_kind", + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: "persisted attach resolver requires baseline_kind=persisted_host_attach" + .to_string(), + }); + } + if envelope + .requested_execution_scope_override + .is_some_and(|scope| scope != contract.execution_scope) + { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field: "requested_execution_scope", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "persisted attach launch cannot replace execution scope {} with {}", + runtime_scope_label(contract.execution_scope), + runtime_scope_label(envelope.requested_execution_scope_override.unwrap()), + ), + }); + } + if !envelope.capability_overrides.is_empty() { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideNotSupportedForCaller, + field: "capability_overrides", + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: "persisted attach launches do not accept dispatch-time capability overrides in slice 29".to_string(), + }); + } + if envelope + .backend_id + .as_deref() + .is_some_and(|backend_id| backend_id != contract.backend_id) + { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field: "backend_id", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "persisted attach launch cannot replace backend '{}' with '{}'", + contract.backend_id, + envelope.backend_id.as_deref().unwrap_or_default(), + ), + }); + } + + let backend_kind = contract + .launch_descriptor + .backend_kind + .try_into() + .map_err(|err| DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineIneligible, + field: "backend_kind", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!("{err:#}"), + })?; + let agent_id = contract.launch_descriptor.agent_id.clone(); + let protocol = contract.protocol.clone(); + if protocol != PURE_AGENT_PROTOCOL { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineIneligible, + field: "protocol", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: protocol_validation_error( + &format!( + "persisted host attach contract backend '{}'", + contract.backend_id + ), + Some(protocol.as_str()), + ), + }); + } + + let effective_policy = serde_json::from_value::(contract.effective_policy.clone()) + .map_err(|err| DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineIneligible, + field: "effective_policy", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "persisted host attach contract stored an invalid policy snapshot: {err}" + ), + })?; + let attach_launch_knobs = resolve_persisted_attach_launch_knobs(envelope, contract)?; + + let mut field_provenance = BTreeMap::new(); + for field in [ + "agent_id", + "backend_id", + "protocol", + "execution_scope", + "cli_mode", + "cli_binary", + "session_resume", + "session_fork", + "session_stop", + "status_snapshot", + "event_stream", + "effective_policy", + "requested_execution_scope", + "host_execution_client_start", + "attach_mode_preference", + ] { + field_provenance.insert( + field.to_string(), + FieldProvenance { + baseline_origin: FieldBaselineOrigin::PersistedHostAttachContract, + value_origin: FieldValueOrigin::InventoryExplicit, + }, + ); + } + + Ok(ResolvedLaunchContract { + caller_kind: envelope.caller_kind, + baseline_kind: DispatchBaselineKind::PersistedHostAttach, + agent_id, + backend_id: contract.backend_id.clone(), + backend_kind, + protocol, + execution_scope: contract.execution_scope, + runtime: ResolvedLaunchRuntime { + kind: AgentConfigKind::Cli, + cli_mode: AgentCliMode::Persistent, + cli_binary: Some(contract.launch_descriptor.binary_path.clone()), + }, + capabilities: AgentCapabilitiesV1 { + session_start: true, + session_resume: contract.capabilities.session_resume, + session_fork: contract.capabilities.session_fork, + session_stop: contract.capabilities.session_stop, + status_snapshot: contract.capabilities.status_snapshot, + event_stream: contract.capabilities.event_stream, + llm: true, + mcp_client: false, + }, + attach_launch_knobs, + effective_policy, + baseline_source: BaselineSourceMetadata { + baseline_kind: DispatchBaselineKind::PersistedHostAttach, + baseline_origin: FieldBaselineOrigin::PersistedHostAttachContract, + inventory_path: None, + orchestration_session_id: envelope.orchestration_session_id.clone(), + }, + field_provenance, + }) +} + +fn resolve_persisted_attach_launch_knobs( + envelope: &DispatchRequestEnvelope, + contract: &HostAttachContract, +) -> Result { + let host_execution_client_start = match ( + contract.attach_launch_knobs.host_execution_client_start, + envelope.attach_launch_knobs.host_execution_client_start, + ) { + (HostAttachExecutionClientStart::StartNow, HostExecutionClientStart::StartNow) + | (HostAttachExecutionClientStart::StartNow, HostExecutionClientStart::Defer) => { + envelope.attach_launch_knobs.host_execution_client_start + } + (HostAttachExecutionClientStart::Defer, HostExecutionClientStart::Defer) => { + HostExecutionClientStart::Defer + } + (HostAttachExecutionClientStart::Defer, HostExecutionClientStart::StartNow) => { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field: "host_execution_client_start", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: + "persisted attach launch cannot broaden host execution client start from defer to start_now" + .to_string(), + }); + } + }; + + let attach_mode_preference = + if persisted_attach_mode_rank(envelope.attach_launch_knobs.attach_mode_preference) + <= persisted_host_attach_mode_rank(contract.attach_launch_knobs.attach_mode_preference) + { + envelope.attach_launch_knobs.attach_mode_preference + } else { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field: "attach_mode_preference", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "persisted attach launch cannot broaden attach mode from {} to {}", + persisted_host_attach_mode_label( + contract.attach_launch_knobs.attach_mode_preference + ), + persisted_attach_mode_label( + envelope.attach_launch_knobs.attach_mode_preference + ), + ), + }); + }; + + if matches!( + attach_mode_preference, + AttachModePreference::ContinuityRequired + ) && contract.continuity_uaa_session_id.is_none() + { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::MissingRequiredAttachContinuity, + field: "continuity_uaa_session_id", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: + "persisted host attach contract no longer has continuity required for this attach launch" + .to_string(), + }); + } + + Ok(AttachLaunchKnobs { + requested_execution_scope: contract.attach_launch_knobs.requested_execution_scope, + host_execution_client_start, + attach_mode_preference, + }) +} + +fn persisted_attach_mode_rank(value: AttachModePreference) -> u8 { + match value { + AttachModePreference::ContinuityRequired => 0, + AttachModePreference::ContinuityPreferred => 1, + AttachModePreference::FreshAllowed => 2, + } +} + +fn persisted_host_attach_mode_rank(value: HostAttachModePreference) -> u8 { + match value { + HostAttachModePreference::ContinuityRequired => 0, + HostAttachModePreference::ContinuityPreferred => 1, + HostAttachModePreference::FreshAllowed => 2, + } +} + +fn persisted_attach_mode_label(value: AttachModePreference) -> &'static str { + match value { + AttachModePreference::ContinuityRequired => "continuity_required", + AttachModePreference::ContinuityPreferred => "continuity_preferred", + AttachModePreference::FreshAllowed => "fresh_allowed", + } +} + +fn persisted_host_attach_mode_label(value: HostAttachModePreference) -> &'static str { + match value { + HostAttachModePreference::ContinuityRequired => "continuity_required", + HostAttachModePreference::ContinuityPreferred => "continuity_preferred", + HostAttachModePreference::FreshAllowed => "fresh_allowed", + } +} + +fn resolve_inventory_projected_contract( + base_policy: &Policy, + envelope: &DispatchRequestEnvelope, + projected: ProjectedInventoryEntryV1, +) -> Result { + validate_inventory_projected_candidate(&projected)?; + validate_dispatch_overrides(envelope, projected.execution_scope)?; + let effective_policy = + resolve_inventory_effective_policy(base_policy, projected.policy_overlay.as_ref()); + let (capabilities, capability_origins) = resolve_inventory_capabilities( + projected.capabilities.clone(), + &envelope.capability_overrides, + )?; + if !base_policy + .agents_allowed_backends + .iter() + .any(|allowed| allowed == &projected.backend_id) + { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideDeniedByPolicy, + field: "backend_id", + rejecting_layer: DispatchRejectingLayer::Policy, + reason: format!( + "selected orchestrator backend '{}' is not allowlisted by effective policy agents.allowed_backends", + projected.backend_id + ), + }); + } + + let baseline_origin = match projected.origin { + AgentInventoryBaselineOrigin::GlobalInventory => FieldBaselineOrigin::GlobalInventory, + AgentInventoryBaselineOrigin::WorkspaceInventory => FieldBaselineOrigin::WorkspaceInventory, + }; + + let mut field_provenance = BTreeMap::new(); + field_provenance.insert( + "agent_id".to_string(), + FieldProvenance { + baseline_origin, + value_origin: FieldValueOrigin::InventoryExplicit, + }, + ); + field_provenance.insert( + "backend_id".to_string(), + FieldProvenance { + baseline_origin, + value_origin: FieldValueOrigin::InventoryExplicit, + }, + ); + field_provenance.insert( + "protocol".to_string(), + FieldProvenance { + baseline_origin, + value_origin: FieldValueOrigin::InventoryExplicit, + }, + ); + field_provenance.insert( + "execution_scope".to_string(), + FieldProvenance { + baseline_origin, + value_origin: map_value_origin(projected.execution_scope_origin), + }, + ); + field_provenance.insert( + "cli_mode".to_string(), + FieldProvenance { + baseline_origin, + value_origin: map_value_origin(projected.cli_mode_origin), + }, + ); + field_provenance.insert( + "cli_binary".to_string(), + FieldProvenance { + baseline_origin, + value_origin: FieldValueOrigin::InventoryExplicit, + }, + ); + field_provenance.insert( + "effective_policy".to_string(), + FieldProvenance { + baseline_origin, + value_origin: if projected.policy_overlay.is_some() { + FieldValueOrigin::DispatchOverrideNarrowedByPolicy + } else { + FieldValueOrigin::InventoryExplicit + }, + }, + ); + for (field, value_origin) in capability_origins { + field_provenance.insert( + field, + FieldProvenance { + baseline_origin, + value_origin, + }, + ); + } + + let backend_kind = orchestrator_backend_kind(projected.agent_id.as_str()).map_err(|err| { + DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineIneligible, + field: "agent_id", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: err + .to_string() + .replace("selected orchestrator backend", "selected runtime backend"), + } + })?; + + Ok(ResolvedLaunchContract { + caller_kind: envelope.caller_kind, + baseline_kind: DispatchBaselineKind::InventoryLaunch, + agent_id: projected.agent_id, + backend_id: projected.backend_id, + backend_kind, + protocol: PURE_AGENT_PROTOCOL.to_string(), + execution_scope: projected.execution_scope, + runtime: ResolvedLaunchRuntime { + kind: projected.kind, + cli_mode: projected.cli_mode, + cli_binary: projected.cli_binary, + }, + capabilities, + attach_launch_knobs: envelope.attach_launch_knobs, + effective_policy, + baseline_source: BaselineSourceMetadata { + baseline_kind: DispatchBaselineKind::InventoryLaunch, + baseline_origin, + inventory_path: Some(projected.path), + orchestration_session_id: envelope.orchestration_session_id.clone(), + }, + field_provenance, + }) +} + +fn validate_inventory_projected_candidate( + projected: &ProjectedInventoryEntryV1, +) -> Result<(), DispatchResolutionError> { + if projected.protocol.as_deref() != Some(PURE_AGENT_PROTOCOL) { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineIneligible, + field: "protocol", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "selected {} runtime '{}' for backend '{}' {}", + runtime_scope_label(projected.execution_scope), + projected.agent_id, + projected.backend_id, + protocol_validation_error("does not advertise", projected.protocol.as_deref()) + .replacen("does not advertise ", "", 1), + ), + }); + } + + if let Some(capability) = missing_required_dispatch_capability(&projected.capabilities) { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::BaselineIneligible, + field: capability, + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "selected {} runtime '{}' for backend '{}' is missing required capability '{}'", + runtime_scope_label(projected.execution_scope), + projected.agent_id, + projected.backend_id, + capability, + ), + }); + } + + if projected.kind != AgentConfigKind::Cli { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::RuntimeUnrealizableAfterResolution, + field: "config.kind", + rejecting_layer: DispatchRejectingLayer::RuntimeMaterialization, + reason: format!( + "selected runtime '{}' is not runtime-realizable by the shell-owned UAA runtime because config.kind={} is unsupported; only config.kind=cli is supported in v1", + projected.agent_id, + projected.kind.as_str() + ), + }); + } + + Ok(()) +} + +fn resolve_inventory_effective_policy( + base_policy: &Policy, + overlay: Option<&PolicyPatch>, +) -> Policy { + match overlay { + Some(overlay) => apply_policy_patch(base_policy, overlay), + None => base_policy.clone(), + } +} + +fn validate_dispatch_overrides( + envelope: &DispatchRequestEnvelope, + baseline_scope: AgentExecutionScope, +) -> Result<(), DispatchResolutionError> { + if envelope + .requested_execution_scope_override + .is_some_and(|scope| scope != baseline_scope) + { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field: "requested_execution_scope", + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: format!( + "dispatch scope override {} broadens or changes baseline {}", + runtime_scope_label(envelope.requested_execution_scope_override.unwrap()), + runtime_scope_label(baseline_scope), + ), + }); + } + + validate_capability_override_shape(&envelope.capability_overrides)?; + + Ok(()) +} + +fn validate_capability_override_shape( + overrides: &DispatchCapabilityOverrideSet, +) -> Result<(), DispatchResolutionError> { + for (field, value) in [ + ("session_start", overrides.session_start), + ("llm", overrides.llm), + ("mcp_client", overrides.mcp_client), + ] { + if value.is_some() { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideNotSupportedForCaller, + field, + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: "dispatch-time capability override is unsupported for this field in slice 29.5; only session_resume, session_fork, session_stop, status_snapshot, and event_stream may narrow from true to false".to_string(), + }); + } + } + + for (field, value) in [ + ("session_resume", overrides.session_resume), + ("session_fork", overrides.session_fork), + ("session_stop", overrides.session_stop), + ("status_snapshot", overrides.status_snapshot), + ("event_stream", overrides.event_stream), + ] { + if value == Some(true) { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field, + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: + "dispatch-time capability override must be narrowing-only; only true-to-false is supported in slice 29.5" + .to_string(), + }); + } + } + + Ok(()) +} + +fn resolve_inventory_capabilities( + baseline: AgentCapabilitiesV1, + overrides: &DispatchCapabilityOverrideSet, +) -> Result<(AgentCapabilitiesV1, BTreeMap), DispatchResolutionError> { + let mut capabilities = baseline.clone(); + let mut origins = BTreeMap::new(); + + origins.insert( + "session_start".to_string(), + FieldValueOrigin::InventoryExplicit, + ); + origins.insert("llm".to_string(), FieldValueOrigin::InventoryExplicit); + origins.insert( + "mcp_client".to_string(), + FieldValueOrigin::InventoryExplicit, + ); + + apply_supported_capability_override( + "session_resume", + baseline.session_resume, + overrides.session_resume, + &mut capabilities.session_resume, + &mut origins, + )?; + apply_supported_capability_override( + "session_fork", + baseline.session_fork, + overrides.session_fork, + &mut capabilities.session_fork, + &mut origins, + )?; + apply_supported_capability_override( + "session_stop", + baseline.session_stop, + overrides.session_stop, + &mut capabilities.session_stop, + &mut origins, + )?; + apply_supported_capability_override( + "status_snapshot", + baseline.status_snapshot, + overrides.status_snapshot, + &mut capabilities.status_snapshot, + &mut origins, + )?; + apply_supported_capability_override( + "event_stream", + baseline.event_stream, + overrides.event_stream, + &mut capabilities.event_stream, + &mut origins, + )?; + + Ok((capabilities, origins)) +} + +fn apply_supported_capability_override( + field: &'static str, + baseline: bool, + override_value: Option, + target: &mut bool, + origins: &mut BTreeMap, +) -> Result<(), DispatchResolutionError> { + let value_origin = match override_value { + Some(false) if baseline => { + *target = false; + FieldValueOrigin::DispatchOverrideAccepted + } + Some(false) => { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field, + rejecting_layer: DispatchRejectingLayer::BaselineTruth, + reason: "dispatch-time capability override cannot narrow a baseline capability that is already false".to_string(), + }); + } + Some(true) => { + return Err(DispatchResolutionError { + kind: DispatchResolutionErrorKind::OverrideExceedsBaseline, + field, + rejecting_layer: DispatchRejectingLayer::CallerContract, + reason: + "dispatch-time capability override must be narrowing-only; only true-to-false is supported in slice 29.5" + .to_string(), + }); + } + None => FieldValueOrigin::InventoryExplicit, + }; + + origins.insert(field.to_string(), value_origin); + Ok(()) +} + +fn map_value_origin(origin: ProjectedInventoryValueOrigin) -> FieldValueOrigin { + match origin { + ProjectedInventoryValueOrigin::InventoryExplicit => FieldValueOrigin::InventoryExplicit, + ProjectedInventoryValueOrigin::EffectiveConfigDefault => { + FieldValueOrigin::EffectiveConfigDefault + } + } +} + +fn missing_required_dispatch_capability( + capabilities: &AgentCapabilitiesV1, +) -> Option<&'static str> { + [ + ("session_start", capabilities.session_start), + ("session_resume", capabilities.session_resume), + ("session_fork", capabilities.session_fork), + ("session_stop", capabilities.session_stop), + ("status_snapshot", capabilities.status_snapshot), + ("event_stream", capabilities.event_stream), + ] + .into_iter() + .find_map(|(name, enabled)| (!enabled).then_some(name)) +} + +fn runtime_scope_label(scope: AgentExecutionScope) -> &'static str { + match scope { + AgentExecutionScope::Host => "host-scoped", + AgentExecutionScope::World => "world-scoped", + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::PathBuf; + + use tempfile::tempdir; + + use super::{ + resolve_inventory_contract_for_exact_backend, resolve_persisted_host_attach_contract, + AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, DispatchCallerKind, + DispatchCapabilityOverrideSet, DispatchRequestEnvelope, DispatchResolutionErrorKind, + FieldBaselineOrigin, FieldValueOrigin, HostExecutionClientStart, + }; + use crate::execution::agent_inventory::{ + AgentCapabilitiesV1, AgentCliConfigV1, AgentConfigKind, AgentConfigV1, + AgentExecutionConfigV1, AgentFileV1, AgentInventoryEntryV1, + }; + use crate::execution::agent_runtime::control::{ + ResolvedRuntimeBackendKind, ResolvedRuntimeDescriptor, + }; + use crate::execution::agent_runtime::orchestration_session::HostAttachContract; + use crate::execution::config_model::{AgentCliMode, AgentExecutionScope, SubstrateConfig}; + use crate::execution::policy_model::PolicyPatch; + use crate::execution::workspace::{workspace_marker_path, SUBSTRATE_DIR_NAME}; + use substrate_broker::Policy; + + fn required_capabilities() -> AgentCapabilitiesV1 { + AgentCapabilitiesV1 { + session_start: true, + session_resume: true, + session_fork: true, + session_stop: true, + status_snapshot: true, + event_stream: true, + llm: true, + mcp_client: false, + } + } + + fn exact_backend_envelope( + caller_kind: DispatchCallerKind, + baseline_kind: DispatchBaselineKind, + backend_id: &str, + ) -> DispatchRequestEnvelope { + DispatchRequestEnvelope { + caller_kind, + baseline_kind, + backend_id: Some(backend_id.to_string()), + orchestration_session_id: Some("sess_123".to_string()), + requested_execution_scope_override: None, + capability_overrides: DispatchCapabilityOverrideSet::default(), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: HostExecutionClientStart::StartNow, + attach_mode_preference: AttachModePreference::ContinuityRequired, + }, + has_prompt_payload: true, + } + } + + fn make_entry( + path: PathBuf, + agent_id: &str, + scope: Option, + cli_mode: Option, + capabilities: AgentCapabilitiesV1, + ) -> AgentInventoryEntryV1 { + make_entry_with_overlay(path, agent_id, scope, cli_mode, capabilities, None) + } + + fn make_entry_with_overlay( + path: PathBuf, + agent_id: &str, + scope: Option, + cli_mode: Option, + capabilities: AgentCapabilitiesV1, + policy_overlay: Option, + ) -> AgentInventoryEntryV1 { + AgentInventoryEntryV1 { + path, + file: AgentFileV1 { + version: 1, + id: agent_id.to_string(), + config: AgentConfigV1 { + enabled: true, + kind: AgentConfigKind::Cli, + protocol: Some(super::PURE_AGENT_PROTOCOL.to_string()), + execution: AgentExecutionConfigV1 { scope }, + cli: Some(AgentCliConfigV1 { + binary: "cargo".to_string(), + mode: cli_mode, + }), + api: None, + capabilities, + }, + policy_overlay, + }, + } + } + + #[test] + fn inventory_contract_tracks_workspace_origin_and_config_defaults() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let workspace_agents = workspace_root.join(SUBSTRATE_DIR_NAME).join("agents"); + std::fs::create_dir_all(&workspace_agents).expect("workspace agents"); + std::fs::create_dir_all( + workspace_marker_path(&workspace_root) + .parent() + .expect("marker parent"), + ) + .expect("workspace marker dir"); + std::fs::write(workspace_marker_path(&workspace_root), "version: 1\n").expect("marker"); + + let cwd = workspace_root.join("src"); + std::fs::create_dir_all(&cwd).expect("cwd"); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + workspace_agents.join("codex.yaml"), + "codex", + None, + None, + required_capabilities(), + ), + ); + + let mut config = SubstrateConfig::default(); + config.agents.defaults.execution.scope = AgentExecutionScope::Host; + config.agents.defaults.cli.mode = AgentCliMode::Persistent; + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }; + + let resolved = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ), + AgentExecutionScope::Host, + ) + .expect("resolution should succeed") + .expect("contract"); + + assert_eq!( + resolved.baseline_source.baseline_origin, + FieldBaselineOrigin::WorkspaceInventory + ); + assert_eq!( + resolved + .field_provenance + .get("execution_scope") + .expect("scope provenance") + .value_origin, + FieldValueOrigin::EffectiveConfigDefault + ); + assert_eq!( + resolved + .field_provenance + .get("cli_mode") + .expect("cli provenance") + .value_origin, + FieldValueOrigin::EffectiveConfigDefault + ); + assert_eq!(resolved.backend_id, "cli:codex"); + assert_eq!(resolved.execution_scope, AgentExecutionScope::Host); + } + + #[test] + fn inventory_contract_merges_policy_overlay_into_effective_policy() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry_with_overlay( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + Some(PolicyPatch { + require_approval: Some(true), + ..PolicyPatch::default() + }), + ), + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + require_approval: false, + ..Policy::default() + }; + let config = SubstrateConfig::default(); + + let resolved = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ), + AgentExecutionScope::Host, + ) + .expect("resolution should succeed") + .expect("contract"); + + assert!(resolved.effective_policy.require_approval); + assert_eq!( + resolved + .field_provenance + .get("effective_policy") + .expect("policy provenance") + .value_origin, + FieldValueOrigin::DispatchOverrideNarrowedByPolicy + ); + } + + #[test] + fn inventory_contract_without_policy_overlay_keeps_base_policy() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + ), + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + require_approval: false, + ..Policy::default() + }; + let config = SubstrateConfig::default(); + + let resolved = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ), + AgentExecutionScope::Host, + ) + .expect("resolution should succeed") + .expect("contract"); + + assert!(!resolved.effective_policy.require_approval); + assert_eq!( + resolved + .field_provenance + .get("effective_policy") + .expect("policy provenance") + .value_origin, + FieldValueOrigin::InventoryExplicit + ); + } + + #[test] + fn persisted_attach_contract_is_explicit_baseline_domain() { + let contract = HostAttachContract { + backend_id: "cli:codex".to_string(), + execution_scope: AgentExecutionScope::Host, + protocol: super::PURE_AGENT_PROTOCOL.to_string(), + launch_descriptor: ResolvedRuntimeDescriptor { + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: ResolvedRuntimeBackendKind::Codex, + protocol: super::PURE_AGENT_PROTOCOL.to_string(), + execution_scope: AgentExecutionScope::Host, + binary_path: "cargo".to_string(), + }, + capabilities: + crate::execution::agent_runtime::orchestration_session::HostAttachCapabilities::default(), + attach_launch_knobs: + crate::execution::agent_runtime::orchestration_session::HostAttachLaunchKnobs::default(), + effective_policy: serde_json::to_value(Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }) + .expect("serialize policy"), + continuity_uaa_session_id: Some("uaa_123".to_string()), + }; + + let resolved = resolve_persisted_host_attach_contract( + &exact_backend_envelope( + DispatchCallerKind::HumanReattach, + DispatchBaselineKind::PersistedHostAttach, + "cli:codex", + ), + &contract, + ) + .expect("persisted attach resolution"); + + assert_eq!( + resolved.baseline_kind, + DispatchBaselineKind::PersistedHostAttach + ); + assert_eq!( + resolved.baseline_source.baseline_origin, + FieldBaselineOrigin::PersistedHostAttachContract + ); + assert_eq!(resolved.backend_id, "cli:codex"); + assert_eq!(resolved.agent_id, "codex"); + assert_eq!( + resolved.effective_policy.agents_allowed_backends, + vec!["cli:codex".to_string()] + ); + } + + #[test] + fn persisted_attach_contract_reuses_persisted_capabilities_and_only_honors_or_narrows_knobs() { + let contract = HostAttachContract { + backend_id: "cli:codex".to_string(), + execution_scope: AgentExecutionScope::Host, + protocol: super::PURE_AGENT_PROTOCOL.to_string(), + launch_descriptor: ResolvedRuntimeDescriptor { + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: ResolvedRuntimeBackendKind::Codex, + protocol: super::PURE_AGENT_PROTOCOL.to_string(), + execution_scope: AgentExecutionScope::Host, + binary_path: "cargo".to_string(), + }, + capabilities: + crate::execution::agent_runtime::orchestration_session::HostAttachCapabilities { + session_resume: false, + session_fork: true, + session_stop: false, + status_snapshot: true, + event_stream: false, + }, + attach_launch_knobs: + crate::execution::agent_runtime::orchestration_session::HostAttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: + crate::execution::agent_runtime::orchestration_session::HostAttachExecutionClientStart::StartNow, + attach_mode_preference: + crate::execution::agent_runtime::orchestration_session::HostAttachModePreference::FreshAllowed, + }, + effective_policy: serde_json::to_value(Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }) + .expect("serialize policy"), + continuity_uaa_session_id: Some("uaa_123".to_string()), + }; + let mut envelope = exact_backend_envelope( + DispatchCallerKind::HumanFork, + DispatchBaselineKind::PersistedHostAttach, + "cli:codex", + ); + envelope.attach_launch_knobs.host_execution_client_start = HostExecutionClientStart::Defer; + envelope.attach_launch_knobs.attach_mode_preference = + AttachModePreference::ContinuityPreferred; + + let resolved = + resolve_persisted_host_attach_contract(&envelope, &contract).expect("resolution"); + + assert!(!resolved.capabilities.session_resume); + assert!(resolved.capabilities.session_fork); + assert!(!resolved.capabilities.session_stop); + assert!(resolved.capabilities.status_snapshot); + assert!(!resolved.capabilities.event_stream); + assert_eq!( + resolved.attach_launch_knobs.requested_execution_scope, + AgentExecutionScope::Host + ); + assert_eq!( + resolved.attach_launch_knobs.host_execution_client_start, + HostExecutionClientStart::Defer + ); + assert_eq!( + resolved.attach_launch_knobs.attach_mode_preference, + AttachModePreference::ContinuityPreferred + ); + } + + #[test] + fn persisted_attach_contract_missing_policy_snapshot_fails_closed() { + let mut payload = serde_json::json!({ + "backend_id": "cli:codex", + "execution_scope": "host", + "protocol": super::PURE_AGENT_PROTOCOL, + "launch_descriptor": { + "agent_id": "codex", + "backend_id": "cli:codex", + "backend_kind": "codex", + "protocol": super::PURE_AGENT_PROTOCOL, + "execution_scope": "host", + "binary_path": "cargo" + }, + "capabilities": { + "session_resume": true, + "session_fork": true, + "session_stop": true, + "status_snapshot": true, + "event_stream": true + }, + "attach_launch_knobs": { + "requested_execution_scope": "host", + "host_execution_client_start": "start_now", + "attach_mode_preference": "continuity_required" + }, + "continuity_uaa_session_id": "uaa_123" + }); + payload + .as_object_mut() + .expect("object") + .remove("effective_policy"); + + let err = serde_json::from_value::(payload) + .expect_err("missing persisted policy must fail closed"); + assert!(err.to_string().contains("effective_policy")); + } + + #[test] + fn persisted_attach_contract_broadening_knobs_fails_closed() { + let contract = HostAttachContract { + backend_id: "cli:codex".to_string(), + execution_scope: AgentExecutionScope::Host, + protocol: super::PURE_AGENT_PROTOCOL.to_string(), + launch_descriptor: ResolvedRuntimeDescriptor { + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: ResolvedRuntimeBackendKind::Codex, + protocol: super::PURE_AGENT_PROTOCOL.to_string(), + execution_scope: AgentExecutionScope::Host, + binary_path: "cargo".to_string(), + }, + capabilities: + crate::execution::agent_runtime::orchestration_session::HostAttachCapabilities::default(), + attach_launch_knobs: + crate::execution::agent_runtime::orchestration_session::HostAttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: + crate::execution::agent_runtime::orchestration_session::HostAttachExecutionClientStart::Defer, + attach_mode_preference: + crate::execution::agent_runtime::orchestration_session::HostAttachModePreference::ContinuityRequired, + }, + effective_policy: serde_json::to_value(Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }) + .expect("serialize policy"), + continuity_uaa_session_id: Some("uaa_123".to_string()), + }; + let mut envelope = exact_backend_envelope( + DispatchCallerKind::HumanReattach, + DispatchBaselineKind::PersistedHostAttach, + "cli:codex", + ); + envelope.attach_launch_knobs.host_execution_client_start = + HostExecutionClientStart::StartNow; + envelope.attach_launch_knobs.attach_mode_preference = AttachModePreference::FreshAllowed; + + let err = resolve_persisted_host_attach_contract(&envelope, &contract) + .expect_err("broadening persisted attach knobs must fail closed"); + assert!( + err.field == "host_execution_client_start" || err.field == "attach_mode_preference" + ); + } + + #[test] + fn scope_override_that_changes_baseline_fails_closed() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + ), + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }; + let config = SubstrateConfig::default(); + let mut envelope = exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ); + envelope.requested_execution_scope_override = Some(AgentExecutionScope::World); + + let error = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &envelope, + AgentExecutionScope::Host, + ) + .expect_err("override must fail closed"); + + assert_eq!( + error.kind, + DispatchResolutionErrorKind::OverrideExceedsBaseline + ); + assert_eq!(error.field, "requested_execution_scope"); + } + + #[test] + fn policy_denial_names_field_and_layer() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + ), + ); + let config = SubstrateConfig::default(); + let policy = Policy::default(); + + let error = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ), + AgentExecutionScope::Host, + ) + .expect_err("policy denial expected"); + + assert_eq!( + error.kind, + DispatchResolutionErrorKind::OverrideDeniedByPolicy + ); + assert_eq!(error.field, "backend_id"); + assert!(error + .to_string() + .contains("policy rejected field 'backend_id'")); + } + + #[test] + fn supported_capability_override_family_narrows_from_true_to_false() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + ), + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }; + let config = SubstrateConfig::default(); + let mut envelope = exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ); + envelope.capability_overrides.session_resume = Some(false); + envelope.capability_overrides.session_fork = Some(false); + envelope.capability_overrides.session_stop = Some(false); + envelope.capability_overrides.status_snapshot = Some(false); + envelope.capability_overrides.event_stream = Some(false); + + let resolved = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &envelope, + AgentExecutionScope::Host, + ) + .expect("resolution should succeed") + .expect("contract"); + + assert!(!resolved.capabilities.session_resume); + assert!(!resolved.capabilities.session_fork); + assert!(!resolved.capabilities.session_stop); + assert!(!resolved.capabilities.status_snapshot); + assert!(!resolved.capabilities.event_stream); + for field in [ + "session_resume", + "session_fork", + "session_stop", + "status_snapshot", + "event_stream", + ] { + assert_eq!( + resolved + .field_provenance + .get(field) + .expect("capability provenance") + .value_origin, + FieldValueOrigin::DispatchOverrideAccepted + ); + } + } + + #[test] + fn unsupported_capability_override_fields_fail_closed_with_field_names() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + ), + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }; + let config = SubstrateConfig::default(); + + for field in ["session_start", "llm", "mcp_client"] { + let mut envelope = exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ); + match field { + "session_start" => envelope.capability_overrides.session_start = Some(false), + "llm" => envelope.capability_overrides.llm = Some(false), + "mcp_client" => envelope.capability_overrides.mcp_client = Some(false), + _ => unreachable!(), + } + + let error = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &envelope, + AgentExecutionScope::Host, + ) + .expect_err("override must fail closed"); + + assert_eq!(error.field, field); + assert_eq!( + error.kind, + DispatchResolutionErrorKind::OverrideNotSupportedForCaller + ); + } + } + + #[test] + fn supported_capability_override_rejects_true_value() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + PathBuf::from("codex.yaml"), + "codex", + Some(AgentExecutionScope::Host), + Some(AgentCliMode::Persistent), + required_capabilities(), + ), + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }; + let config = SubstrateConfig::default(); + let mut envelope = exact_backend_envelope( + DispatchCallerKind::HumanStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex", + ); + envelope.capability_overrides.session_resume = Some(true); + + let error = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &envelope, + AgentExecutionScope::Host, + ) + .expect_err("override must fail closed"); + + assert_eq!(error.field, "session_resume"); + assert_eq!( + error.kind, + DispatchResolutionErrorKind::OverrideExceedsBaseline + ); + } +} diff --git a/crates/shell/src/execution/agent_runtime/mod.rs b/crates/shell/src/execution/agent_runtime/mod.rs index 1ba281653..6333bbfcd 100644 --- a/crates/shell/src/execution/agent_runtime/mod.rs +++ b/crates/shell/src/execution/agent_runtime/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod control; +pub(crate) mod dispatch_contract; pub(crate) mod mapping; pub(crate) mod orchestration_session; pub(crate) mod registry; @@ -13,6 +14,14 @@ pub(crate) use control::{ PublicPromptInput, PublicPromptRenderedExit, PublicSessionPosture, SubmittedPromptCompletion, SubmittedPromptStreamEvent, }; +#[allow(unused_imports)] +pub(crate) use dispatch_contract::{ + resolve_inventory_contract_for_exact_backend, resolve_inventory_contract_for_unique_scope, + resolve_persisted_host_attach_contract, AttachLaunchKnobs, AttachModePreference, + DispatchBaselineKind, DispatchCallerKind, DispatchCapabilityOverrideSet, + DispatchRequestEnvelope, DispatchResolutionError, DispatchResolutionErrorKind, + HostExecutionClientStart, ResolvedLaunchContract, +}; pub(crate) use mapping::{ MEMBER_ROLE, NESTED_ROUTER, ORCHESTRATOR_ROLE, PURE_AGENT_PROTOCOL, PURE_AGENT_ROUTER, SESSION_HANDLE_SCHEMA_V1, diff --git a/crates/shell/src/execution/agent_runtime/orchestration_session.rs b/crates/shell/src/execution/agent_runtime/orchestration_session.rs index f37bdb5df..892367fc1 100644 --- a/crates/shell/src/execution/agent_runtime/orchestration_session.rs +++ b/crates/shell/src/execution/agent_runtime/orchestration_session.rs @@ -1,6 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; +#[cfg(test)] +use crate::execution::agent_runtime::control::ResolvedRuntimeBackendKind; +use crate::execution::agent_runtime::control::ResolvedRuntimeDescriptor; +use crate::execution::config_model::AgentExecutionScope; + +use super::dispatch_contract::ResolvedLaunchContract; +use super::dispatch_contract::{AttachLaunchKnobs, AttachModePreference, HostExecutionClientStart}; use super::mapping::{protocol_validation_error, PURE_AGENT_PROTOCOL}; use super::session::AgentRuntimeSessionManifest; @@ -66,6 +74,198 @@ pub(crate) enum OrchestrationSessionPosture { Terminal, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct HostAttachContract { + pub backend_id: String, + pub execution_scope: AgentExecutionScope, + pub protocol: String, + pub launch_descriptor: ResolvedRuntimeDescriptor, + pub capabilities: HostAttachCapabilities, + pub attach_launch_knobs: HostAttachLaunchKnobs, + pub effective_policy: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuity_uaa_session_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct HostAttachCapabilities { + pub session_resume: bool, + pub session_fork: bool, + pub session_stop: bool, + pub status_snapshot: bool, + pub event_stream: bool, +} + +impl Default for HostAttachCapabilities { + fn default() -> Self { + Self { + session_resume: true, + session_fork: true, + session_stop: true, + status_snapshot: true, + event_stream: true, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum HostAttachExecutionClientStart { + #[default] + StartNow, + Defer, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum HostAttachModePreference { + #[default] + ContinuityRequired, + ContinuityPreferred, + FreshAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct HostAttachLaunchKnobs { + pub requested_execution_scope: AgentExecutionScope, + pub host_execution_client_start: HostAttachExecutionClientStart, + pub attach_mode_preference: HostAttachModePreference, +} + +impl Default for HostAttachLaunchKnobs { + fn default() -> Self { + Self { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: HostAttachExecutionClientStart::StartNow, + attach_mode_preference: HostAttachModePreference::ContinuityRequired, + } + } +} + +impl HostAttachContract { + #[cfg(test)] + pub(crate) fn from_manifest_for_test(manifest: &AgentRuntimeSessionManifest) -> Option { + if manifest.handle.role != super::mapping::ORCHESTRATOR_ROLE + || manifest.handle.execution.scope != AgentExecutionScope::Host + { + return None; + } + + let backend_kind = match manifest.internal.resolved_agent_kind.as_str() { + "codex" => ResolvedRuntimeBackendKind::Codex, + "claude_code" => ResolvedRuntimeBackendKind::ClaudeCode, + _ => return None, + }; + + Some(Self { + backend_id: manifest.handle.backend_id.clone(), + execution_scope: manifest.handle.execution.scope, + protocol: manifest.handle.protocol.clone(), + launch_descriptor: ResolvedRuntimeDescriptor { + agent_id: manifest.handle.agent_id.clone(), + backend_id: manifest.handle.backend_id.clone(), + backend_kind, + protocol: manifest.handle.protocol.clone(), + execution_scope: manifest.handle.execution.scope, + binary_path: manifest.internal.resolved_binary_path.clone(), + }, + capabilities: HostAttachCapabilities::default(), + attach_launch_knobs: HostAttachLaunchKnobs { + requested_execution_scope: manifest.handle.execution.scope, + ..HostAttachLaunchKnobs::default() + }, + effective_policy: serde_json::to_value(substrate_broker::Policy::default()) + .expect("test policy should serialize"), + continuity_uaa_session_id: manifest.internal_uaa_session_id().map(ToOwned::to_owned), + }) + } + + pub(crate) fn from_resolved_contract( + resolved: &ResolvedLaunchContract, + continuity_uaa_session_id: Option, + ) -> Option { + if resolved.execution_scope != AgentExecutionScope::Host { + return None; + } + + Some(Self { + backend_id: resolved.backend_id.clone(), + execution_scope: resolved.execution_scope, + protocol: resolved.protocol.clone(), + launch_descriptor: ResolvedRuntimeDescriptor { + agent_id: resolved.agent_id.clone(), + backend_id: resolved.backend_id.clone(), + backend_kind: resolved.backend_kind.into(), + protocol: resolved.protocol.clone(), + execution_scope: resolved.execution_scope, + binary_path: resolved.runtime.cli_binary.clone().unwrap_or_default(), + }, + capabilities: HostAttachCapabilities::from(&resolved.capabilities), + attach_launch_knobs: HostAttachLaunchKnobs::from(&resolved.attach_launch_knobs), + effective_policy: serde_json::to_value(&resolved.effective_policy) + .expect("effective policy should serialize"), + continuity_uaa_session_id, + }) + } + + pub(crate) fn supports_resume(&self) -> bool { + self.capabilities.session_resume + } + + pub(crate) fn supports_fork(&self) -> bool { + self.capabilities.session_fork + } + + pub(crate) fn supports_stop(&self) -> bool { + self.capabilities.session_stop + } + + pub(crate) fn supports_continuity_attach(&self) -> bool { + matches!( + self.attach_launch_knobs.attach_mode_preference, + HostAttachModePreference::ContinuityRequired + | HostAttachModePreference::ContinuityPreferred + ) + } + + pub(crate) fn has_continuity_selector(&self) -> bool { + self.continuity_uaa_session_id.is_some() + } +} + +impl From<&crate::execution::agent_inventory::AgentCapabilitiesV1> for HostAttachCapabilities { + fn from(value: &crate::execution::agent_inventory::AgentCapabilitiesV1) -> Self { + Self { + session_resume: value.session_resume, + session_fork: value.session_fork, + session_stop: value.session_stop, + status_snapshot: value.status_snapshot, + event_stream: value.event_stream, + } + } +} + +impl From<&AttachLaunchKnobs> for HostAttachLaunchKnobs { + fn from(value: &AttachLaunchKnobs) -> Self { + Self { + requested_execution_scope: value.requested_execution_scope, + host_execution_client_start: match value.host_execution_client_start { + HostExecutionClientStart::StartNow => HostAttachExecutionClientStart::StartNow, + HostExecutionClientStart::Defer => HostAttachExecutionClientStart::Defer, + }, + attach_mode_preference: match value.attach_mode_preference { + AttachModePreference::ContinuityRequired => { + HostAttachModePreference::ContinuityRequired + } + AttachModePreference::ContinuityPreferred => { + HostAttachModePreference::ContinuityPreferred + } + AttachModePreference::FreshAllowed => HostAttachModePreference::FreshAllowed, + }, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct OrchestrationSessionRecord { // This is the public/operator-facing selector for the parent orchestration row. @@ -102,6 +302,8 @@ pub(crate) struct OrchestrationSessionRecord { pub parked_reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub startup_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_attach_contract: Option, } impl OrchestrationSessionRecord { @@ -110,6 +312,7 @@ impl OrchestrationSessionRecord { shell_trace_session_id: String, workspace_root: String, child_manifest: &AgentRuntimeSessionManifest, + host_attach_contract: Option, ) -> Self { let now = Utc::now(); Self { @@ -137,6 +340,7 @@ impl OrchestrationSessionRecord { last_attention_at: None, parked_reason: None, startup_prompt: None, + host_attach_contract, } } @@ -171,6 +375,33 @@ impl OrchestrationSessionRecord { self.world_id.is_some() && self.world_generation.is_some() } + pub(crate) fn host_attach_contract(&self) -> Option<&HostAttachContract> { + self.host_attach_contract.as_ref() + } + + pub(crate) fn sync_host_attach_contract(&mut self, manifest: &AgentRuntimeSessionManifest) { + let Some(contract) = self.host_attach_contract.as_mut() else { + return; + }; + if manifest.handle.role != super::mapping::ORCHESTRATOR_ROLE + || manifest.handle.execution.scope != AgentExecutionScope::Host + || manifest.handle.orchestration_session_id != self.orchestration_session_id + || manifest.handle.agent_id != self.orchestrator_agent_id + { + return; + } + + if let Some(session_id) = manifest.internal_uaa_session_id() { + contract.continuity_uaa_session_id = Some(session_id.to_string()); + } + } + + pub(crate) fn fork_successor_attach_contract(&self) -> Option { + let mut contract = self.host_attach_contract.clone()?; + contract.continuity_uaa_session_id = None; + Some(contract) + } + pub(crate) fn bind_active_session_handle(&mut self, participant_id: impl Into) { let participant_id = participant_id.into(); self.active_session_handle_id = Some(participant_id.clone()); @@ -234,6 +465,38 @@ impl OrchestrationSessionRecord { ); } + let contract = self.host_attach_contract.as_ref().ok_or_else(|| { + anyhow::anyhow!("host_attach_contract is required for authoritative host sessions") + })?; + if contract.execution_scope != AgentExecutionScope::Host { + anyhow::bail!("host_attach_contract must remain host scoped"); + } + if contract.protocol != PURE_AGENT_PROTOCOL { + anyhow::bail!("host_attach_contract must use the pure agent protocol"); + } + if contract.backend_id != self.orchestrator_backend_id { + anyhow::bail!("host_attach_contract backend_id must match the session backend"); + } + if contract.launch_descriptor.backend_id != contract.backend_id { + anyhow::bail!("host_attach_contract launch_descriptor backend drifted"); + } + if contract.launch_descriptor.execution_scope != contract.execution_scope { + anyhow::bail!("host_attach_contract launch_descriptor scope drifted"); + } + if contract.launch_descriptor.protocol != contract.protocol { + anyhow::bail!("host_attach_contract launch_descriptor protocol drifted"); + } + if contract.attach_launch_knobs.requested_execution_scope != contract.execution_scope { + anyhow::bail!("host_attach_contract requested_execution_scope drifted"); + } + if serde_json::from_value::(contract.effective_policy.clone()) + .is_err() + { + anyhow::bail!( + "host_attach_contract effective_policy must remain a valid policy snapshot" + ); + } + match self.posture { OrchestrationSessionPosture::ActiveAttached => { if self.attached_participant_id.is_none() { @@ -370,26 +633,38 @@ mod tests { use std::path::PathBuf; use super::*; + #[cfg(unix)] + use crate::execution::agent_inventory::{AgentCapabilitiesV1, AgentConfigKind}; + #[cfg(unix)] + use crate::execution::agent_runtime::dispatch_contract::{ + BaselineSourceMetadata, DispatchBaselineKind, DispatchCallerKind, FieldBaselineOrigin, + ResolvedLaunchRuntime, + }; use crate::execution::agent_runtime::{ mapping::AgentRuntimeBackendKind, validator::RuntimeSelectionDescriptor, }; use crate::execution::config_model::AgentExecutionScope; + #[cfg(unix)] + use substrate_broker::Policy; fn manifest() -> AgentRuntimeSessionManifest { - crate::execution::agent_runtime::session::AgentRuntimeParticipantRecord::new_orchestrator_participant( - &RuntimeSelectionDescriptor { - agent_id: "codex".to_string(), - backend_id: "cli:codex".to_string(), - backend_kind: AgentRuntimeBackendKind::Codex, - protocol: "substrate.agent.session".to_string(), - execution_scope: AgentExecutionScope::Host, - binary_path: PathBuf::from("/usr/bin/codex"), - }, - "sess_001".to_string(), - "ash_001".to_string(), - "lease_001".to_string(), - ) - .expect("manifest") + let mut manifest = + crate::execution::agent_runtime::session::AgentRuntimeParticipantRecord::new_orchestrator_participant( + &RuntimeSelectionDescriptor { + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: AgentRuntimeBackendKind::Codex, + protocol: "substrate.agent.session".to_string(), + execution_scope: AgentExecutionScope::Host, + binary_path: PathBuf::from("/usr/bin/codex"), + }, + "sess_001".to_string(), + "ash_001".to_string(), + "lease_001".to_string(), + ) + .expect("manifest"); + manifest.set_uaa_session_id("uaa_session"); + manifest } #[test] @@ -400,11 +675,39 @@ mod tests { "trace_001".to_string(), "/workspace".to_string(), &manifest, + HostAttachContract::from_manifest_for_test(&manifest), ); assert_eq!(session.posture, OrchestrationSessionPosture::ActiveAttached); assert_eq!(session.attached_participant_id(), Some("ash_001")); assert_eq!(session.pending_inbox_count, 0); + let contract = session + .host_attach_contract() + .expect("new session should persist host attach truth"); + assert_eq!(contract.backend_id, "cli:codex"); + assert_eq!(contract.execution_scope, AgentExecutionScope::Host); + assert_eq!(contract.launch_descriptor.backend_id, "cli:codex"); + assert_eq!( + contract.attach_launch_knobs.requested_execution_scope, + AgentExecutionScope::Host + ); + assert_eq!( + contract.attach_launch_knobs.host_execution_client_start, + HostAttachExecutionClientStart::StartNow + ); + assert_eq!( + contract.attach_launch_knobs.attach_mode_preference, + HostAttachModePreference::ContinuityRequired + ); + assert!(contract.capabilities.session_resume); + assert!(contract.capabilities.session_fork); + assert!(contract.capabilities.session_stop); + assert!(contract.capabilities.status_snapshot); + assert!(contract.capabilities.event_stream); + assert_eq!( + contract.continuity_uaa_session_id.as_deref(), + Some("uaa_session") + ); session .validate_persisted_invariants() .expect("new session invariants"); @@ -418,6 +721,7 @@ mod tests { "trace_001".to_string(), "/workspace".to_string(), &manifest, + HostAttachContract::from_manifest_for_test(&manifest), ); session.mark_parked_resumable("owner detached cleanly"); @@ -433,4 +737,135 @@ mod tests { .validate_persisted_invariants() .expect("attention invariants"); } + + #[test] + fn successor_attach_contract_clears_continuity_and_preserves_launch_truth() { + let manifest = manifest(); + let session = OrchestrationSessionRecord::new( + "sess_001".to_string(), + "trace_001".to_string(), + "/workspace".to_string(), + &manifest, + HostAttachContract::from_manifest_for_test(&manifest), + ); + + let original = session + .host_attach_contract() + .expect("source contract") + .clone(); + let successor = session + .fork_successor_attach_contract() + .expect("successor contract"); + + assert_eq!(successor.backend_id, original.backend_id); + assert_eq!(successor.execution_scope, original.execution_scope); + assert_eq!(successor.protocol, original.protocol); + assert_eq!(successor.launch_descriptor, original.launch_descriptor); + assert_eq!(successor.capabilities, original.capabilities); + assert_eq!(successor.attach_launch_knobs, original.attach_launch_knobs); + assert_eq!(successor.continuity_uaa_session_id, None); + } + + #[test] + fn host_attach_contract_knob_drift_fails_closed() { + let manifest = manifest(); + let mut session = OrchestrationSessionRecord::new( + "sess_001".to_string(), + "trace_001".to_string(), + "/workspace".to_string(), + &manifest, + HostAttachContract::from_manifest_for_test(&manifest), + ); + session + .host_attach_contract + .as_mut() + .expect("contract") + .attach_launch_knobs + .requested_execution_scope = AgentExecutionScope::World; + + let err = session + .validate_persisted_invariants() + .expect_err("scope drift must fail closed"); + assert!(err + .to_string() + .contains("host_attach_contract requested_execution_scope drifted")); + } + + #[cfg(unix)] + #[test] + fn host_attach_contract_from_resolved_contract_preserves_truth() { + let resolved = ResolvedLaunchContract { + caller_kind: DispatchCallerKind::HumanStart, + baseline_kind: DispatchBaselineKind::InventoryLaunch, + agent_id: "codex".to_string(), + backend_id: "cli:codex".to_string(), + backend_kind: AgentRuntimeBackendKind::Codex, + protocol: PURE_AGENT_PROTOCOL.to_string(), + execution_scope: AgentExecutionScope::Host, + runtime: ResolvedLaunchRuntime { + kind: AgentConfigKind::Cli, + cli_mode: crate::execution::config_model::AgentCliMode::Persistent, + cli_binary: Some("/opt/codex/bin/codex".to_string()), + }, + capabilities: AgentCapabilitiesV1 { + session_start: true, + session_resume: false, + session_fork: true, + session_stop: false, + status_snapshot: true, + event_stream: false, + llm: true, + mcp_client: false, + }, + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: HostExecutionClientStart::Defer, + attach_mode_preference: AttachModePreference::FreshAllowed, + }, + effective_policy: Policy { + agents_allowed_backends: vec!["cli:codex".to_string()], + ..Policy::default() + }, + baseline_source: BaselineSourceMetadata { + baseline_kind: DispatchBaselineKind::InventoryLaunch, + baseline_origin: FieldBaselineOrigin::WorkspaceInventory, + inventory_path: None, + orchestration_session_id: None, + }, + field_provenance: Default::default(), + }; + + let contract = + HostAttachContract::from_resolved_contract(&resolved, Some("uaa_resolved".to_string())) + .expect("host attach contract"); + + assert_eq!(contract.backend_id, "cli:codex"); + assert_eq!( + contract.launch_descriptor.binary_path, + "/opt/codex/bin/codex" + ); + assert!(!contract.capabilities.session_resume); + assert!(contract.capabilities.session_fork); + assert!(!contract.capabilities.session_stop); + assert!(contract.capabilities.status_snapshot); + assert!(!contract.capabilities.event_stream); + assert_eq!( + contract.attach_launch_knobs.host_execution_client_start, + HostAttachExecutionClientStart::Defer + ); + assert_eq!( + contract.attach_launch_knobs.attach_mode_preference, + HostAttachModePreference::FreshAllowed + ); + let persisted_policy: Policy = serde_json::from_value(contract.effective_policy.clone()) + .expect("deserialize persisted policy"); + assert_eq!( + persisted_policy.agents_allowed_backends, + vec!["cli:codex".to_string()] + ); + assert_eq!( + contract.continuity_uaa_session_id.as_deref(), + Some("uaa_resolved") + ); + } } diff --git a/crates/shell/src/execution/agent_runtime/registry.rs b/crates/shell/src/execution/agent_runtime/registry.rs index a4648bedb..855687797 100644 --- a/crates/shell/src/execution/agent_runtime/registry.rs +++ b/crates/shell/src/execution/agent_runtime/registry.rs @@ -1,35 +1,9 @@ -use std::sync::Arc; +use crate::execution::prompt_fulfillment::PromptFulfillmentBridge; -use agent_api::AgentWrapperGateway; - -use super::mapping::AgentRuntimeBackendKind; use super::validator::RuntimeSelectionDescriptor; pub(crate) fn build_gateway_for_descriptor( descriptor: &RuntimeSelectionDescriptor, -) -> anyhow::Result { - let mut gateway = AgentWrapperGateway::new(); - - match descriptor.backend_kind { - AgentRuntimeBackendKind::Codex => { - let backend = agent_api::backends::codex::CodexBackend::new( - agent_api::backends::codex::CodexBackendConfig { - binary: Some(descriptor.binary_path.clone()), - ..Default::default() - }, - ); - gateway.register(Arc::new(backend))?; - } - AgentRuntimeBackendKind::ClaudeCode => { - let backend = agent_api::backends::claude_code::ClaudeCodeBackend::new( - agent_api::backends::claude_code::ClaudeCodeBackendConfig { - binary: Some(descriptor.binary_path.clone()), - ..Default::default() - }, - ); - gateway.register(Arc::new(backend))?; - } - } - - Ok(gateway) +) -> anyhow::Result { + PromptFulfillmentBridge::for_descriptor(descriptor) } diff --git a/crates/shell/src/execution/agent_runtime/state_store.rs b/crates/shell/src/execution/agent_runtime/state_store.rs index 061ffb02d..8215554d0 100644 --- a/crates/shell/src/execution/agent_runtime/state_store.rs +++ b/crates/shell/src/execution/agent_runtime/state_store.rs @@ -18,8 +18,8 @@ use super::{ control::PublicSessionPosture, mapping::{MEMBER_ROLE, ORCHESTRATOR_ROLE}, orchestration_session::{ - OrchestrationSessionPosture, OrchestrationSessionRecord, OrchestrationSessionState, - StartupPromptStreamState, + HostAttachContract, OrchestrationSessionPosture, OrchestrationSessionRecord, + OrchestrationSessionState, StartupPromptStreamState, }, session::{AgentRuntimeParticipantRecord, AgentRuntimeSessionManifest}, }; @@ -252,10 +252,14 @@ pub(crate) enum PublicControlAction { } impl PublicControlAction { - fn requires_internal_session_id(self) -> bool { + fn requires_attach_contract(self) -> bool { matches!(self, Self::Resume | Self::Fork) } + fn requires_continuity_contract(self) -> bool { + matches!(self, Self::Resume) + } + fn rejects_live_owner(self) -> bool { matches!(self, Self::Resume) } @@ -266,6 +270,7 @@ pub(crate) struct ResolvedPublicControlTarget { pub session: OrchestrationSessionRecord, pub active_participant: AgentRuntimeParticipantRecord, pub session_posture: PublicSessionPosture, + pub host_attach_contract: Option, } impl ResolvedPublicControlTarget { @@ -289,6 +294,7 @@ pub(crate) struct ResolvedPublicTurnTarget { pub participant: AgentRuntimeParticipantRecord, pub target_kind: PublicTurnTargetKind, pub session_posture: PublicSessionPosture, + pub host_attach_contract: Option, } impl ResolvedPublicTurnTarget { @@ -436,6 +442,13 @@ impl AgentRuntimeStateStore { return Ok(()); } } + self.write_participant_snapshot(participant) + } + + fn write_participant_snapshot( + &self, + participant: &AgentRuntimeParticipantRecord, + ) -> Result<()> { self.ensure_participants_dir()?; write_atomic_json( &self.participant_path(&participant.handle.participant_id), @@ -557,6 +570,7 @@ impl AgentRuntimeStateStore { active_generation: u64, ) -> Result> { let mut invalidated_participant_ids = Vec::new(); + let mut invalidated_participants = Vec::new(); for mut participant in self.list_participants_across_sources()? { if participant.handle.orchestration_session_id != orchestration_session_id @@ -576,9 +590,20 @@ impl AgentRuntimeStateStore { if participant.invalidate_for_world_generation_rollover() { invalidated_participant_ids.push(participant.handle.participant_id.clone()); - self.persist_participant(&participant)?; + invalidated_participants.push(participant); } } + if invalidated_participants.is_empty() { + return Ok(invalidated_participant_ids); + } + + let _write_guard = snapshot_write_lock() + .lock() + .expect("snapshot write mutex poisoned"); + for participant in &invalidated_participants { + self.validate_participant_record(participant)?; + self.write_participant_snapshot(participant)?; + } Ok(invalidated_participant_ids) } @@ -803,7 +828,54 @@ impl AgentRuntimeStateStore { orchestration_session_id ); } - if (action.requires_internal_session_id() + let host_attach_contract = resolved.session.host_attach_contract().cloned(); + if action.requires_attach_contract() && host_attach_contract.is_none() { + anyhow::bail!( + "owner_unreachable: orchestration session {} is missing durable host attach contract state", + orchestration_session_id + ); + } + if matches!(action, PublicControlAction::Resume) + && host_attach_contract + .as_ref() + .is_some_and(|contract| !contract.supports_resume()) + { + anyhow::bail!( + "owner_unreachable: orchestration session {} durable host attach contract does not allow resume", + orchestration_session_id + ); + } + if matches!(action, PublicControlAction::Fork) + && host_attach_contract + .as_ref() + .is_some_and(|contract| !contract.supports_fork()) + { + anyhow::bail!( + "owner_unreachable: orchestration session {} durable host attach contract does not allow fork", + orchestration_session_id + ); + } + if matches!(action, PublicControlAction::Stop) + && host_attach_contract + .as_ref() + .is_some_and(|contract| !contract.supports_stop()) + { + anyhow::bail!( + "owner_unreachable: orchestration session {} durable host attach contract does not allow stop", + orchestration_session_id + ); + } + if action.requires_continuity_contract() + && host_attach_contract + .as_ref() + .is_none_or(|contract| !contract.has_continuity_selector()) + { + anyhow::bail!( + "owner_unreachable: orchestration session {} no longer has continuity required for control-only reattach", + orchestration_session_id + ); + } + if (matches!(action, PublicControlAction::Resume) || matches!( (action, resolved.session_posture), ( @@ -818,15 +890,13 @@ impl AgentRuntimeStateStore { orchestration_session_id ); } - if (action.requires_internal_session_id() - || matches!( - (action, resolved.session_posture), - ( - PublicControlAction::Stop, - PublicSessionPosture::DetachedReattachable - ) - )) - && resolved.participant.internal_uaa_session_id().is_none() + if matches!( + (action, resolved.session_posture), + ( + PublicControlAction::Stop, + PublicSessionPosture::DetachedReattachable + ) + ) && resolved.participant.internal_uaa_session_id().is_none() { anyhow::bail!( "missing_internal_session_id: orchestration session {} active participant {} is missing internal.uaa_session_id", @@ -839,6 +909,7 @@ impl AgentRuntimeStateStore { session: resolved.session, active_participant: resolved.participant, session_posture: resolved.session_posture, + host_attach_contract, }) } @@ -913,11 +984,14 @@ impl AgentRuntimeStateStore { ); } + let host_attach_contract = authoritative.session.host_attach_contract().cloned(); + Ok(ResolvedPublicTurnTarget { session: authoritative.session, participant: candidate.participant, target_kind: candidate.kind, session_posture: authoritative.session_posture, + host_attach_contract, }) } @@ -2072,6 +2146,7 @@ fn synthesize_session_record( "".to_string(), "".to_string(), template, + None, ); session.opened_at = participants .iter() @@ -2307,6 +2382,7 @@ pub(crate) fn valid_detached_host_continuity_posture( participant: &AgentRuntimeParticipantRecord, require_internal_session_id: bool, ) -> Option { + let contract = session.host_attach_contract()?; if session.state.is_terminal() || !participant.handle.state.is_live() { return None; } @@ -2322,7 +2398,10 @@ pub(crate) fn valid_detached_host_continuity_posture( if !participant.is_resume_eligible() { return None; } - if require_internal_session_id && participant.internal_uaa_session_id().is_none() { + if !contract.supports_resume() || !contract.supports_continuity_attach() { + return None; + } + if require_internal_session_id && !contract.has_continuity_selector() { return None; } @@ -2343,6 +2422,9 @@ fn recoverable_stale_host_attachment( participant: &AgentRuntimeParticipantRecord, require_internal_session_id: bool, ) -> bool { + let Some(contract) = session.host_attach_contract() else { + return false; + }; if session.state.is_terminal() || !participant.handle.state.is_live() { return false; } @@ -2363,6 +2445,9 @@ fn recoverable_stale_host_attachment( if owner_process_is_alive(participant) || !participant.is_resume_eligible() { return false; } + if !contract.supports_resume() || !contract.supports_continuity_attach() { + return false; + } if record.participants.iter().any(|candidate| { candidate.participant_id() != participant.participant_id() && candidate.matches_public_parent_linkage(session) @@ -2374,7 +2459,7 @@ fn recoverable_stale_host_attachment( return false; } - !require_internal_session_id || participant.internal_uaa_session_id().is_some() + !require_internal_session_id || contract.has_continuity_selector() } fn session_authoritative_participant_id(session: &OrchestrationSessionRecord) -> Option<&str> { @@ -2538,6 +2623,7 @@ mod tests { "trace_session".to_string(), "/workspace".to_string(), participant, + HostAttachContract::from_manifest_for_test(participant), ); parent.transition_state(OrchestrationSessionState::Active); parent.bind_active_session_handle(participant.handle.participant_id.clone()); @@ -3246,6 +3332,44 @@ mod tests { }); } + #[test] + #[serial_test::serial] + fn invalidate_stale_world_members_for_session_handles_large_batches() { + with_store(|store| { + let orchestrator = live_orchestrator("claude_code", "sess_live", "ash_orchestrator"); + store + .persist_participant(&orchestrator) + .expect("persist orchestrator"); + + for idx in 0..256 { + let participant_id = format!("ash_member_stale_{idx:03}"); + let stale_member = + live_member("codex", "sess_live", &participant_id, "ash_orchestrator"); + store + .persist_participant(&stale_member) + .expect("persist stale member"); + } + + let invalidated = store + .invalidate_stale_world_members_for_session("sess_live", 3) + .expect("invalidate stale members"); + + assert_eq!(invalidated.len(), 256); + for idx in 0..256 { + let participant_id = format!("ash_member_stale_{idx:03}"); + let stale_member = store + .load_participant(&participant_id) + .expect("load stale member") + .expect("stale member exists"); + assert_eq!( + stale_member.handle.state, + AgentRuntimeSessionState::Invalidated, + "{participant_id} must be invalidated in the same sweep" + ); + } + }); + } + #[test] #[serial_test::serial] fn list_invalidated_participants_reads_authoritative_tombstones_only() { @@ -3445,7 +3569,7 @@ mod tests { #[test] #[serial_test::serial] - fn resolve_public_control_target_requires_internal_session_id_for_resume_and_fork() { + fn resolve_public_control_target_requires_continuity_for_resume_but_not_fork() { with_store(|store| { let participant = detached_orchestrator("codex", "sess_missing_internal", "ash_detached"); @@ -3462,13 +3586,136 @@ mod tests { let resume_err = store .resolve_public_control_target("sess_missing_internal", PublicControlAction::Resume) - .expect_err("resume must require internal.uaa_session_id"); + .expect_err("resume must require continuity"); assert!(resume_err.to_string().contains("owner_unreachable")); - let fork_err = store + let fork_target = store .resolve_public_control_target("sess_missing_internal", PublicControlAction::Fork) - .expect_err("fork must require internal.uaa_session_id"); - assert!(fork_err.to_string().contains("owner_unreachable")); + .expect("fork should allow durable successor allocation without continuity"); + assert_eq!( + fork_target.active_participant.handle.participant_id, + "ash_detached" + ); + assert!( + fork_target.host_attach_contract.is_some(), + "fork must still require the durable host attach contract" + ); + assert!(fork_target + .host_attach_contract + .as_ref() + .expect("durable contract") + .supports_fork()); + }); + } + + #[test] + #[serial_test::serial] + fn resolve_public_control_target_uses_persisted_continuity_truth() { + with_store(|store| { + let participant = + detached_orchestrator("codex", "sess_persisted_resume", "ash_detached"); + let parent = active_parent(&participant); + let mut participant = participant; + participant.internal.uaa_session_id = None; + + store + .persist_orchestration_session(&parent) + .expect("persist parent"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let target = store + .resolve_public_control_target("sess_persisted_resume", PublicControlAction::Resume) + .expect("persisted contract continuity should remain authoritative"); + assert_eq!( + target + .host_attach_contract + .as_ref() + .and_then(|contract| contract.continuity_uaa_session_id.as_deref()), + Some("uaa_session") + ); + }); + } + + #[test] + #[serial_test::serial] + fn resolve_public_control_target_respects_persisted_resume_narrowing() { + with_store(|store| { + let participant = detached_orchestrator("codex", "sess_resume_denied", "ash_detached"); + let mut parent = parked_parent(&participant); + parent + .host_attach_contract + .as_mut() + .expect("durable contract") + .capabilities + .session_resume = false; + + store + .persist_orchestration_session(&parent) + .expect("persist parent"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let err = store + .resolve_public_control_target("sess_resume_denied", PublicControlAction::Resume) + .expect_err("resume must honor persisted capability narrowing"); + assert!(err.to_string().contains("does not allow resume")); + }); + } + + #[test] + #[serial_test::serial] + fn resolve_public_control_target_respects_persisted_fork_narrowing() { + with_store(|store| { + let participant = detached_orchestrator("codex", "sess_fork_denied", "ash_detached"); + let mut parent = parked_parent(&participant); + parent + .host_attach_contract + .as_mut() + .expect("durable contract") + .capabilities + .session_fork = false; + + store + .persist_orchestration_session(&parent) + .expect("persist parent"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let err = store + .resolve_public_control_target("sess_fork_denied", PublicControlAction::Fork) + .expect_err("fork must honor persisted capability narrowing"); + assert!(err.to_string().contains("does not allow fork")); + }); + } + + #[test] + #[serial_test::serial] + fn resolve_public_control_target_respects_persisted_stop_narrowing() { + with_store(|store| { + let participant = detached_orchestrator("codex", "sess_stop_denied", "ash_detached"); + let mut parent = parked_parent(&participant); + parent + .host_attach_contract + .as_mut() + .expect("durable contract") + .capabilities + .session_stop = false; + + store + .persist_orchestration_session(&parent) + .expect("persist parent"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let err = store + .resolve_public_control_target("sess_stop_denied", PublicControlAction::Stop) + .expect_err("stop must honor persisted capability narrowing"); + assert!(err.to_string().contains("does not allow stop")); }); } @@ -3493,6 +3740,41 @@ mod tests { target.session_posture, PublicSessionPosture::DetachedReattachable ); + assert!(target.host_attach_contract.is_some()); + }); + } + + #[test] + #[serial_test::serial] + fn resolve_public_turn_target_uses_persisted_continuity_truth() { + with_store(|store| { + let participant = detached_orchestrator("codex", "sess_turn_persisted", "ash_detached"); + let mut parent = active_parent(&participant); + parent.mark_parked_resumable("owner detached cleanly"); + let mut participant = participant; + participant.internal.uaa_session_id = None; + + store + .persist_orchestration_session(&parent) + .expect("persist parent"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let target = store + .resolve_public_turn_target("sess_turn_persisted", "cli:codex") + .expect("persisted contract continuity should keep detached posture"); + assert_eq!( + target.session_posture, + PublicSessionPosture::DetachedReattachable + ); + assert_eq!( + target + .host_attach_contract + .as_ref() + .and_then(|contract| contract.continuity_uaa_session_id.as_deref()), + Some("uaa_session") + ); }); } @@ -3660,6 +3942,44 @@ mod tests { }); } + #[test] + #[serial_test::serial] + fn load_session_fails_closed_for_incomplete_host_attach_contract_json() { + with_store(|store| { + let participant = live_orchestrator("codex", "sess_legacy_attach", "ash_selected"); + let parent = active_parent(&participant); + let mut payload = serde_json::to_value(&parent).expect("serialize session"); + let contract = payload + .get_mut("host_attach_contract") + .and_then(Value::as_object_mut) + .expect("host attach contract"); + contract.remove("capabilities"); + contract.remove("attach_launch_knobs"); + + fs::create_dir_all(store.sessions_dir()).expect("create sessions dir"); + fs::create_dir_all(store.canonical_session_dir("sess_legacy_attach")) + .expect("create canonical session dir"); + fs::write( + store.orchestration_session_path("sess_legacy_attach"), + serde_json::to_vec_pretty(&payload).expect("serialize legacy flat session"), + ) + .expect("write legacy flat session"); + fs::write( + store.canonical_session_path("sess_legacy_attach"), + serde_json::to_vec_pretty(&payload).expect("serialize legacy canonical session"), + ) + .expect("write legacy canonical session"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let err = store + .load_session("sess_legacy_attach") + .expect_err("incomplete persisted attach truth must fail closed"); + assert!(err.to_string().contains("failed to parse")); + }); + } + #[test] #[serial_test::serial] fn hidden_owner_helper_launch_continuity_reports_stale_attached_truth_separately() { @@ -4234,6 +4554,7 @@ mod tests { "trace_session".to_string(), "/workspace".to_string(), &participant, + HostAttachContract::from_manifest_for_test(&participant), ); store diff --git a/crates/shell/src/execution/agent_runtime/validator.rs b/crates/shell/src/execution/agent_runtime/validator.rs index 19932c20d..e4550a422 100644 --- a/crates/shell/src/execution/agent_runtime/validator.rs +++ b/crates/shell/src/execution/agent_runtime/validator.rs @@ -4,8 +4,12 @@ use std::path::PathBuf; use anyhow::Result; use substrate_broker::Policy; -use crate::execution::agent_inventory::{ - AgentCapabilitiesV1, AgentConfigKind, AgentInventoryEntryV1, +use crate::execution::agent_inventory::{AgentCapabilitiesV1, AgentInventoryEntryV1}; +use crate::execution::agent_runtime::dispatch_contract::{ + resolve_inventory_contract_for_exact_backend, resolve_inventory_contract_for_unique_scope, + AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, DispatchCallerKind, + DispatchCapabilityOverrideSet, DispatchRequestEnvelope, HostExecutionClientStart, + ResolvedLaunchContract, }; use crate::execution::config_model::{AgentCliMode, AgentExecutionScope, SubstrateConfig}; @@ -121,25 +125,72 @@ pub(crate) fn validate_runtime_realizability( entry: &AgentInventoryEntryV1, effective_config: &SubstrateConfig, ) -> std::result::Result { - if entry.file.config.kind != AgentConfigKind::Cli { + let contract = ResolvedLaunchContract { + caller_kind: DispatchCallerKind::HumanStart, + baseline_kind: DispatchBaselineKind::InventoryLaunch, + agent_id: entry.file.id.clone(), + backend_id: entry.derived_backend_id(), + backend_kind: orchestrator_backend_kind(&entry.file.id).map_err(|err| { + RuntimeRealizabilityError { + exit_code: 2, + reason: err + .to_string() + .replace("selected orchestrator backend", "selected runtime backend"), + } + })?, + protocol: entry + .file + .config + .protocol + .clone() + .unwrap_or_else(|| PURE_AGENT_PROTOCOL.to_string()), + execution_scope: entry.effective_scope(effective_config), + runtime: crate::execution::agent_runtime::dispatch_contract::ResolvedLaunchRuntime { + kind: entry.file.config.kind, + cli_mode: entry.effective_cli_mode(effective_config), + cli_binary: entry.effective_cli_binary().map(ToOwned::to_owned), + }, + capabilities: entry.file.config.capabilities.clone(), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: entry.effective_scope(effective_config), + host_execution_client_start: HostExecutionClientStart::StartNow, + attach_mode_preference: AttachModePreference::ContinuityRequired, + }, + effective_policy: Policy::default(), + baseline_source: crate::execution::agent_runtime::dispatch_contract::BaselineSourceMetadata { + baseline_kind: DispatchBaselineKind::InventoryLaunch, + baseline_origin: + crate::execution::agent_runtime::dispatch_contract::FieldBaselineOrigin::GlobalInventory, + inventory_path: Some(entry.path.clone()), + orchestration_session_id: None, + }, + field_provenance: BTreeMap::new(), + }; + + materialize_runtime_descriptor(&contract) +} + +pub(crate) fn materialize_runtime_descriptor( + contract: &ResolvedLaunchContract, +) -> std::result::Result { + if contract.runtime.kind != crate::execution::agent_inventory::AgentConfigKind::Cli { return Err(RuntimeRealizabilityError { exit_code: 2, reason: format!( "selected runtime '{}' is not runtime-realizable by the shell-owned UAA runtime because config.kind={} is unsupported; only config.kind=cli is supported in v1", - entry.file.id, - entry.file.config.kind.as_str() + contract.agent_id, + contract.runtime.kind.as_str() ), }); } - let cli_mode = entry.effective_cli_mode(effective_config); - if cli_mode != AgentCliMode::Persistent { + if contract.runtime.cli_mode != AgentCliMode::Persistent { return Err(RuntimeRealizabilityError { exit_code: 2, reason: format!( "selected runtime '{}' is not runtime-realizable because cli.mode={} is unsupported; only cli.mode=persistent is supported for the first caller path", - entry.file.id, - match cli_mode { + contract.agent_id, + match contract.runtime.cli_mode { AgentCliMode::Persistent => "persistent", AgentCliMode::PerRequest => "per_request", } @@ -147,37 +198,32 @@ pub(crate) fn validate_runtime_realizability( }); } - let backend_kind = - orchestrator_backend_kind(&entry.file.id).map_err(|err| RuntimeRealizabilityError { - exit_code: 2, - reason: err - .to_string() - .replace("selected orchestrator backend", "selected runtime backend"), - })?; - - let binary = entry - .effective_cli_binary() - .ok_or_else(|| RuntimeRealizabilityError { - exit_code: 4, - reason: format!( + let binary = + contract + .runtime + .cli_binary + .as_deref() + .ok_or_else(|| RuntimeRealizabilityError { + exit_code: 4, + reason: format!( "selected runtime '{}' is not runtime-realizable because config.cli.binary is missing", - entry.file.id + contract.agent_id ), - })?; + })?; let binary_path = which::which(binary).map_err(|err| RuntimeRealizabilityError { exit_code: 4, reason: format!( "selected runtime '{}' is not runtime-realizable because config.cli.binary '{}' did not resolve on the host: {}", - entry.file.id, binary, err + contract.agent_id, binary, err ), })?; Ok(RuntimeSelectionDescriptor { - agent_id: entry.file.id.clone(), - backend_id: entry.derived_backend_id(), - backend_kind, - protocol: PURE_AGENT_PROTOCOL.to_string(), - execution_scope: entry.effective_scope(effective_config), + agent_id: contract.agent_id.clone(), + backend_id: contract.backend_id.clone(), + backend_kind: contract.backend_kind, + protocol: contract.protocol.clone(), + execution_scope: contract.execution_scope, binary_path, }) } @@ -186,65 +232,34 @@ pub(crate) fn validate_member_selection( effective_config: &SubstrateConfig, inventory: &BTreeMap, ) -> std::result::Result, MemberSelectionError> { - let mut selected = Vec::new(); - - for entry in inventory.values() { - if !entry.file.config.enabled - || entry.effective_scope(effective_config) != AgentExecutionScope::World - { - continue; - } - - if entry.file.config.protocol.as_deref() != Some(PURE_AGENT_PROTOCOL) { - return Err(MemberSelectionError { - exit_code: 2, - reason: format!( - "world-scoped member '{}' is not eligible because {}", - entry.file.id, - protocol_validation_error("it", entry.file.config.protocol.as_deref()) - ), - }); - } - - if let Some(capability) = - missing_required_orchestrator_capability(&entry.file.config.capabilities) - { - return Err(MemberSelectionError { - exit_code: 2, - reason: format!( - "world-scoped member '{}' is not eligible because it is missing required capability '{}'", - entry.file.id, capability - ), - }); - } - - let descriptor = - validate_runtime_realizability(entry, effective_config).map_err(|err| { - MemberSelectionError { - exit_code: err.exit_code, - reason: err.reason, - } - })?; - selected.push(descriptor); - } + let envelope = inventory_dispatch_envelope( + DispatchCallerKind::OrchestratorMemberStart, + DispatchBaselineKind::InventoryLaunch, + None, + AgentExecutionScope::World, + ); + let selection_policy = permissive_inventory_selection_policy(inventory); + let resolved = resolve_inventory_contract_for_unique_scope( + PathBuf::from(".").as_path(), + effective_config, + inventory, + &selection_policy, + &envelope, + AgentExecutionScope::World, + ) + .map_err(|err| MemberSelectionError { + exit_code: 2, + reason: err.reason, + })?; - match selected.len() { - 0 => Ok(None), - 1 => Ok(selected.into_iter().next()), - _ => { - let agent_ids = selected - .iter() - .map(|descriptor| descriptor.agent_id.as_str()) - .collect::>() - .join(", "); - Err(MemberSelectionError { - exit_code: 2, - reason: format!( - "ambiguous world member selection: multiple eligible world-scoped members found ({agent_ids})" - ), - }) - } - } + resolved + .as_ref() + .map(materialize_runtime_descriptor) + .transpose() + .map_err(|err| MemberSelectionError { + exit_code: err.exit_code, + reason: err.reason, + }) } pub(crate) fn validate_exact_backend_selection( @@ -253,90 +268,75 @@ pub(crate) fn validate_exact_backend_selection( scope: AgentExecutionScope, backend_id: &str, ) -> std::result::Result, ExactBackendSelectionError> { - let mut matches = inventory - .values() - .filter(|entry| { - entry.file.config.enabled - && entry.effective_scope(effective_config) == scope - && entry.derived_backend_id() == backend_id - }) - .collect::>(); + let envelope = inventory_dispatch_envelope( + if scope == AgentExecutionScope::Host { + DispatchCallerKind::HumanStart + } else { + DispatchCallerKind::OrchestratorMemberStart + }, + DispatchBaselineKind::InventoryLaunch, + Some(backend_id), + scope, + ); + let selection_policy = permissive_inventory_selection_policy(inventory); + let resolved = resolve_inventory_contract_for_exact_backend( + PathBuf::from(".").as_path(), + effective_config, + inventory, + &selection_policy, + &envelope, + scope, + ) + .map_err(|err| ExactBackendSelectionError { + exit_code: 2, + reason: err.reason, + })?; - match matches.len() { - 0 => Ok(None), - 1 => { - let entry = matches.pop().expect("single exact backend match"); - validate_exact_backend_entry(entry, effective_config, scope, backend_id) - } - _ => { - let agent_ids = matches - .iter() - .map(|entry| entry.file.id.as_str()) - .collect::>() - .join(", "); - Err(ExactBackendSelectionError { - exit_code: 2, - reason: format!( - "ambiguous exact backend selection: multiple {} runtime entries advertise backend '{}' ({agent_ids})", - runtime_scope_label(scope), - backend_id, - ), - }) - } - } + resolved + .as_ref() + .map(materialize_runtime_descriptor) + .transpose() + .map_err(|err| ExactBackendSelectionError { + exit_code: err.exit_code, + reason: err.reason, + }) } -fn validate_exact_backend_entry( - entry: &AgentInventoryEntryV1, - effective_config: &SubstrateConfig, +fn inventory_dispatch_envelope( + caller_kind: DispatchCallerKind, + baseline_kind: DispatchBaselineKind, + backend_id: Option<&str>, scope: AgentExecutionScope, - backend_id: &str, -) -> std::result::Result, ExactBackendSelectionError> { - if entry.file.config.protocol.as_deref() != Some(PURE_AGENT_PROTOCOL) { - return Err(ExactBackendSelectionError { - exit_code: 2, - reason: format!( - "selected {} runtime '{}' for backend '{}' {}", - runtime_scope_label(scope), - entry.file.id, - backend_id, - protocol_validation_error( - "does not advertise", - entry.file.config.protocol.as_deref() - ) - .replacen("does not advertise ", "", 1), - ), - }); - } - - if let Some(capability) = - missing_required_orchestrator_capability(&entry.file.config.capabilities) - { - return Err(ExactBackendSelectionError { - exit_code: 2, - reason: format!( - "selected {} runtime '{}' for backend '{}' is missing required capability '{}'", - runtime_scope_label(scope), - entry.file.id, - backend_id, - capability, - ), - }); +) -> DispatchRequestEnvelope { + DispatchRequestEnvelope { + caller_kind, + baseline_kind, + backend_id: backend_id.map(ToOwned::to_owned), + orchestration_session_id: None, + requested_execution_scope_override: None, + capability_overrides: DispatchCapabilityOverrideSet::default(), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: scope, + host_execution_client_start: if scope == AgentExecutionScope::Host { + HostExecutionClientStart::StartNow + } else { + HostExecutionClientStart::Defer + }, + attach_mode_preference: AttachModePreference::ContinuityRequired, + }, + has_prompt_payload: false, } - - let descriptor = validate_runtime_realizability(entry, effective_config).map_err(|err| { - ExactBackendSelectionError { - exit_code: err.exit_code, - reason: err.reason, - } - })?; - Ok(Some(descriptor)) } -fn runtime_scope_label(scope: AgentExecutionScope) -> &'static str { - match scope { - AgentExecutionScope::Host => "host-scoped", - AgentExecutionScope::World => "world-scoped", +fn permissive_inventory_selection_policy( + inventory: &BTreeMap, +) -> Policy { + Policy { + agents_allowed_backends: inventory + .values() + .map(AgentInventoryEntryV1::derived_backend_id) + .collect(), + ..Policy::default() } } diff --git a/crates/shell/src/execution/agents_cmd.rs b/crates/shell/src/execution/agents_cmd.rs index c94e11c85..b8ab48306 100644 --- a/crates/shell/src/execution/agents_cmd.rs +++ b/crates/shell/src/execution/agents_cmd.rs @@ -13,29 +13,37 @@ use crate::execution::agent_runtime::control::{ HiddenOwnerHelperParticipantPlan, HiddenOwnerHelperSessionPlan, HiddenOwnerHelperStartTimeoutReconciliation, OwnerHelperMode, PublicPromptAction, PublicPromptCommandRequest, PublicPromptInput, PublicSessionPosture, - ResolvedRuntimeBackendKind, ResolvedRuntimeDescriptor, HIDDEN_OWNER_HELPER_SUBCOMMAND, + HIDDEN_OWNER_HELPER_SUBCOMMAND, }; #[cfg(unix)] use crate::execution::agent_runtime::control::{ - private_stop_transport_path, register_hidden_owner_helper_startup_prompt_listener, - run_hidden_owner_helper_startup_prompt_stream, HiddenOwnerHelperStartupPromptPlan, + private_stop_transport_path, reconcile_resumed_public_turn_detach_timeout, + reconcile_start_prompt_completion_timeout, + register_hidden_owner_helper_startup_prompt_listener, + run_hidden_owner_helper_startup_prompt_stream, + run_hidden_owner_helper_startup_prompt_stream_with_action, HiddenOwnerHelperStartupPromptPlan, }; +use crate::execution::agent_runtime::orchestration_session::HostAttachContract; use crate::execution::agent_runtime::orchestration_session::{ OrchestrationSessionPosture, OrchestrationSessionRecord, }; +use crate::execution::agent_runtime::session::AgentRuntimeReplacementParticipantInit; #[cfg(unix)] use crate::execution::agent_runtime::state_store::HiddenOwnerHelperLaunchReadiness; use crate::execution::agent_runtime::validator::{ - exact_backend_selection_error_exit_code, member_selection_error_exit_code, - validate_exact_backend_selection, validate_member_selection, + materialize_runtime_descriptor, member_selection_error_exit_code, validate_member_selection, + RuntimeSelectionDescriptor, }; #[cfg(unix)] use crate::execution::agent_runtime::StartupPromptReplayState; use crate::execution::agent_runtime::{ + resolve_inventory_contract_for_exact_backend, resolve_persisted_host_attach_contract, runtime_realizability_error_exit_code, validate_orchestrator_selection, validate_runtime_realizability, AgentRuntimeParticipantRecord, AgentRuntimeSessionRecord, - AgentRuntimeStateStore, PublicControlAction, PublicTurnTargetKind, MEMBER_ROLE, NESTED_ROUTER, - ORCHESTRATOR_ROLE, PURE_AGENT_PROTOCOL, PURE_AGENT_ROUTER, + AgentRuntimeStateStore, AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, + DispatchCallerKind, DispatchCapabilityOverrideSet, DispatchRequestEnvelope, + HostExecutionClientStart, PublicControlAction, PublicTurnTargetKind, ResolvedLaunchContract, + MEMBER_ROLE, NESTED_ROUTER, ORCHESTRATOR_ROLE, PURE_AGENT_PROTOCOL, PURE_AGENT_ROUTER, }; use crate::execution::cli::{ AgentAction, AgentCmd, AgentDoctorArgs, AgentOwnerHelperArgs, AgentScopeArg, @@ -361,6 +369,11 @@ struct AgentControlResultJson<'a> { source_orchestration_session_id: Option<&'a str>, } +struct StartLaunchPlan { + helper_plan: HiddenOwnerHelperLaunchPlan, + resolved_contract: ResolvedLaunchContract, +} + fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { let prompt = load_public_prompt_source(&PublicPromptInput { prompt: args.prompt_source.prompt.clone(), @@ -369,12 +382,16 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { .map_err(normalize_public_prompt_error)?; let context = resolve_command_context(cli)?; let store = AgentRuntimeStateStore::new()?; - let plan = build_start_launch_plan(args, &context)?; + let StartLaunchPlan { + helper_plan, + resolved_contract, + } = build_start_launch_plan(args, &context)?; #[cfg(not(unix))] { let _ = store; - let _ = plan; + let _ = helper_plan; + let _ = resolved_contract; let _ = prompt; anyhow::bail!(config_model::user_error( "unsupported_platform_or_posture: public start prompt streaming requires a Unix launch-time backchannel" @@ -383,7 +400,7 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { #[cfg(unix)] { - let mut plan = plan; + let mut plan = helper_plan; let startup_listener = register_hidden_owner_helper_startup_prompt_listener( &store, plan.orchestration_session_id(), @@ -402,6 +419,13 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { &receipt.orchestration_session_id, &receipt.participant_id, ) + .and_then(|()| { + persist_resolved_start_attach_contract( + &store, + &receipt.orchestration_session_id, + &resolved_contract, + ) + }) .map_err(runtime_start_error), Err(err) if recoverable_detached_start_retry( @@ -430,6 +454,13 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { &retry_receipt.orchestration_session_id, &retry_receipt.participant_id, ) + .and_then(|()| { + persist_resolved_start_attach_contract( + &store, + &retry_receipt.orchestration_session_id, + &resolved_contract, + ) + }) .map_err(runtime_start_error) } Err(err) => Err(normalize_public_prompt_error(err)), @@ -486,6 +517,20 @@ fn stabilize_hidden_owner_helper_start_return( } if normalization_started_at.elapsed() >= START_DETACH_NORMALIZATION_TIMEOUT { + match reconcile_hidden_owner_helper_start_timeout(store, plan) { + Ok(HiddenOwnerHelperStartTimeoutReconciliation::Success) => return Ok(()), + Ok( + HiddenOwnerHelperStartTimeoutReconciliation::FailureMarkedTerminal + | HiddenOwnerHelperStartTimeoutReconciliation::FailureUnchanged, + ) => {} + Err(reconcile_err) => { + anyhow::bail!( + "timed out waiting for detached start normalization for orchestration session {} after hidden owner-helper {} exited; additionally failed to reconcile persisted startup state: {reconcile_err:#}", + plan.orchestration_session_id(), + child.id(), + ); + } + } let snapshot_summary = store .load_orchestration_session(plan.orchestration_session_id())? .map(|session| { @@ -516,7 +561,6 @@ fn wait_for_start_prompt_completion_normalization( participant_id: &str, ) -> Result<()> { let normalization_started_at = std::time::Instant::now(); - let mut attached_grace_started_at: Option = None; loop { match store.classify_hidden_owner_helper_launch_readiness( orchestration_session_id, @@ -524,19 +568,25 @@ fn wait_for_start_prompt_completion_normalization( true, )? { HiddenOwnerHelperLaunchReadiness::ReadyDetached(_) => return Ok(()), - HiddenOwnerHelperLaunchReadiness::ReadyAttached => { - let grace_started_at = - attached_grace_started_at.get_or_insert_with(std::time::Instant::now); - if grace_started_at.elapsed() >= START_ATTACHED_GRACE_TIMEOUT { - return Ok(()); - } - } - HiddenOwnerHelperLaunchReadiness::Pending => { - attached_grace_started_at = None; - } + HiddenOwnerHelperLaunchReadiness::ReadyAttached + | HiddenOwnerHelperLaunchReadiness::Pending => {} } if normalization_started_at.elapsed() >= START_DETACH_NORMALIZATION_TIMEOUT { + match reconcile_start_prompt_completion_timeout( + store, + orchestration_session_id, + participant_id, + ) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(reconcile_err) => { + anyhow::bail!( + "timed out waiting for detached start normalization after startup prompt completion for orchestration session {}; additionally failed to reconcile persisted startup state: {reconcile_err:#}", + orchestration_session_id, + ); + } + } let snapshot_summary = store .load_orchestration_session(orchestration_session_id)? .map(|session| { @@ -616,25 +666,51 @@ fn run_turn(args: &AgentTurnArgs, cli: &Cli) -> Result<()> { .map_err(|err| config_model::user_error(err.to_string()))?; let orchestration_session_id = target.session.orchestration_session_id.clone(); let backend_id = target.participant.handle.backend_id.clone(); + #[cfg(unix)] + let exact_backend_id = backend_id.clone(); - let resumed_receipt = match target.session_posture { + #[cfg(unix)] + if target.session_posture == PublicSessionPosture::DetachedReattachable + && target.target_kind == PublicTurnTargetKind::Host + { + let mut plan = build_resumed_turn_launch_plan(&args.session)?; + if plan.descriptor.backend_id != backend_id { + anyhow::bail!(config_model::user_error(format!( + "backend_not_in_session: orchestration session {} has no exact backend slot for {}", + args.session, args.backend + ))); + } + let startup_listener = register_hidden_owner_helper_startup_prompt_listener( + &store, + plan.orchestration_session_id(), + plan.participant_id(), + ) + .map_err(runtime_start_error)?; + plan.startup_prompt = Some(HiddenOwnerHelperStartupPromptPlan { + prompt_text: prompt.prompt_text.clone(), + stream_path: startup_listener.path().to_path_buf(), + }); + let receipt = launch_hidden_owner_helper(&plan, cli).map_err(runtime_start_error)?; + run_hidden_owner_helper_startup_prompt_stream_with_action( + startup_listener, + args.json, + PublicPromptAction::Turn, + ) + .map_err(normalize_public_prompt_error)?; + wait_for_resumed_public_turn_detach( + &store, + &receipt.orchestration_session_id, + &receipt.participant_id, + &exact_backend_id, + ) + .map_err(runtime_start_error)?; + return Ok(()); + } + + let resumed_receipt: Option = match target.session_posture { PublicSessionPosture::Active => None, PublicSessionPosture::DetachedReattachable => match target.target_kind { - PublicTurnTargetKind::Host => { - let plan = build_successor_launch_plan( - cli, - &args.session, - OwnerHelperMode::ResumeOneTurn, - )?; - if plan.descriptor.backend_id != backend_id { - anyhow::bail!(config_model::user_error(format!( - "backend_not_in_session: orchestration session {} has no exact backend slot for {}", - args.session, - args.backend - ))); - } - Some(launch_hidden_owner_helper(&plan, cli).map_err(runtime_start_error)?) - } + PublicTurnTargetKind::Host => unreachable!("detached host turns are handled above"), PublicTurnTargetKind::World => { anyhow::bail!(config_model::user_error(format!( "unsupported_platform_or_posture: orchestration session {} backend {} is detached and cannot be recovered through the retained world-member seam without an active host owner; run `substrate agent reattach --session {}` first", @@ -674,6 +750,7 @@ fn run_turn(args: &AgentTurnArgs, cli: &Cli) -> Result<()> { &store, &receipt.orchestration_session_id, &receipt.participant_id, + &exact_backend_id, ) .map_err(runtime_start_error)?; } @@ -682,11 +759,16 @@ fn run_turn(args: &AgentTurnArgs, cli: &Cli) -> Result<()> { } fn run_reattach(args: &AgentSessionControlArgs, cli: &Cli) -> Result<()> { - let plan = build_successor_launch_plan(cli, &args.session, OwnerHelperMode::Resume)?; + let plan = build_attach_launch_plan( + &args.session, + DispatchCallerKind::HumanReattach, + OwnerHelperMode::Attach, + false, + )?; let receipt = launch_hidden_owner_helper(&plan, cli).map_err(runtime_start_error)?; if receipt.orchestration_session_id != args.session { anyhow::bail!(runtime_start_error(anyhow::anyhow!( - "hidden owner-helper resumed orchestration session {} instead of requested session {}", + "hidden owner-helper attached orchestration session {} instead of requested session {}", receipt.orchestration_session_id, args.session ))); @@ -765,20 +847,117 @@ fn runtime_start_error(err: anyhow::Error) -> anyhow::Error { config_model::user_error(format!("runtime_start_failed: {err}")) } -fn run_fork(args: &AgentSessionControlArgs, cli: &Cli) -> Result<()> { - let plan = build_successor_launch_plan(cli, &args.session, OwnerHelperMode::Fork)?; - let receipt = launch_hidden_owner_helper(&plan, cli)?; +fn dispatch_resolution_user_error( + classifier: &str, + err: crate::execution::agent_runtime::DispatchResolutionError, +) -> anyhow::Error { + config_model::user_error(format!("{classifier}: {err}")) +} + +fn runtime_materialization_user_error( + classifier: &str, + reason: impl Into, +) -> anyhow::Error { + let reason = reason.into(); + let field = if reason.contains("config.kind=") { + "config.kind" + } else if reason.contains("cli.mode=") { + "cli.mode" + } else if reason.contains("config.cli.binary") { + "config.cli.binary" + } else { + "runtime_descriptor" + }; + config_model::user_error(format!( + "{classifier}: runtime materialization rejected field '{field}': {reason}" + )) +} + +fn start_dispatch_classifier( + err: &crate::execution::agent_runtime::DispatchResolutionError, +) -> &'static str { + use crate::execution::agent_runtime::DispatchResolutionErrorKind; + + match err.kind { + DispatchResolutionErrorKind::AmbiguousBaselineSelection => "ambiguous_backend", + DispatchResolutionErrorKind::OverrideDeniedByPolicy => "policy_disallow", + DispatchResolutionErrorKind::BaselineNotFound => "unknown_backend", + DispatchResolutionErrorKind::BaselineIneligible + | DispatchResolutionErrorKind::InvalidPolicyOverlay + | DispatchResolutionErrorKind::RuntimeUnrealizableAfterResolution + | DispatchResolutionErrorKind::UnknownOverrideFamily + | DispatchResolutionErrorKind::OverrideNotSupportedForCaller + | DispatchResolutionErrorKind::OverrideExceedsBaseline + | DispatchResolutionErrorKind::MissingRequiredAttachContinuity => "runtime_start_failed", + } +} + +fn build_start_dispatch_envelope(backend_id: &str) -> DispatchRequestEnvelope { + DispatchRequestEnvelope { + caller_kind: DispatchCallerKind::HumanStart, + baseline_kind: DispatchBaselineKind::InventoryLaunch, + backend_id: Some(backend_id.to_string()), + orchestration_session_id: None, + requested_execution_scope_override: None, + capability_overrides: DispatchCapabilityOverrideSet::default(), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: HostExecutionClientStart::StartNow, + attach_mode_preference: AttachModePreference::ContinuityRequired, + }, + has_prompt_payload: true, + } +} + +fn build_persisted_attach_dispatch_envelope( + caller_kind: DispatchCallerKind, + orchestration_session_id: &str, + backend_id: &str, + host_execution_client_start: HostExecutionClientStart, + attach_mode_preference: AttachModePreference, + has_prompt_payload: bool, +) -> DispatchRequestEnvelope { + DispatchRequestEnvelope { + caller_kind, + baseline_kind: DispatchBaselineKind::PersistedHostAttach, + backend_id: Some(backend_id.to_string()), + orchestration_session_id: Some(orchestration_session_id.to_string()), + requested_execution_scope_override: None, + capability_overrides: DispatchCapabilityOverrideSet::default(), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start, + attach_mode_preference, + }, + has_prompt_payload, + } +} + +fn permissive_inventory_selection_policy( + inventory: &BTreeMap, +) -> Policy { + Policy { + agents_allowed_backends: inventory + .values() + .map(AgentInventoryEntryV1::derived_backend_id) + .collect(), + ..Policy::default() + } +} + +fn run_fork(args: &AgentSessionControlArgs, _cli: &Cli) -> Result<()> { + let allocation = allocate_fork_successor(&args.session)?; render_agent_control_result( args.json, &AgentControlResultJson { action: "fork", - orchestration_session_id: &receipt.orchestration_session_id, - backend_id: &receipt.backend_id, + orchestration_session_id: &allocation.orchestration_session_id, + backend_id: &allocation.backend_id, scope: "host", - state: "active", + state: "parked_resumable", warnings: Vec::new(), - participant_id: Some(&receipt.participant_id), - source_orchestration_session_id: plan.source_orchestration_session_id.as_deref(), + participant_id: None, + source_orchestration_session_id: Some(&allocation.source_orchestration_session_id), }, ) } @@ -886,7 +1065,7 @@ fn run_stop(args: &AgentSessionControlArgs, _cli: &Cli) -> Result<()> { fn build_start_launch_plan( args: &AgentStartArgs, context: &AgentCommandContext, -) -> Result { +) -> Result { let backend_id = args.backend.trim(); if backend_id.is_empty() { anyhow::bail!(config_model::user_error( @@ -894,109 +1073,134 @@ fn build_start_launch_plan( )); } - let descriptor = match validate_exact_backend_selection( + let cwd = current_dir(); + let envelope = build_start_dispatch_envelope(backend_id); + let resolved = match resolve_inventory_contract_for_exact_backend( + &cwd, &context.effective_config, &context.inventory, + &context.base_policy, + &envelope, AgentExecutionScope::Host, - backend_id, ) { - Ok(Some(descriptor)) => descriptor, + Ok(Some(contract)) => contract, Ok(None) => { - if validate_exact_backend_selection( + let permissive_policy = permissive_inventory_selection_policy(&context.inventory); + match resolve_inventory_contract_for_exact_backend( + &cwd, &context.effective_config, &context.inventory, + &permissive_policy, + &envelope, AgentExecutionScope::World, - backend_id, - ) - .ok() - .flatten() - .is_some() - { - anyhow::bail!(config_model::user_error(format!( - "unsupported_platform_or_posture: backend '{}' resolves only to a world-scoped runtime; public root start is host-only in v1", - backend_id - ))); + ) { + Ok(Some(_)) => { + anyhow::bail!(config_model::user_error(format!( + "unsupported_platform_or_posture: baseline truth rejected field 'requested_execution_scope': backend '{}' resolves only to a world-scoped runtime; public root start is host-only in v1", + backend_id + ))); + } + Ok(None) => { + anyhow::bail!(config_model::user_error(format!( + "unknown_backend: baseline truth rejected field 'backend_id': no exact host-scoped backend match found for '{}'", + backend_id + ))); + } + Err(err) => anyhow::bail!(dispatch_resolution_user_error( + start_dispatch_classifier(&err), + err, + )), } - anyhow::bail!(config_model::user_error(format!( - "unknown_backend: no exact host-scoped backend match found for '{}'", - backend_id - ))); - } - Err(err) => { - let prefix = if err.reason.contains("ambiguous exact backend selection") { - "ambiguous_backend" - } else { - "unknown_backend" - }; - let _ = exact_backend_selection_error_exit_code(&err); - anyhow::bail!(config_model::user_error(format!( - "{prefix}: {}", - err.reason - ))); } + Err(err) => anyhow::bail!(dispatch_resolution_user_error( + start_dispatch_classifier(&err), + err, + )), }; - if !backend_allowed(&context.base_policy, &descriptor.backend_id) { - anyhow::bail!(config_model::user_error(format!( - "policy_disallow: selected orchestrator backend '{}' is not allowlisted by effective policy agents.allowed_backends", - descriptor.backend_id - ))); - } + let descriptor = materialize_runtime_descriptor(&resolved) + .map_err(|err| runtime_materialization_user_error("runtime_start_failed", err.reason))?; let orchestration_session_id = Uuid::now_v7().to_string(); - Ok(HiddenOwnerHelperLaunchPlan { - mode: OwnerHelperMode::Start, - descriptor: (&descriptor).into(), - session: HiddenOwnerHelperSessionPlan { - orchestration_session_id, - shell_trace_session_id: Uuid::now_v7().to_string(), - workspace_root: current_dir().display().to_string(), - world_id: None, - world_generation: None, - }, - participant: HiddenOwnerHelperParticipantPlan { - participant_id: format!("ash_{}", Uuid::now_v7()), - lease_token: Uuid::now_v7().to_string(), - run_id: Uuid::now_v7().to_string(), - resumed_from_participant_id: None, - internal_uaa_session_id: None, + Ok(StartLaunchPlan { + helper_plan: HiddenOwnerHelperLaunchPlan { + mode: OwnerHelperMode::Start, + descriptor: (&descriptor).into(), + session: HiddenOwnerHelperSessionPlan { + orchestration_session_id, + shell_trace_session_id: Uuid::now_v7().to_string(), + workspace_root: current_dir().display().to_string(), + world_id: None, + world_generation: None, + }, + participant: HiddenOwnerHelperParticipantPlan { + participant_id: format!("ash_{}", Uuid::now_v7()), + lease_token: Uuid::now_v7().to_string(), + run_id: Uuid::now_v7().to_string(), + resumed_from_participant_id: None, + internal_uaa_session_id: None, + }, + host_attach_contract: HostAttachContract::from_resolved_contract(&resolved, None), + startup_prompt: None, + source_orchestration_session_id: None, }, - startup_prompt: None, - source_orchestration_session_id: None, + resolved_contract: resolved, }) } -fn build_successor_launch_plan( - _cli: &Cli, +#[cfg(unix)] +fn persist_resolved_start_attach_contract( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + resolved: &ResolvedLaunchContract, +) -> Result<()> { + let mut session = store + .load_orchestration_session(orchestration_session_id)? + .ok_or_else(|| anyhow::anyhow!( + "runtime_start_failed: orchestration session {orchestration_session_id} disappeared before durable attach truth could be persisted" + ))?; + let continuity = session + .host_attach_contract() + .and_then(|contract| contract.continuity_uaa_session_id.clone()); + session.host_attach_contract = HostAttachContract::from_resolved_contract(resolved, continuity); + store.persist_orchestration_session(&session)?; + Ok(()) +} + +fn build_attach_launch_plan( orchestration_session_id: &str, + caller_kind: DispatchCallerKind, mode: OwnerHelperMode, + has_prompt_payload: bool, ) -> Result { let store = AgentRuntimeStateStore::new()?; - let action = match mode { - OwnerHelperMode::Resume | OwnerHelperMode::ResumeOneTurn => PublicControlAction::Resume, - OwnerHelperMode::Fork => PublicControlAction::Fork, - OwnerHelperMode::Start => anyhow::bail!("invalid successor launch mode"), - }; let target = store - .resolve_public_control_target(orchestration_session_id, action) + .resolve_public_control_target(orchestration_session_id, PublicControlAction::Resume) .map_err(|err| config_model::user_error(err.to_string()))?; - let descriptor = descriptor_from_participant(&target.active_participant)?; - let target_session_id = if mode == OwnerHelperMode::Fork { - Uuid::now_v7().to_string() - } else { - target.session.orchestration_session_id.clone() - }; - let trace_session_id = if mode == OwnerHelperMode::Fork { - Uuid::now_v7().to_string() - } else { - target.session.shell_trace_session_id.clone() - }; + let attach_contract = target.host_attach_contract.clone().ok_or_else(|| { + config_model::user_error(format!( + "owner_unreachable: orchestration session {} is missing durable host attach contract state", + orchestration_session_id + )) + })?; + let envelope = build_persisted_attach_dispatch_envelope( + caller_kind, + orchestration_session_id, + &attach_contract.backend_id, + HostExecutionClientStart::StartNow, + AttachModePreference::ContinuityRequired, + has_prompt_payload, + ); + let resolved = resolve_persisted_host_attach_contract(&envelope, &attach_contract) + .map_err(|err| dispatch_resolution_user_error("owner_unreachable", err))?; + let descriptor = materialize_runtime_descriptor(&resolved) + .map_err(|err| runtime_materialization_user_error("owner_unreachable", err.reason))?; Ok(HiddenOwnerHelperLaunchPlan { mode, - descriptor, + descriptor: (&descriptor).into(), session: HiddenOwnerHelperSessionPlan { - orchestration_session_id: target_session_id, - shell_trace_session_id: trace_session_id, + orchestration_session_id: target.session.orchestration_session_id.clone(), + shell_trace_session_id: target.session.shell_trace_session_id.clone(), workspace_root: target.session.workspace_root.clone(), world_id: target.session.world_id.clone(), world_generation: target.session.world_generation, @@ -1008,14 +1212,114 @@ fn build_successor_launch_plan( resumed_from_participant_id: Some( target.active_participant.handle.participant_id.clone(), ), - internal_uaa_session_id: target - .active_participant - .internal_uaa_session_id() - .map(ToOwned::to_owned), + internal_uaa_session_id: attach_contract.continuity_uaa_session_id.clone(), }, + host_attach_contract: Some(attach_contract), startup_prompt: None, - source_orchestration_session_id: (mode == OwnerHelperMode::Fork) - .then(|| target.session.orchestration_session_id.clone()), + source_orchestration_session_id: None, + }) +} + +#[cfg(unix)] +fn build_resumed_turn_launch_plan( + orchestration_session_id: &str, +) -> Result { + build_attach_launch_plan( + orchestration_session_id, + DispatchCallerKind::HumanTurn, + OwnerHelperMode::ResumeOneTurn, + true, + ) +} + +#[derive(Clone, Debug)] +struct ForkSuccessorAllocation { + orchestration_session_id: String, + backend_id: String, + source_orchestration_session_id: String, +} + +fn allocate_fork_successor(orchestration_session_id: &str) -> Result { + let store = AgentRuntimeStateStore::new()?; + let target = store + .resolve_public_control_target(orchestration_session_id, PublicControlAction::Fork) + .map_err(|err| config_model::user_error(err.to_string()))?; + let attach_contract = target.host_attach_contract.clone().ok_or_else(|| { + config_model::user_error(format!( + "owner_unreachable: orchestration session {} is missing durable host attach contract state", + orchestration_session_id + )) + })?; + let descriptor = RuntimeSelectionDescriptor::try_from(&attach_contract.launch_descriptor) + .map_err(|err| { + runtime_materialization_user_error( + "owner_unreachable", + format!( + "persisted host attach contract stored an invalid successor launch descriptor: {err:#}" + ), + ) + })?; + let successor_session_id = Uuid::now_v7().to_string(); + let successor_participant_id = format!("ash_{}", Uuid::now_v7()); + let lease_token = Uuid::now_v7().to_string(); + let run_id = Uuid::now_v7().to_string(); + let mut successor_participant = AgentRuntimeParticipantRecord::new_replacement_participant( + &descriptor, + AgentRuntimeReplacementParticipantInit { + orchestration_session_id: successor_session_id.clone(), + participant_id: successor_participant_id.clone(), + role: ORCHESTRATOR_ROLE.to_string(), + orchestrator_participant_id: None, + parent_participant_id: None, + resumed_from_participant_id: target.active_participant.handle.participant_id.clone(), + world: None, + lease_token, + }, + ) + .context("failed to allocate fork successor participant")?; + successor_participant + .transition_state(crate::execution::agent_runtime::AgentRuntimeSessionState::Ready); + successor_participant.internal.latest_run_id = Some(run_id.clone()); + successor_participant.internal.shell_owner_pid = 0; + successor_participant.internal.uaa_session_id = None; + successor_participant.internal.control_owner_retained = false; + successor_participant.internal.event_stream_active = false; + successor_participant.internal.completion_observer_retained = false; + successor_participant.internal.ownership_valid = false; + successor_participant.internal.ownership_verified_at = None; + successor_participant.internal.attached_client_present = false; + successor_participant.internal.last_attached_at = None; + successor_participant.internal.last_detached_at = None; + successor_participant.internal.detach_reason = None; + successor_participant.internal.resume_eligible = false; + successor_participant.touch_heartbeat(); + + let mut successor_session = OrchestrationSessionRecord::new( + successor_session_id.clone(), + Uuid::now_v7().to_string(), + target.session.workspace_root.clone(), + &successor_participant, + target.session.fork_successor_attach_contract(), + ); + successor_session.shell_owner_pid = 0; + successor_session.latest_run_id = Some(run_id); + successor_session.world_id = target.session.world_id.clone(); + successor_session.world_generation = target.session.world_generation; + successor_session + .transition_state(crate::execution::agent_runtime::OrchestrationSessionState::Active); + successor_session.active_session_handle_id = Some(successor_participant_id); + successor_session.pending_inbox_count = 0; + successor_session.last_attention_at = None; + successor_session + .mark_parked_resumable("fork successor allocated without attached host client"); + + store.persist_orchestration_session(&successor_session)?; + store.persist_participant(&successor_participant)?; + + Ok(ForkSuccessorAllocation { + orchestration_session_id: successor_session_id, + backend_id: attach_contract.backend_id, + source_orchestration_session_id: target.session.orchestration_session_id, }) } @@ -1024,45 +1328,114 @@ fn wait_for_resumed_public_turn_detach( store: &AgentRuntimeStateStore, orchestration_session_id: &str, resumed_participant_id: &str, + backend_id: &str, ) -> Result<()> { let started_at = std::time::Instant::now(); loop { - if store - .resumed_public_turn_detach_posture(orchestration_session_id, resumed_participant_id)? - .is_some() - { + if resumed_public_turn_detach_ready( + store, + orchestration_session_id, + resumed_participant_id, + backend_id, + )? && resumed_public_turn_detach_stable_after_grace( + store, + orchestration_session_id, + resumed_participant_id, + backend_id, + )? { return Ok(()); } - - let Some(record) = store.load_session(orchestration_session_id)? else { - anyhow::bail!( - "owner_unreachable: orchestration session {} disappeared before the resumed public turn could detach cleanly", - orchestration_session_id - ); - }; - if record.session.state.is_terminal() - || record.session.posture == OrchestrationSessionPosture::Terminal - { - anyhow::bail!( - "owner_unreachable: orchestration session {} became terminal before resumed participant {} could repark", - orchestration_session_id, - resumed_participant_id - ); + if reconcile_resumed_public_turn_detach_timeout( + store, + orchestration_session_id, + resumed_participant_id, + )? && resumed_public_turn_detach_stable_after_grace( + store, + orchestration_session_id, + resumed_participant_id, + backend_id, + )? { + return Ok(()); } - if record - .session - .active_participant_id() - .is_some_and(|participant_id| participant_id != resumed_participant_id) - { - anyhow::bail!( - "owner_unreachable: orchestration session {} reassigned active ownership to {} before resumed participant {} could repark", - orchestration_session_id, - record.session.active_participant_id().unwrap_or_default(), - resumed_participant_id - ); + if resumed_public_turn_detach_ready( + store, + orchestration_session_id, + resumed_participant_id, + backend_id, + )? && resumed_public_turn_detach_stable_after_grace( + store, + orchestration_session_id, + resumed_participant_id, + backend_id, + )? { + return Ok(()); } + let obstruction = match store.load_session(orchestration_session_id)? { + None => { + Some(format!( + "orchestration session {} disappeared before the resumed public turn could detach cleanly", + orchestration_session_id + )) + } + Some(record) + if record.session.state.is_terminal() + || record.session.posture == OrchestrationSessionPosture::Terminal => + { + let participant_summary = record + .participants + .iter() + .find(|candidate| candidate.participant_id() == resumed_participant_id) + .map(|participant| { + format!( + "participant_state={:?}, participant_reason={}", + participant.handle.state, + participant + .internal + .termination_reason + .as_deref() + .unwrap_or("") + ) + }) + .unwrap_or_else(|| "participant_state=".to_string()); + Some(format!( + "orchestration session {} became terminal before resumed participant {} could repark (session_state={:?}, session_reason={}, {})", + orchestration_session_id, + resumed_participant_id, + record.session.state, + record + .session + .invalidation_reason + .as_deref() + .unwrap_or(""), + participant_summary + )) + } + Some(record) + if record + .session + .active_participant_id() + .is_some_and(|participant_id| participant_id != resumed_participant_id) => + { + Some(format!( + "orchestration session {} reassigned active ownership to {} before resumed participant {} could repark", + orchestration_session_id, + record.session.active_participant_id().unwrap_or_default(), + resumed_participant_id + )) + } + Some(_) => None, + }; + if started_at.elapsed() >= TURN_DETACH_NORMALIZATION_TIMEOUT { + if let Some(obstruction) = obstruction { + anyhow::bail!( + "owner_unreachable: timed out waiting for orchestration session {} resumed participant {} to converge back to detached durable continuity; last observed obstruction: {}", + orchestration_session_id, + resumed_participant_id, + obstruction + ); + } anyhow::bail!( "owner_unreachable: timed out waiting for orchestration session {} resumed participant {} to converge back to detached durable continuity", orchestration_session_id, @@ -1074,27 +1447,57 @@ fn wait_for_resumed_public_turn_detach( } } -fn descriptor_from_participant( - participant: &AgentRuntimeParticipantRecord, -) -> Result { - let backend_kind = match participant.internal.resolved_agent_kind.as_str() { - "codex" => ResolvedRuntimeBackendKind::Codex, - "claude_code" => ResolvedRuntimeBackendKind::ClaudeCode, - other => { - anyhow::bail!(config_model::user_error(format!( - "unsupported_platform_or_posture: unsupported hidden owner-helper backend kind '{}'", - other - ))); - } +#[cfg(unix)] +fn resumed_public_turn_detach_ready( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + resumed_participant_id: &str, + backend_id: &str, +) -> Result { + Ok(store + .resumed_public_turn_detach_posture(orchestration_session_id, resumed_participant_id)? + .is_some() + || exact_detached_host_turn_target_ready(store, orchestration_session_id, backend_id)?) +} + +#[cfg(unix)] +fn resumed_public_turn_detach_stable_after_grace( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + resumed_participant_id: &str, + backend_id: &str, +) -> Result { + thread::sleep(START_ATTACHED_GRACE_TIMEOUT); + Ok(resumed_public_turn_detach_ready( + store, + orchestration_session_id, + resumed_participant_id, + backend_id, + )? && resumed_public_turn_runtime_released(store, resumed_participant_id)?) +} + +#[cfg(unix)] +fn exact_detached_host_turn_target_ready( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + backend_id: &str, +) -> Result { + match store.resolve_public_turn_target(orchestration_session_id, backend_id) { + Ok(target) => Ok(target.target_kind == PublicTurnTargetKind::Host + && target.session_posture == PublicSessionPosture::DetachedReattachable), + Err(_) => Ok(false), + } +} + +#[cfg(unix)] +fn resumed_public_turn_runtime_released( + store: &AgentRuntimeStateStore, + resumed_participant_id: &str, +) -> Result { + let Some(participant) = store.load_participant(resumed_participant_id)? else { + return Ok(false); }; - Ok(ResolvedRuntimeDescriptor { - agent_id: participant.handle.agent_id.clone(), - backend_id: participant.handle.backend_id.clone(), - backend_kind, - protocol: participant.handle.protocol.clone(), - execution_scope: participant.handle.execution.scope, - binary_path: participant.internal.resolved_binary_path.clone(), - }) + Ok(!participant.has_valid_ownership() && participant.internal.shell_owner_pid == 0) } fn wait_for_terminal_session_state( diff --git a/crates/shell/src/execution/mod.rs b/crates/shell/src/execution/mod.rs index 4ea8c7aaf..d786e08d1 100644 --- a/crates/shell/src/execution/mod.rs +++ b/crates/shell/src/execution/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod platform_world; mod policy_cmd; pub(crate) mod policy_model; pub(crate) mod policy_snapshot; +pub(crate) mod prompt_fulfillment; mod pty; pub mod repl_persistent_session; mod routing; diff --git a/crates/shell/src/execution/policy_model.rs b/crates/shell/src/execution/policy_model.rs index db064b4df..45e5b8e6f 100644 --- a/crates/shell/src/execution/policy_model.rs +++ b/crates/shell/src/execution/policy_model.rs @@ -1062,6 +1062,12 @@ fn validate_dotted_id_list(values: &[String], key: &str) -> Result<()> { Ok(()) } +pub(crate) fn apply_policy_patch(base: &Policy, patch: &PolicyPatch) -> Policy { + let mut effective = base.clone(); + apply_policy_patch_over(&mut effective, patch); + effective +} + fn apply_policy_patch_over(target: &mut Policy, patch: &PolicyPatch) { if let Some(v) = &patch.id { target.id = v.clone(); diff --git a/crates/shell/src/execution/prompt_fulfillment.rs b/crates/shell/src/execution/prompt_fulfillment.rs new file mode 100644 index 000000000..97c9e8b7d --- /dev/null +++ b/crates/shell/src/execution/prompt_fulfillment.rs @@ -0,0 +1,387 @@ +use std::{ + collections::BTreeMap, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +use agent_api::{ + AgentWrapperCompletion, AgentWrapperError, AgentWrapperEvent, AgentWrapperEventKind, + AgentWrapperKind, AgentWrapperRunHandle, AgentWrapperRunRequest, +}; +use anyhow::Context; +use futures::StreamExt; +use substrate_gateway::adapter_runtime::{GatewayAdapterBackendKind, GatewayAdapterRuntime}; + +use crate::execution::agent_runtime::{ + mapping::AgentRuntimeBackendKind, validator::RuntimeSelectionDescriptor, +}; + +const SESSION_HANDLE_SCHEMA_V1: &str = "agent_api.session.handle.v1"; + +#[derive(Clone)] +pub(crate) enum PromptFulfillmentCancelHandle { + Gateway { + handle: agent_api::AgentWrapperCancelHandle, + cancel_requested: Arc, + }, + Codex { + handle: codex::ExecTerminationHandle, + cancel_requested: Arc, + }, + Claude { + handle: claude_code::ClaudeTerminationHandle, + cancel_requested: Arc, + }, +} + +impl PromptFulfillmentCancelHandle { + pub(crate) fn cancel(&self) { + match self { + Self::Gateway { + handle, + cancel_requested, + } => { + cancel_requested.store(true, Ordering::SeqCst); + handle.cancel(); + } + Self::Codex { + handle, + cancel_requested, + } => { + cancel_requested.store(true, Ordering::SeqCst); + handle.request_termination(); + } + Self::Claude { + handle, + cancel_requested, + } => { + cancel_requested.store(true, Ordering::SeqCst); + handle.request_termination(); + } + } + } +} + +pub(crate) struct PromptFulfillmentRunControl { + pub(crate) handle: AgentWrapperRunHandle, + pub(crate) cancel: PromptFulfillmentCancelHandle, +} + +pub(crate) struct PromptFulfillmentBridge { + runtime: GatewayAdapterRuntime, + backend_kind: GatewayAdapterBackendKind, + binary_path: PathBuf, +} + +impl PromptFulfillmentBridge { + pub(crate) fn for_descriptor(descriptor: &RuntimeSelectionDescriptor) -> anyhow::Result { + let backend_kind = match descriptor.backend_kind { + AgentRuntimeBackendKind::Codex => GatewayAdapterBackendKind::Codex, + AgentRuntimeBackendKind::ClaudeCode => GatewayAdapterBackendKind::ClaudeCode, + }; + let runtime = + GatewayAdapterRuntime::for_backend(backend_kind, descriptor.binary_path.clone())?; + + Ok(Self { + runtime, + backend_kind, + binary_path: descriptor.binary_path.clone(), + }) + } + + pub(crate) async fn run_control( + &self, + request: AgentWrapperRunRequest, + ) -> Result { + let cancel_requested = Arc::new(AtomicBool::new(false)); + let control = self.runtime.run_control(request).await?; + Ok(PromptFulfillmentRunControl { + handle: control.handle, + cancel: PromptFulfillmentCancelHandle::Gateway { + handle: control.cancel, + cancel_requested, + }, + }) + } + + pub(crate) async fn run_attach_control( + &self, + session_id: &str, + ) -> Result { + match self.backend_kind { + GatewayAdapterBackendKind::Codex => self.run_codex_attach_control(session_id).await, + GatewayAdapterBackendKind::ClaudeCode => { + self.run_claude_attach_control(session_id).await + } + } + } + + async fn run_codex_attach_control( + &self, + session_id: &str, + ) -> Result { + let mut builder = codex::CodexClient::builder() + .json(true) + .mirror_stdout(false) + .quiet(true) + .color_mode(codex::ColorMode::Never) + .approval_policy(codex::ApprovalPolicy::Never) + .sandbox_mode(codex::SandboxMode::WorkspaceWrite) + .binary(self.binary_path.clone()); + if let Some(working_dir) = current_working_dir()? { + builder = builder.working_dir(working_dir); + } + + let client = builder.build(); + let control = client + .stream_resume_with_env_overrides_control( + codex::ResumeRequest::with_id(session_id), + &BTreeMap::new(), + ) + .await + .map_err(map_codex_stream_error)?; + let codex::ExecStreamControl { + events, + completion, + termination, + } = control; + let cancel_requested = Arc::new(AtomicBool::new(false)); + let completion_cancel_requested = Arc::clone(&cancel_requested); + + let events = Box::pin(events.map(|event| match event { + Ok(event) => map_codex_attach_event(event), + Err(err) => AgentWrapperEvent { + agent_kind: codex_kind(), + kind: AgentWrapperEventKind::Error, + channel: Some("error".to_string()), + text: None, + message: Some(format!("codex attach stream failed: {err}")), + data: None, + }, + })); + let completion = Box::pin(async move { + let completion = completion.await.map_err(|err| { + if completion_cancel_requested.load(Ordering::SeqCst) { + cancelled_wrapper_error() + } else { + map_codex_stream_error(err) + } + })?; + Ok(AgentWrapperCompletion { + status: completion.status, + final_text: completion.last_message, + data: None, + }) + }); + + Ok(PromptFulfillmentRunControl { + handle: AgentWrapperRunHandle { events, completion }, + cancel: PromptFulfillmentCancelHandle::Codex { + handle: termination, + cancel_requested, + }, + }) + } + + async fn run_claude_attach_control( + &self, + session_id: &str, + ) -> Result { + let mut builder = claude_code::ClaudeClient::builder() + .binary(&self.binary_path) + .mirror_stdout(false) + .mirror_stderr(false); + if let Some(working_dir) = current_working_dir()? { + builder = builder.working_dir(working_dir); + } + + let client = builder.build(); + let request = claude_code::ClaudePrintRequest::new("substrate-attach") + .no_prompt() + .output_format(claude_code::ClaudeOutputFormat::StreamJson) + .resume_value(session_id); + let control = client + .print_stream_json_control(request) + .await + .map_err(map_claude_error)?; + let claude_code::ClaudePrintStreamJsonControlHandle { + events, + completion, + termination, + } = control; + let cancel_requested = Arc::new(AtomicBool::new(false)); + let completion_cancel_requested = Arc::clone(&cancel_requested); + + let events = Box::pin(events.map(|event| match event { + Ok(event) => map_claude_attach_event(event), + Err(err) => AgentWrapperEvent { + agent_kind: claude_kind(), + kind: AgentWrapperEventKind::Error, + channel: Some("error".to_string()), + text: None, + message: Some(format!("claude_code attach stream parse failed: {err}")), + data: None, + }, + })); + let completion = Box::pin(async move { + let status = completion.await.map_err(|err| { + if completion_cancel_requested.load(Ordering::SeqCst) { + cancelled_wrapper_error() + } else { + map_claude_error(err) + } + })?; + Ok(AgentWrapperCompletion { + status, + final_text: None, + data: None, + }) + }); + + Ok(PromptFulfillmentRunControl { + handle: AgentWrapperRunHandle { events, completion }, + cancel: PromptFulfillmentCancelHandle::Claude { + handle: termination, + cancel_requested, + }, + }) + } +} + +fn current_working_dir() -> Result, AgentWrapperError> { + std::env::current_dir() + .map(Some) + .with_context(|| "resolve current working directory for prompt fulfillment") + .map_err(map_anyhow_error) +} + +fn codex_kind() -> AgentWrapperKind { + AgentWrapperKind::new("codex").expect("codex agent kind should be valid") +} + +fn claude_kind() -> AgentWrapperKind { + AgentWrapperKind::new("claude_code").expect("claude_code agent kind should be valid") +} + +fn map_codex_attach_event(event: codex::ThreadEvent) -> AgentWrapperEvent { + let raw = serde_json::to_value(&event).ok(); + let data = match event.thread_id() { + Some(thread_id) => session_handle_facet(thread_id, raw), + None => raw, + }; + let (kind, channel, message) = match &event { + codex::ThreadEvent::Error(err) => ( + AgentWrapperEventKind::Error, + Some("error".to_string()), + Some(err.message.clone()), + ), + codex::ThreadEvent::TurnFailed(_) => ( + AgentWrapperEventKind::Status, + Some("status".to_string()), + Some("turn failed".to_string()), + ), + _ => ( + AgentWrapperEventKind::Status, + Some("status".to_string()), + None, + ), + }; + + AgentWrapperEvent { + agent_kind: codex_kind(), + kind, + channel, + text: None, + message, + data, + } +} + +fn map_claude_attach_event(event: claude_code::ClaudeStreamJsonEvent) -> AgentWrapperEvent { + let raw = event.clone().into_raw(); + let data = match event.session_id() { + Some(session_id) => session_handle_facet(session_id, Some(raw)), + None => Some(raw), + }; + let (kind, channel, message) = match &event { + claude_code::ClaudeStreamJsonEvent::ResultError { .. } => ( + AgentWrapperEventKind::Error, + Some("error".to_string()), + Some("result error".to_string()), + ), + claude_code::ClaudeStreamJsonEvent::SystemInit { .. } => ( + AgentWrapperEventKind::Status, + Some("status".to_string()), + Some("system init".to_string()), + ), + claude_code::ClaudeStreamJsonEvent::SystemOther { subtype, .. } => ( + AgentWrapperEventKind::Status, + Some("status".to_string()), + Some(format!("system {subtype}")), + ), + claude_code::ClaudeStreamJsonEvent::ResultSuccess { .. } => ( + AgentWrapperEventKind::Status, + Some("status".to_string()), + Some("result success".to_string()), + ), + _ => ( + AgentWrapperEventKind::Status, + Some("status".to_string()), + None, + ), + }; + + AgentWrapperEvent { + agent_kind: claude_kind(), + kind, + channel, + text: None, + message, + data, + } +} + +fn session_handle_facet( + session_id: &str, + raw_event: Option, +) -> Option { + if session_id.trim().is_empty() { + return raw_event; + } + + let mut value = serde_json::json!({ + "schema": SESSION_HANDLE_SCHEMA_V1, + "session": { "id": session_id }, + }); + if let Some(raw_event) = raw_event { + value["raw_event"] = raw_event; + } + Some(value) +} + +fn map_anyhow_error(err: anyhow::Error) -> AgentWrapperError { + AgentWrapperError::Backend { + message: format!("substrate-gateway adapter runtime: {err}"), + } +} + +fn cancelled_wrapper_error() -> AgentWrapperError { + AgentWrapperError::Backend { + message: "cancelled".to_string(), + } +} + +fn map_codex_stream_error(err: codex::ExecStreamError) -> AgentWrapperError { + AgentWrapperError::Backend { + message: format!("codex attach failed: {err}"), + } +} + +fn map_claude_error(err: claude_code::ClaudeCodeError) -> AgentWrapperError { + AgentWrapperError::Backend { + message: format!("claude_code attach failed: {err}"), + } +} diff --git a/crates/shell/src/repl/async_repl.rs b/crates/shell/src/repl/async_repl.rs index f62b9159f..6dc2bb115 100644 --- a/crates/shell/src/repl/async_repl.rs +++ b/crates/shell/src/repl/async_repl.rs @@ -45,16 +45,16 @@ use crate::execution::agent_runtime::control::{ PublicPromptEnvelope, PublicSessionPosture, ResolvedRuntimeDescriptor, SubmittedPromptStreamEvent, AGENT_API_SESSION_RESUME_V1, AGENT_API_TURN_LIFECYCLE_V1, }; -#[cfg(any(target_os = "linux", target_os = "macos"))] use crate::execution::agent_runtime::mapping::AgentRuntimeBackendKind; use crate::execution::agent_runtime::orchestration_session::{ - OrchestrationSessionPosture, StartupPromptStreamState, + HostAttachContract, OrchestrationSessionPosture, StartupPromptStreamState, }; use crate::execution::agent_runtime::session::AgentRuntimeReplacementParticipantInit; use crate::execution::agent_runtime::state_store::valid_detached_host_continuity_posture; use crate::execution::agent_runtime::validator::RuntimeSelectionDescriptor; use crate::execution::agent_runtime::validator::{ - exact_backend_selection_error_exit_code, validate_exact_backend_selection, + exact_backend_selection_error_exit_code, materialize_runtime_descriptor, + validate_exact_backend_selection, }; #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::execution::agent_runtime::validator::{ @@ -63,14 +63,21 @@ use crate::execution::agent_runtime::validator::{ #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::execution::agent_runtime::AgentRuntimeParticipantWorldBinding; use crate::execution::agent_runtime::{ - backend_allowed, build_gateway_for_descriptor, runtime_realizability_error_exit_code, - validate_orchestrator_selection, validate_runtime_realizability, AgentRuntimeParticipantRecord, - AgentRuntimeSessionManifest, AgentRuntimeSessionState, AgentRuntimeStateStore, - OrchestrationSessionRecord, OrchestrationSessionState, MEMBER_ROLE, ORCHESTRATOR_ROLE, - PURE_AGENT_PROTOCOL, SESSION_HANDLE_SCHEMA_V1, + backend_allowed, build_gateway_for_descriptor, resolve_inventory_contract_for_exact_backend, + runtime_realizability_error_exit_code, validate_orchestrator_selection, + AgentRuntimeParticipantRecord, AgentRuntimeSessionManifest, AgentRuntimeSessionState, + AgentRuntimeStateStore, AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, + DispatchCallerKind, DispatchCapabilityOverrideSet, DispatchRequestEnvelope, + HostExecutionClientStart, OrchestrationSessionRecord, OrchestrationSessionState, + ResolvedLaunchContract, MEMBER_ROLE, ORCHESTRATOR_ROLE, PURE_AGENT_PROTOCOL, + SESSION_HANDLE_SCHEMA_V1, }; +use crate::execution::config_model::AgentExecutionScope; #[cfg(unix)] use crate::execution::get_terminal_size; +use crate::execution::prompt_fulfillment::{ + PromptFulfillmentBridge, PromptFulfillmentCancelHandle, +}; use crate::execution::ReplSessionTelemetry; use crate::execution::WorldRootSettings; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1621,7 +1628,8 @@ struct TargetedTurn<'a> { enum TargetedTurnRoute { Host, - World(RuntimeSelectionDescriptor), + #[cfg(any(target_os = "linux", target_os = "macos"))] + World(MemberDispatchParitySubset), } enum TargetedTurnDispatchStatus { @@ -1669,20 +1677,83 @@ impl RuntimeOrchestrationContext { struct PreparedAgentRuntime { descriptor: RuntimeSelectionDescriptor, - gateway: agent_api::AgentWrapperGateway, - agent_kind: agent_api::AgentWrapperKind, + member_dispatch_parity: Option, + prompt_fulfillment: Option, startup_context: RuntimeOrchestrationContext, manifest: Arc>, run_id: String, startup_extensions: BTreeMap, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct MemberDispatchParitySubset { + agent_id: String, + backend_id: String, + backend_kind: AgentRuntimeBackendKind, + protocol: String, + execution_scope: crate::execution::config_model::AgentExecutionScope, + binary_path: PathBuf, +} + +impl MemberDispatchParitySubset { + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn from_descriptor(descriptor: &RuntimeSelectionDescriptor) -> Self { + Self { + agent_id: descriptor.agent_id.clone(), + backend_id: descriptor.backend_id.clone(), + backend_kind: descriptor.backend_kind, + protocol: descriptor.protocol.clone(), + execution_scope: descriptor.execution_scope, + binary_path: descriptor.binary_path.clone(), + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn from_participant( + participant: &AgentRuntimeParticipantRecord, + ) -> std::result::Result { + let backend_kind = match participant.internal.resolved_agent_kind.as_str() { + "codex" => AgentRuntimeBackendKind::Codex, + "claude_code" => AgentRuntimeBackendKind::ClaudeCode, + other => { + return Err(RuntimeBootstrapFailure { + exit_code: 1, + message: format!( + "retained world member has unsupported resolved_agent_kind '{}'", + other + ), + }); + } + }; + Ok(Self { + agent_id: participant.handle.agent_id.clone(), + backend_id: participant.handle.backend_id.clone(), + backend_kind, + protocol: participant.handle.protocol.clone(), + execution_scope: participant.handle.execution.scope, + binary_path: PathBuf::from(&participant.internal.resolved_binary_path), + }) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn to_runtime_selection_descriptor(&self) -> RuntimeSelectionDescriptor { + RuntimeSelectionDescriptor { + agent_id: self.agent_id.clone(), + backend_id: self.backend_id.clone(), + backend_kind: self.backend_kind, + protocol: self.protocol.clone(), + execution_scope: self.execution_scope, + binary_path: self.binary_path.clone(), + } + } +} + struct ResolvedHostOrchestratorBootstrap { cwd: PathBuf, shell_session_id: String, + resolved_contract: ResolvedLaunchContract, descriptor: RuntimeSelectionDescriptor, - gateway: agent_api::AgentWrapperGateway, - agent_kind: agent_api::AgentWrapperKind, + prompt_fulfillment: PromptFulfillmentBridge, state_store: AgentRuntimeStateStore, effective_config: crate::execution::config_model::SubstrateConfig, #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1738,7 +1809,7 @@ impl StartupPromptBackchannel { // long-lived tasks that own the non-clonable `run_control.handle` facets. A manifest // may only advertise a live orchestrator session while all three remain retained. struct LocalRetainedRunControl { - cancel: agent_api::AgentWrapperCancelHandle, + cancel: PromptFulfillmentCancelHandle, event_task: Option>, completion_task: Option>, } @@ -1757,6 +1828,7 @@ enum RetainedRunControl { } const LOCAL_RETAINED_STOP_COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); +const LOCAL_RETAINED_AUTO_PARK_GRACE_TIMEOUT: Duration = Duration::from_millis(250); struct AsyncReplAgentRuntime { descriptor: RuntimeSelectionDescriptor, @@ -1795,14 +1867,6 @@ fn runtime_task_start_message(role: &str) -> &'static str { } } -fn runtime_bootstrap_prompt(role: &str) -> &'static str { - if role == MEMBER_ROLE { - "Enter persistent Substrate world-scoped member mode. Keep this control session attached for the lifetime of the parent REPL session and do not exit until the client cancels the run." - } else { - "Enter persistent Substrate host orchestrator mode. Keep this control session attached for the lifetime of the host REPL and do not exit until the client cancels the run." - } -} - fn runtime_ready_message(role: &str) -> &'static str { if role == MEMBER_ROLE { "world-scoped member session is ready via retained attached control ownership" @@ -2091,7 +2155,19 @@ async fn start_host_orchestrator_runtime( telemetry: &mut ReplSessionTelemetry, ) -> std::result::Result, RuntimeBootstrapFailure> { let prepared = prepare_host_orchestrator_runtime_startup(config)?; - start_host_orchestrator_runtime_with_prepared(prepared, None, agent_printer, telemetry).await + start_host_orchestrator_runtime_with_prepared_prompt( + prepared, + None, + Some(InitialExecPromptPlan::Replace( + "test retained host startup".to_string(), + )), + false, + false, + false, + agent_printer, + telemetry, + ) + .await } fn prepare_host_orchestrator_runtime_startup( @@ -2111,9 +2187,9 @@ fn prepare_host_orchestrator_runtime_from_resolved( let ResolvedHostOrchestratorBootstrap { cwd, shell_session_id, + resolved_contract, descriptor, - gateway, - agent_kind, + prompt_fulfillment, state_store, effective_config, #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -2137,6 +2213,7 @@ fn prepare_host_orchestrator_runtime_from_resolved( shell_session_id, cwd.display().to_string(), &manifest, + HostAttachContract::from_resolved_contract(&resolved_contract, None), ))); state_store .persist_orchestration_session( @@ -2151,8 +2228,8 @@ fn prepare_host_orchestrator_runtime_from_resolved( Ok(PreparedAgentRuntime { descriptor, - gateway, - agent_kind, + member_dispatch_parity: None, + prompt_fulfillment: Some(prompt_fulfillment), startup_context: RuntimeOrchestrationContext { store: state_store, orchestration_session, @@ -2219,34 +2296,61 @@ fn resolve_host_orchestrator_bootstrap( }); } - let descriptor = - validate_runtime_realizability(orchestrator, &effective_config).map_err(|err| { - RuntimeBootstrapFailure { - exit_code: runtime_realizability_error_exit_code(&err), - message: err.reason, - } - })?; + let envelope = DispatchRequestEnvelope { + caller_kind: DispatchCallerKind::HumanStart, + baseline_kind: DispatchBaselineKind::InventoryLaunch, + backend_id: Some(orchestrator.derived_backend_id()), + orchestration_session_id: None, + requested_execution_scope_override: None, + capability_overrides: DispatchCapabilityOverrideSet::default(), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: HostExecutionClientStart::StartNow, + attach_mode_preference: AttachModePreference::ContinuityRequired, + }, + has_prompt_payload: false, + }; + let resolved_contract = resolve_inventory_contract_for_exact_backend( + &cwd, + &effective_config, + &inventory, + &base_policy, + &envelope, + AgentExecutionScope::Host, + ) + .map_err(|err| RuntimeBootstrapFailure { + exit_code: 2, + message: format!("runtime_start_failed: {err}"), + })? + .ok_or_else(|| RuntimeBootstrapFailure { + exit_code: 2, + message: format!( + "runtime_start_failed: baseline truth rejected field 'backend_id': no exact host-scoped backend match found for '{}'", + orchestrator.derived_backend_id() + ), + })?; + let descriptor = materialize_runtime_descriptor(&resolved_contract).map_err(|err| { + RuntimeBootstrapFailure { + exit_code: runtime_realizability_error_exit_code(&err), + message: err.reason, + } + })?; let state_store = AgentRuntimeStateStore::new().map_err(|err| RuntimeBootstrapFailure { exit_code: 1, message: format!("failed to initialize shell runtime state store: {err:#}"), })?; - let gateway = + let prompt_fulfillment = build_gateway_for_descriptor(&descriptor).map_err(|err| RuntimeBootstrapFailure { exit_code: 1, message: format!("failed to build shell-owned UAA runtime registry: {err:#}"), })?; - let agent_kind = agent_api::AgentWrapperKind::new(descriptor.backend_kind.as_agent_kind_str()) - .map_err(|err| RuntimeBootstrapFailure { - exit_code: 2, - message: format!("failed to resolve runtime backend kind: {err}"), - })?; Ok(Some(ResolvedHostOrchestratorBootstrap { cwd, shell_session_id: config.session_id.clone(), + resolved_contract, descriptor, - gateway, - agent_kind, + prompt_fulfillment, state_store, effective_config, #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -2312,7 +2416,7 @@ fn owner_helper_startup_extensions( ) -> std::result::Result, RuntimeBootstrapFailure> { match plan.mode { OwnerHelperMode::Start => Ok(BTreeMap::new()), - OwnerHelperMode::Resume | OwnerHelperMode::ResumeOneTurn | OwnerHelperMode::Fork => { + OwnerHelperMode::Attach | OwnerHelperMode::ResumeOneTurn => { let session_id = plan .participant .internal_uaa_session_id @@ -2345,7 +2449,7 @@ fn owner_helper_manifest( participant_id, lease_token, ), - OwnerHelperMode::Resume | OwnerHelperMode::ResumeOneTurn | OwnerHelperMode::Fork => { + OwnerHelperMode::Attach | OwnerHelperMode::ResumeOneTurn => { let resumed_from_participant_id = plan .participant .resumed_from_participant_id @@ -2391,6 +2495,7 @@ fn owner_helper_orchestration_session( plan.session.shell_trace_session_id.clone(), plan.session.workspace_root.clone(), manifest, + plan.host_attach_contract.clone(), ); session.latest_run_id = Some(plan.participant.run_id.clone()); session.active_session_handle_id = None; @@ -2420,9 +2525,9 @@ fn prepare_hidden_owner_helper_runtime( let ResolvedHostOrchestratorBootstrap { cwd: _cwd, shell_session_id: _shell_session_id, + resolved_contract: _resolved_contract, descriptor: validated_descriptor, - gateway: _validated_gateway, - agent_kind: _validated_agent_kind, + prompt_fulfillment: _validated_prompt_fulfillment, state_store, effective_config, #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -2455,20 +2560,15 @@ fn prepare_hidden_owner_helper_runtime( message: format!("failed to persist hidden owner-helper session record: {err:#}"), })?; - let gateway = + let prompt_fulfillment = build_gateway_for_descriptor(&descriptor).map_err(|err| RuntimeBootstrapFailure { exit_code: 1, message: format!("failed to build hidden owner-helper gateway: {err:#}"), })?; - let agent_kind = agent_api::AgentWrapperKind::new(descriptor.backend_kind.as_agent_kind_str()) - .map_err(|err| RuntimeBootstrapFailure { - exit_code: 2, - message: format!("failed to resolve hidden owner-helper backend kind: {err}"), - })?; Ok(PreparedAgentRuntime { descriptor, - gateway, - agent_kind, + member_dispatch_parity: None, + prompt_fulfillment: Some(prompt_fulfillment), startup_context: RuntimeOrchestrationContext { store: state_store, orchestration_session, @@ -2689,7 +2789,40 @@ async fn wait_for_hidden_owner_helper_local_runtime( let outcome = tokio::select! { result = &mut completion_task => { join_failed |= result.is_err(); - HiddenOwnerHelperLocalRuntimeOutcome::Joined { join_failed } + if !join_failed + && completed_hidden_owner_helper_should_auto_park(auto_park_rx).await + { + shutdown_host_orchestrator_runtime_with_mode( + AsyncReplAgentRuntime { + descriptor, + orchestration_session: Arc::clone(orchestration_session), + manifest: Arc::clone(manifest), + store: store.clone(), + uaa_session_handle_id: uaa_session_handle_id.to_string(), + retained_control: RetainedRunControl::Local(LocalRetainedRunControl { + cancel: retained_control.cancel.clone(), + event_task: retained_control.event_task.take(), + completion_task: None, + }), + shutdown_requested: Arc::clone(shutdown_requested), + auto_park_rx: auto_park_rx.take(), + private_stop_rx: private_stop_rx.take(), + stop_transport: stop_transport.take(), + stop_owner_task: stop_owner_task.take(), + prompt_transport: prompt_transport.take(), + prompt_owner_task: prompt_owner_task.take(), + heartbeat_stop_tx: heartbeat_stop_tx.take(), + heartbeat_task: heartbeat_task.take(), + }, + HostRuntimeShutdownMode::ParkIfResumable, + agent_printer, + telemetry, + ) + .await; + HiddenOwnerHelperLocalRuntimeOutcome::AutoParked + } else { + HiddenOwnerHelperLocalRuntimeOutcome::Joined { join_failed } + } } maybe_request = async { match private_stop_rx.as_mut() { @@ -2829,6 +2962,25 @@ async fn wait_for_hidden_owner_helper_local_runtime( outcome } +async fn completed_hidden_owner_helper_should_auto_park( + auto_park_rx: &mut Option>, +) -> bool { + let Some(requests) = auto_park_rx.as_mut() else { + return false; + }; + + match requests.try_recv() { + Ok(_) => true, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + matches!( + tokio::time::timeout(LOCAL_RETAINED_AUTO_PARK_GRACE_TIMEOUT, requests.recv()).await, + Ok(Some(_)) + ) + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => false, + } +} + fn persist_hidden_owner_helper_stop_failure( store: &AgentRuntimeStateStore, orchestration_session: &Arc>, @@ -2889,6 +3041,7 @@ pub(crate) fn run_hidden_owner_helper(plan: HiddenOwnerHelperLaunchPlan) -> Resu Some(prepared), None, initial_prompt, + matches!(plan.mode, OwnerHelperMode::Attach), true, matches!(plan.mode, OwnerHelperMode::ResumeOneTurn), &ReplPrinter::Stdout, @@ -2915,16 +3068,19 @@ async fn start_host_orchestrator_runtime_with_prepared( None, false, false, + false, agent_printer, telemetry, ) .await } +#[allow(clippy::too_many_arguments)] async fn start_host_orchestrator_runtime_with_prepared_prompt( prepared: Option, initial_world_binding: Option<&PersistedWorldBinding>, initial_prompt: Option, + control_only_attach: bool, runtime_owns_private_stop: bool, auto_park_after_public_turn: bool, agent_printer: &ReplPrinter, @@ -2935,13 +3091,19 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( }; let PreparedAgentRuntime { descriptor, - gateway, - agent_kind, + member_dispatch_parity: _member_dispatch_parity, + prompt_fulfillment, startup_context, manifest, run_id, startup_extensions, } = prepared; + let Some(prompt_fulfillment) = prompt_fulfillment else { + return Err(RuntimeBootstrapFailure { + exit_code: 1, + message: "missing host prompt-fulfillment bridge for retained startup".to_string(), + }); + }; let runtime_role = { manifest .lock() @@ -3013,17 +3175,6 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( agent_printer, ); - let request = agent_api::AgentWrapperRunRequest { - prompt: match initial_prompt.as_ref() { - Some(InitialExecPromptPlan::Replace(prompt)) => prompt.clone(), - Some(InitialExecPromptPlan::StartupPrompt { prompt, .. }) => prompt.clone(), - None => runtime_bootstrap_prompt(&runtime_role).to_string(), - }, - working_dir: Some(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))), - timeout: None, - env: BTreeMap::new(), - extensions: startup_extensions, - }; let startup_backchannel = match initial_prompt.as_ref() { Some(InitialExecPromptPlan::StartupPrompt { stream_path, .. }) => { Some(connect_startup_prompt_backchannel(stream_path).await?) @@ -3059,7 +3210,47 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( scope: "host".to_string(), }); } - let control = match gateway.run_control(&agent_kind, request).await { + let control = match if control_only_attach { + let continuity_session_id = startup_extensions + .get(AGENT_API_SESSION_RESUME_V1) + .and_then(|value| value.as_object()) + .filter(|value| value.get("selector").and_then(serde_json::Value::as_str) == Some("id")) + .and_then(|value| value.get("id")) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| RuntimeBootstrapFailure { + exit_code: 1, + message: format!( + "missing {} id selector for control-only attach startup: {}", + AGENT_API_SESSION_RESUME_V1, runtime_role + ), + })?; + prompt_fulfillment + .run_attach_control(&continuity_session_id) + .await + } else { + let request = agent_api::AgentWrapperRunRequest { + prompt: match initial_prompt.as_ref() { + Some(InitialExecPromptPlan::Replace(prompt)) => prompt.clone(), + Some(InitialExecPromptPlan::StartupPrompt { prompt, .. }) => prompt.clone(), + None => { + return Err(RuntimeBootstrapFailure { + exit_code: 1, + message: format!( + "missing explicit initial prompt plan for retained {} startup", + runtime_role + ), + }); + } + }, + working_dir: Some(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))), + timeout: None, + env: BTreeMap::new(), + extensions: startup_extensions, + }; + prompt_fulfillment.run_control(request).await + } { Ok(control) => control, Err(err) => { let failure = runtime_bootstrap_failure_from_wrapper_error(err); @@ -3092,11 +3283,17 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( return Err(failure); } }; - let agent_api::AgentWrapperRunControl { handle, cancel } = control; - let agent_api::AgentWrapperRunHandle { events, completion } = handle; + let agent_api::AgentWrapperRunHandle { events, completion } = control.handle; + let cancel = control.cancel; let shutdown_requested = Arc::new(AtomicBool::new(false)); let (startup_tx, startup_rx) = tokio::sync::oneshot::channel::(); let startup_signal = Arc::new(Mutex::new(Some(startup_tx))); + let (auto_park_tx, auto_park_rx) = if auto_park_after_public_turn { + let (tx, rx) = mpsc::unbounded_channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; let agent_id = { manifest .lock() @@ -3113,6 +3310,7 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( let startup_backchannel_for_events = startup_backchannel.clone(); let shutdown_for_events = Arc::clone(&shutdown_requested); let runtime_role_for_events = runtime_role.clone(); + let auto_park_tx_for_events = auto_park_tx.clone(); let mut events = events; let event_task = tokio::spawn(async move { let controls_parent_session = runtime_controls_parent_session(&runtime_role_for_events); @@ -3228,6 +3426,11 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( } } } + if matches!(startup_turn_phase.as_deref(), Some("completed" | "failed")) { + if let Some(park_after_turn_tx) = auto_park_tx_for_events.as_ref() { + let _ = park_after_turn_tx.send(()); + } + } let _ = publish_agent_event(event); if startup_became_live { @@ -3679,6 +3882,52 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( exit_code: 1, message: format!("failed to persist retained runtime ownership: {err:#}"), })?; + let reconciled_ready_after_early_handle = { + let mut orchestration_guard = startup_context + .orchestration_session + .lock() + .expect("orchestration session mutex poisoned"); + let mut manifest_guard = manifest.lock().expect("runtime manifest mutex poisoned"); + if manifest_guard.handle.state == AgentRuntimeSessionState::Allocating + && manifest_guard.can_advertise_live() + { + manifest_guard.transition_state(AgentRuntimeSessionState::Ready); + manifest_guard.touch_heartbeat(); + if controls_parent_session { + orchestration_guard + .bind_active_session_handle(manifest_guard.handle.participant_id.clone()); + orchestration_guard.transition_state(OrchestrationSessionState::Active); + } else if orchestration_guard.state == OrchestrationSessionState::Active { + orchestration_guard.touch_active(); + } + Some((orchestration_guard.clone(), manifest_guard.clone())) + } else { + None + } + }; + if let Some((orchestration_snapshot, manifest_snapshot)) = reconciled_ready_after_early_handle { + persist_runtime_snapshots( + &startup_context.store, + &orchestration_snapshot, + &manifest_snapshot, + ) + .map_err(|err| RuntimeBootstrapFailure { + exit_code: 1, + message: format!("failed to persist reconciled runtime ownership: {err:#}"), + })?; + emit_runtime_event( + build_runtime_message_event( + &manifest_snapshot, + &orchestration_snapshot, + run_id.clone(), + MessageEventKind::Status, + runtime_ready_message(&runtime_role), + ), + telemetry, + agent_printer, + ); + signal_runtime_startup(&startup_signal, RuntimeStartupSignal::Running); + } let mut retained_control = LocalRetainedRunControl { cancel, event_task: Some(event_task), @@ -3830,12 +4079,6 @@ async fn start_host_orchestrator_runtime_with_prepared_prompt( ) }; let (prompt_tx, prompt_rx) = private_prompt_request_channel(); - let (auto_park_tx, auto_park_rx) = if auto_park_after_public_turn { - let (tx, rx) = mpsc::unbounded_channel(); - (Some(tx), Some(rx)) - } else { - (None, None) - }; let prompt_transport = match register_private_prompt_transport( &startup_context.store, &stop_orchestration_session_id, @@ -3959,6 +4202,7 @@ fn select_exact_runtime_descriptor( }) } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn select_member_runtime_descriptor_for_backend( startup_context: &RuntimeOrchestrationContext, backend_id: &str, @@ -3981,6 +4225,47 @@ fn select_host_runtime_descriptor_for_backend( ) } +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn retained_member_dispatch_parity_subset( + startup_context: Option<&RuntimeOrchestrationContext>, + member_runtimes: &RetainedMemberRuntimeMap, + pending_member_replacements: &PendingMemberReplacementMap, + backend_id: &str, +) -> std::result::Result, RuntimeBootstrapFailure> { + if let Some(runtime) = member_runtimes.get(backend_id) { + let manifest_snapshot = runtime_manifest_snapshot(runtime); + if manifest_snapshot.handle.role == MEMBER_ROLE + && manifest_snapshot.handle.backend_id == backend_id + && manifest_snapshot.handle.execution.scope + == crate::execution::config_model::AgentExecutionScope::World + { + return MemberDispatchParitySubset::from_participant(&manifest_snapshot).map(Some); + } + } + + let Some(startup_context) = startup_context else { + return Ok(None); + }; + let orchestration_session_id = startup_context.orchestration_session_id(); + + if let Some(participant) = pending_member_replacements + .get(backend_id) + .filter(|participant| { + participant.handle.orchestration_session_id == orchestration_session_id + && participant.handle.backend_id == backend_id + && participant.handle.role == MEMBER_ROLE + && participant.handle.execution.scope + == crate::execution::config_model::AgentExecutionScope::World + }) + { + return MemberDispatchParitySubset::from_participant(participant).map(Some); + } + + select_member_runtime_descriptor_for_backend(startup_context, backend_id).map(|descriptor| { + descriptor.map(|descriptor| MemberDispatchParitySubset::from_descriptor(&descriptor)) + }) +} + fn active_orchestrator_backend_id(runtime: &AsyncReplAgentRuntime) -> String { runtime_backend_id(runtime) } @@ -3989,6 +4274,10 @@ fn resolve_targeted_turn_route( startup_context: Option<&RuntimeOrchestrationContext>, dormant_host_bootstrap: Option<&ResolvedHostOrchestratorBootstrap>, agent_runtime: Option<&AsyncReplAgentRuntime>, + #[cfg(any(target_os = "linux", target_os = "macos"))] + member_runtimes: &RetainedMemberRuntimeMap, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pending_member_replacements: &PendingMemberReplacementMap, backend_id: &str, ) -> std::result::Result { if let Some(runtime) = agent_runtime { @@ -4021,10 +4310,14 @@ fn resolve_targeted_turn_route( }); } - if let Some(descriptor) = - select_member_runtime_descriptor_for_backend(startup_context, backend_id)? - { - return Ok(TargetedTurnRoute::World(descriptor)); + #[cfg(any(target_os = "linux", target_os = "macos"))] + if let Some(parity) = retained_member_dispatch_parity_subset( + Some(startup_context), + member_runtimes, + pending_member_replacements, + backend_id, + )? { + return Ok(TargetedTurnRoute::World(parity)); } } @@ -4048,11 +4341,17 @@ async fn dispatch_targeted_follow_up_turn( agent_printer, telemetry, } = context; + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = (&member_runtimes, &pending_member_replacements); let route = match resolve_targeted_turn_route( startup_context.as_ref(), dormant_host_bootstrap.as_ref(), agent_runtime.as_ref(), + #[cfg(any(target_os = "linux", target_os = "macos"))] + member_runtimes, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pending_member_replacements, targeted_turn.backend_id, ) { Ok(route) => route, @@ -4083,6 +4382,7 @@ async fn dispatch_targeted_follow_up_turn( )), false, false, + false, agent_printer, telemetry, ) @@ -4116,7 +4416,8 @@ async fn dispatch_targeted_follow_up_turn( .await?; } } - TargetedTurnRoute::World(descriptor) => { + #[cfg(any(target_os = "linux", target_os = "macos"))] + TargetedTurnRoute::World(parity) => { ensure_no_policy_drift( world_session, startup_context.as_ref(), @@ -4139,7 +4440,7 @@ async fn dispatch_targeted_follow_up_turn( agent_printer, telemetry, }, - &descriptor, + &parity, Some(targeted_turn.prompt), member_runtimes, pending_member_replacements, @@ -4356,22 +4657,12 @@ fn prepare_member_runtime_startup_for_descriptor( })?; let mut manifest = manifest; manifest.internal.latest_run_id = Some(run_id.clone()); - - let gateway = - build_gateway_for_descriptor(&descriptor).map_err(|err| RuntimeBootstrapFailure { - exit_code: 1, - message: format!("failed to build world-scoped member UAA runtime registry: {err:#}"), - })?; - let agent_kind = agent_api::AgentWrapperKind::new(descriptor.backend_kind.as_agent_kind_str()) - .map_err(|err| RuntimeBootstrapFailure { - exit_code: 2, - message: format!("failed to resolve member runtime backend kind: {err}"), - })?; + let member_dispatch_parity = MemberDispatchParitySubset::from_descriptor(&descriptor); Ok(PreparedAgentRuntime { descriptor, - gateway, - agent_kind, + member_dispatch_parity: Some(member_dispatch_parity), + prompt_fulfillment: None, startup_context: startup_context.clone(), manifest: Arc::new(Mutex::new(manifest)), run_id, @@ -4381,18 +4672,45 @@ fn prepare_member_runtime_startup_for_descriptor( #[cfg(all(test, unix))] async fn start_member_runtime_with_prepared( - prepared: Option, + mut prepared: Option, agent_printer: &ReplPrinter, telemetry: &mut ReplSessionTelemetry, ) -> std::result::Result, RuntimeBootstrapFailure> { - start_host_orchestrator_runtime_with_prepared(prepared, None, agent_printer, telemetry).await + if let Some(prepared) = prepared.as_mut() { + if prepared.prompt_fulfillment.is_none() { + prepared.prompt_fulfillment = Some( + crate::execution::prompt_fulfillment::PromptFulfillmentBridge::for_descriptor( + &prepared.descriptor, + ) + .map_err(|err| RuntimeBootstrapFailure { + exit_code: 1, + message: format!( + "failed to build test member prompt-fulfillment bridge: {err:#}" + ), + })?, + ); + } + } + start_host_orchestrator_runtime_with_prepared_prompt( + prepared, + None, + Some(InitialExecPromptPlan::Replace( + "test retained member startup".to_string(), + )), + false, + false, + false, + agent_printer, + telemetry, + ) + .await } #[cfg(any(target_os = "linux", target_os = "macos"))] fn member_runtime_backend_kind( - descriptor: &RuntimeSelectionDescriptor, + backend_kind: AgentRuntimeBackendKind, ) -> MemberRuntimeBackendKindV1 { - match descriptor.backend_kind { + match backend_kind { AgentRuntimeBackendKind::Codex => MemberRuntimeBackendKindV1::Codex, AgentRuntimeBackendKind::ClaudeCode => MemberRuntimeBackendKindV1::ClaudeCode, } @@ -4403,6 +4721,15 @@ fn build_member_dispatch_transport_request( prepared: &PreparedAgentRuntime, initial_prompt: Option, ) -> std::result::Result { + let parity = + prepared + .member_dispatch_parity + .as_ref() + .ok_or_else(|| RuntimeBootstrapFailure { + exit_code: 1, + message: "missing shared-contract-derived retained member parity subset" + .to_string(), + })?; let manifest = prepared .manifest .lock() @@ -4439,14 +4766,14 @@ fn build_member_dispatch_transport_request( orchestrator_participant_id, parent_participant_id: manifest.handle.parent_participant_id.clone(), resumed_from_participant_id: manifest.handle.resumed_from_participant_id.clone(), - backend_id: prepared.descriptor.backend_id.clone(), - protocol: prepared.descriptor.protocol.clone(), + backend_id: parity.backend_id.clone(), + protocol: parity.protocol.clone(), run_id: prepared.run_id.clone(), world_id, world_generation, initial_prompt, - backend_kind: member_runtime_backend_kind(&prepared.descriptor), - binary_path: prepared.descriptor.binary_path.display().to_string(), + backend_kind: member_runtime_backend_kind(parity.backend_kind), + binary_path: parity.binary_path.display().to_string(), }) } @@ -4523,8 +4850,8 @@ async fn start_remote_member_runtime_with_prepared( let transport_request = build_member_dispatch_transport_request(&prepared, initial_prompt)?; let PreparedAgentRuntime { descriptor, - gateway: _gateway, - agent_kind: _agent_kind, + member_dispatch_parity: _member_dispatch_parity, + prompt_fulfillment: _prompt_fulfillment, startup_context, manifest, run_id, @@ -5143,7 +5470,7 @@ struct EnsureMemberRuntimeReadyContext<'a> { #[cfg(any(target_os = "linux", target_os = "macos"))] async fn ensure_member_runtime_ready_for_descriptor( context: EnsureMemberRuntimeReadyContext<'_>, - descriptor: &RuntimeSelectionDescriptor, + parity: &MemberDispatchParitySubset, initial_prompt: Option<&str>, member_runtimes: &mut RetainedMemberRuntimeMap, pending_member_replacements: &mut PendingMemberReplacementMap, @@ -5160,11 +5487,12 @@ async fn ensure_member_runtime_ready_for_descriptor( let Some(world_session) = world_session else { return Ok(false); }; - ensure_member_backend_allowed(startup_context, descriptor) + let descriptor = parity.to_runtime_selection_descriptor(); + ensure_member_backend_allowed(startup_context, &descriptor) .map_err(|failure| anyhow!("substrate: error: {}", failure.message))?; let exact_live_member = - live_member_for_generation(startup_context, descriptor, world_session.world_generation) + live_member_for_generation(startup_context, &descriptor, world_session.world_generation) .map_err(|failure| anyhow!("substrate: error: {}", failure.message))?; if let Some(runtime) = member_runtimes.get(&descriptor.backend_id) { @@ -5187,16 +5515,17 @@ async fn ensure_member_runtime_ready_for_descriptor( shutdown_host_orchestrator_runtime(runtime, agent_printer, telemetry).await; } + let backend_id = descriptor.backend_id.clone(); let resumed_from = pending_member_replacements - .get(&descriptor.backend_id) + .get(&backend_id) .filter(|participant| { participant.handle.orchestration_session_id == startup_context.orchestration_session_id() - && participant.handle.backend_id == descriptor.backend_id + && participant.handle.backend_id == backend_id }); let prepared = prepare_member_runtime_startup_for_descriptor( startup_context, - descriptor.clone(), + descriptor, &PersistedWorldBinding { world_id: world_session.world_id.clone(), world_generation: world_session.world_generation, @@ -5217,47 +5546,13 @@ async fn ensure_member_runtime_ready_for_descriptor( Err(failure) => return Err(anyhow!("substrate: error: {}", failure.message)), }; if let Some(runtime) = runtime { - pending_member_replacements.remove(&descriptor.backend_id); + pending_member_replacements.remove(&backend_id); member_runtimes.insert(runtime_backend_id(&runtime), runtime); return Ok(true); } Ok(false) } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -struct EnsureMemberRuntimeReadyContext<'a> { - startup_context: Option<&'a RuntimeOrchestrationContext>, - world_session: Option<&'a WorldSession>, - agent_printer: &'a ReplPrinter, - telemetry: &'a mut ReplSessionTelemetry, -} - -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -async fn ensure_member_runtime_ready_for_descriptor( - context: EnsureMemberRuntimeReadyContext<'_>, - _descriptor: &RuntimeSelectionDescriptor, - _initial_prompt: Option<&str>, - _member_runtimes: &mut RetainedMemberRuntimeMap, - _pending_member_replacements: &mut PendingMemberReplacementMap, -) -> Result { - let EnsureMemberRuntimeReadyContext { - startup_context, - world_session, - agent_printer: _agent_printer, - telemetry: _telemetry, - } = context; - let Some(_startup_context) = startup_context else { - return Ok(false); - }; - let Some(_world_session) = world_session else { - return Ok(false); - }; - - Err(anyhow!( - "substrate: error: world-scoped member runtime dispatch is supported on Linux only" - )) -} - #[cfg(any(target_os = "linux", target_os = "macos"))] async fn ensure_member_runtime_ready( startup_context: Option<&RuntimeOrchestrationContext>, @@ -5275,6 +5570,7 @@ async fn ensure_member_runtime_ready( else { return Ok(()); }; + let parity = MemberDispatchParitySubset::from_descriptor(&descriptor); ensure_member_runtime_ready_for_descriptor( EnsureMemberRuntimeReadyContext { @@ -5283,7 +5579,7 @@ async fn ensure_member_runtime_ready( agent_printer, telemetry, }, - &descriptor, + &parity, None, member_runtimes, pending_member_replacements, @@ -5571,18 +5867,6 @@ async fn submit_world_targeted_turn( Ok(()) } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -async fn submit_world_targeted_turn( - _runtime: &mut AsyncReplAgentRuntime, - _prompt: &str, - _agent_printer: &ReplPrinter, - _telemetry: &mut ReplSessionTelemetry, -) -> Result<()> { - Err(anyhow!( - "substrate: error: world-targeted follow-up turns are supported on Linux and macOS only" - )) -} - async fn shutdown_host_orchestrator_runtime( runtime: AsyncReplAgentRuntime, agent_printer: &ReplPrinter, @@ -5908,11 +6192,17 @@ fn can_park_host_runtime_after_detach( return false; }; let participant_id = manifest.handle.participant_id.as_str(); - if record.session.state != OrchestrationSessionState::Active - || record.session.posture != OrchestrationSessionPosture::ActiveAttached - || record.session.active_participant_id() != Some(participant_id) - || record.session.attached_participant_id() != Some(participant_id) - { + let persisted_attached_parent = record.session.state == OrchestrationSessionState::Active + && record.session.posture == OrchestrationSessionPosture::ActiveAttached + && record.session.active_participant_id() == Some(participant_id) + && record.session.attached_participant_id() == Some(participant_id); + let in_memory_completed_one_turn_handoff = orchestration_session.startup_prompt_state() + == Some(StartupPromptStreamState::Completed) + && orchestration_session.state == OrchestrationSessionState::Active + && orchestration_session.posture == OrchestrationSessionPosture::ActiveAttached + && orchestration_session.active_participant_id() == Some(participant_id) + && orchestration_session.attached_participant_id() == Some(participant_id); + if !persisted_attached_parent && !in_memory_completed_one_turn_handoff { return false; } @@ -5955,6 +6245,7 @@ fn build_parked_host_runtime_snapshots( } } + parked_orchestration.sync_host_attach_contract(&parked_manifest); valid_detached_host_continuity_posture(&parked_orchestration, &parked_manifest, true)?; Some((parked_orchestration, parked_manifest)) } @@ -7658,8 +7949,14 @@ mod tests { acquire_event_test_guard, clear_agent_event_sender, init_event_channel, }; #[cfg(unix)] + use crate::execution::agent_runtime::control::{ + HiddenOwnerHelperParticipantPlan, HiddenOwnerHelperSessionPlan, + }; + #[cfg(unix)] use crate::execution::agent_runtime::orchestration_session::OrchestrationSessionPosture; #[cfg(unix)] + use crate::execution::agent_runtime::state_store::HiddenOwnerHelperLaunchReadiness; + #[cfg(unix)] use crate::execution::config_model::AgentExecutionScope; #[cfg(unix)] use crate::execution::ShellMode; @@ -7957,6 +8254,7 @@ mod tests { "shell-test".to_string(), "/tmp".to_string(), &manifest, + HostAttachContract::from_manifest_for_test(&manifest), ); session.initialize_startup_prompt(manifest.handle.participant_id.clone()); session.mark_startup_prompt_accepted(manifest.handle.participant_id.as_str()); @@ -8000,6 +8298,7 @@ mod tests { "shell-test".to_string(), "/tmp".to_string(), &manifest, + HostAttachContract::from_manifest_for_test(&manifest), ); session.initialize_startup_prompt(manifest.handle.participant_id.clone()); session.mark_startup_prompt_completed(manifest.handle.participant_id.as_str(), "success"); @@ -8090,6 +8389,24 @@ mod tests { path } + #[cfg(unix)] + fn write_fake_codex_attach_capture_script(temp: &TempDir) -> (PathBuf, PathBuf) { + let path = temp.path().join("fake-codex-attach-capture.sh"); + let stdin_path = temp.path().join("fake-codex-attach.stdin"); + let body = format!( + "#!/bin/sh\ncat > '{}'\ntrap 'exit 0' INT TERM\nprintf '{{\"type\":\"thread.started\",\"thread_id\":\"thread-test\"}}\\r\\n'\nprintf '{{\"type\":\"turn.started\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-1\"}}\\r\\n'\nwhile :; do sleep 1; done\n", + stdin_path.display() + ); + fs::write(&path, body).expect("write fake codex attach capture script"); + let mut perms = fs::metadata(&path) + .expect("fake codex metadata") + .permissions(); + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o755); + fs::set_permissions(&path, perms).expect("set fake codex permissions"); + (path, stdin_path) + } + #[cfg(unix)] fn write_fake_codex_script_with_pid_file(temp: &TempDir, pid_file: &Path) -> PathBuf { let path = temp.path().join("fake-codex-with-pid.sh"); @@ -8396,6 +8713,29 @@ mod tests { fs::canonicalize(&parent.workspace_root).expect("canonicalize parent workspace"), fs::canonicalize(&workspace_root).expect("canonicalize workspace root") ); + let host_attach_contract = parent + .host_attach_contract() + .expect("repl-born host session should persist durable attach truth"); + assert_eq!(host_attach_contract.backend_id, "cli:codex"); + assert_eq!(host_attach_contract.protocol, PURE_AGENT_PROTOCOL); + assert_eq!( + host_attach_contract + .attach_launch_knobs + .requested_execution_scope, + AgentExecutionScope::Host + ); + assert!(host_attach_contract.capabilities.session_resume); + let persisted_policy: substrate_broker::Policy = + serde_json::from_value(host_attach_contract.effective_policy.clone()) + .expect("deserialize persisted policy snapshot"); + assert_eq!( + persisted_policy.agents_allowed_backends, + vec!["cli:codex".to_string()] + ); + assert_eq!( + host_attach_contract.continuity_uaa_session_id.as_deref(), + Some("thread-test") + ); assert_eq!( runtime .store @@ -8829,7 +9169,10 @@ mod tests { let mut runtime = start_host_orchestrator_runtime_with_prepared_prompt( Some(prepared), None, - None, + Some(InitialExecPromptPlan::Replace( + "test hidden helper startup".to_string(), + )), + false, true, false, &ReplPrinter::Stdout, @@ -8930,6 +9273,136 @@ mod tests { std::env::remove_var("SUBSTRATE_HOME"); } + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn hidden_owner_helper_attach_startup_reaches_ready_attached() { + let _world_env_guard = crate::execution::world_env_guard(); + let temp = TempDir::new().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let substrate_home = temp.path().join("substrate-home"); + fs::create_dir_all(&workspace_root).expect("workspace root"); + fs::create_dir_all(&substrate_home).expect("substrate home"); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let (fake_codex, stdin_capture_path) = write_fake_codex_attach_capture_script(&temp); + + std::env::set_var("SUBSTRATE_HOME", &substrate_home); + fs::write( + substrate_home.join("config.yaml"), + "agents:\n enabled: true\n hub:\n orchestrator_agent_id: codex\n", + ) + .expect("write config"); + fs::write( + substrate_home.join("policy.yaml"), + "agents:\n allowed_backends:\n - cli:codex\n", + ) + .expect("write policy"); + let agents_dir = substrate_home.join("agents"); + fs::create_dir_all(&agents_dir).expect("agents dir"); + fs::write( + agents_dir.join("codex.yaml"), + format!( + "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: {PURE_AGENT_PROTOCOL}\n execution:\n scope: host\n cli:\n binary: {}\n mode: persistent\n capabilities:\n session_start: true\n session_resume: true\n session_fork: true\n session_stop: true\n status_snapshot: true\n event_stream: true\n llm: true\n mcp_client: false\n", + fake_codex.display() + ), + ) + .expect("write codex agent file"); + + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async { + let mut descriptor = test_runtime_selection_descriptor(); + descriptor.binary_path = fake_codex.clone(); + let mut plan = super::HiddenOwnerHelperLaunchPlan { + mode: super::OwnerHelperMode::Attach, + descriptor: super::ResolvedRuntimeDescriptor::from(&descriptor), + session: HiddenOwnerHelperSessionPlan { + orchestration_session_id: "orch-attach".to_string(), + shell_trace_session_id: "trace-attach".to_string(), + workspace_root: workspace_root.display().to_string(), + world_id: None, + world_generation: None, + }, + participant: HiddenOwnerHelperParticipantPlan { + participant_id: "ash-attach".to_string(), + lease_token: "lease-attach".to_string(), + run_id: "run-attach".to_string(), + resumed_from_participant_id: Some("ash-source".to_string()), + internal_uaa_session_id: Some("uaa-attach-source".to_string()), + }, + host_attach_contract: None, + startup_prompt: None, + source_orchestration_session_id: Some("orch-source".to_string()), + }; + let config = Arc::new( + owner_helper_shell_config(&plan) + .expect("owner helper attach shell config should resolve"), + ); + let resolved = resolve_host_orchestrator_bootstrap(&config) + .expect("resolve host bootstrap for attach test should succeed") + .expect("agents-enabled host bootstrap should be present"); + plan.host_attach_contract = HostAttachContract::from_resolved_contract( + &resolved.resolved_contract, + Some("uaa-attach-source".to_string()), + ); + let prepared = prepare_hidden_owner_helper_runtime(&config, &plan) + .expect("prepare hidden owner-helper attach runtime should succeed"); + let mut telemetry = ReplSessionTelemetry::new(config.clone(), "async-test"); + let runtime = start_host_orchestrator_runtime_with_prepared_prompt( + Some(prepared), + None, + None, + true, + true, + false, + &ReplPrinter::Stdout, + &mut telemetry, + ) + .await + .expect("hidden owner-helper attach runtime should start") + .expect("runtime"); + + let readiness = runtime + .store + .classify_hidden_owner_helper_launch_readiness("orch-attach", "ash-attach", true) + .expect("readiness classification"); + assert_eq!(readiness, HiddenOwnerHelperLaunchReadiness::ReadyAttached); + + let session = runtime + .store + .load_session("orch-attach") + .expect("load attached session") + .expect("attached session record"); + let participant = session + .participants + .iter() + .find(|participant| participant.participant_id() == "ash-attach") + .expect("attached participant"); + assert_eq!(session.session.active_participant_id(), Some("ash-attach")); + assert_eq!(participant.internal_uaa_session_id(), Some("thread-test")); + assert_eq!( + session + .session + .host_attach_contract() + .and_then(|contract| contract.continuity_uaa_session_id.as_deref()), + Some("thread-test") + ); + assert_eq!( + fs::read_to_string(&stdin_capture_path).expect("read attach stdin capture"), + "", + "control-only attach must close stdin without sending a bootstrap prompt" + ); + + let mut shutdown_telemetry = ReplSessionTelemetry::new(config, "async-test-stop"); + shutdown_host_orchestrator_runtime( + runtime, + &ReplPrinter::Stdout, + &mut shutdown_telemetry, + ) + .await; + }); + std::env::remove_var("SUBSTRATE_HOME"); + } + #[cfg(unix)] #[test] #[serial_test::serial] @@ -9131,6 +9604,77 @@ mod tests { std::env::remove_var("SUBSTRATE_HOME"); } + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn can_park_host_runtime_after_detach_accepts_completed_one_turn_when_store_lags() { + let temp = TempDir::new().expect("tempdir"); + let substrate_home = temp.path().join("substrate-home"); + fs::create_dir_all(&substrate_home).expect("substrate home"); + std::env::set_var("SUBSTRATE_HOME", &substrate_home); + + let store = AgentRuntimeStateStore::new().expect("state store"); + let descriptor = test_runtime_selection_descriptor(); + let mut participant = AgentRuntimeParticipantRecord::new_replacement_participant( + &descriptor, + AgentRuntimeReplacementParticipantInit { + orchestration_session_id: "sess_one_turn_lag".to_string(), + participant_id: "ash_one_turn_lag".to_string(), + role: ORCHESTRATOR_ROLE.to_string(), + orchestrator_participant_id: None, + parent_participant_id: None, + resumed_from_participant_id: "ash_source".to_string(), + world: None, + lease_token: "lease_one_turn_lag".to_string(), + }, + ) + .expect("replacement participant"); + participant.transition_state(AgentRuntimeSessionState::Ready); + participant.set_uaa_session_id("thread-test"); + participant.mark_client_detached("owner detached cleanly"); + + let mut persisted_session = OrchestrationSessionRecord::new( + "sess_one_turn_lag".to_string(), + "trace_one_turn_lag".to_string(), + "/workspace".to_string(), + &participant, + HostAttachContract::from_manifest_for_test(&participant), + ); + persisted_session.transition_state(OrchestrationSessionState::Active); + persisted_session.bind_active_session_handle(participant.handle.participant_id.clone()); + persisted_session.mark_parked_resumable("owner detached cleanly"); + + store + .persist_orchestration_session(&persisted_session) + .expect("persist stale orchestration"); + store + .persist_participant(&participant) + .expect("persist participant"); + + let mut in_memory_session = persisted_session.clone(); + in_memory_session.bind_active_session_handle(participant.handle.participant_id.clone()); + in_memory_session.initialize_startup_prompt(participant.handle.participant_id.clone()); + in_memory_session + .mark_startup_prompt_completed(participant.handle.participant_id.as_str(), "success"); + + let mut in_memory_manifest = participant.clone(); + in_memory_manifest.mark_runtime_ownership_retained(); + + assert!( + can_park_host_runtime_after_detach( + &store, + true, + &in_memory_session, + &in_memory_manifest, + true, + true, + ), + "completed resume-one-turn handoff should park even if the persisted session row still lags behind the attached snapshot" + ); + + std::env::remove_var("SUBSTRATE_HOME"); + } + #[cfg(unix)] #[test] #[serial_test::serial] @@ -9350,6 +9894,168 @@ mod tests { std::env::remove_var("SUBSTRATE_HOME"); } + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn build_member_dispatch_transport_request_uses_shared_contract_parity_subset() { + let _world_env_guard = crate::execution::world_env_guard(); + let temp = TempDir::new().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let substrate_home = temp.path().join("substrate-home"); + fs::create_dir_all(&workspace_root).expect("workspace root"); + fs::create_dir_all(&substrate_home).expect("substrate home"); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let fake_orchestrator = + write_fake_codex_script_with_running_and_shutdown_delay(&temp, 1, 1); + let fake_member = write_fake_codex_script_with_running_and_shutdown_delay(&temp, 1, 1); + + std::env::set_var("SUBSTRATE_HOME", &substrate_home); + write_runtime_inventory_with_world_member( + &substrate_home, + &fake_orchestrator, + &fake_member, + ); + + let config = Arc::new(test_shell_config(&workspace_root, &substrate_home)); + let prepared = prepare_host_orchestrator_runtime_startup(&config) + .expect("prepare host runtime should succeed") + .expect("host runtime should be configured"); + let startup_context = prepared.startup_context.clone(); + let host_manifest = prepared.manifest.clone(); + let initial_world_binding = PersistedWorldBinding { + world_id: "wld_member_test".to_string(), + world_generation: 7, + }; + seed_live_orchestrator_parent(&startup_context, &host_manifest, &initial_world_binding); + + let selected_descriptor = select_member_runtime_descriptor(&startup_context) + .expect("member selection should succeed") + .expect("one world-scoped member should be selected"); + let expected_binary_path = selected_descriptor.binary_path.display().to_string(); + let expected_protocol = selected_descriptor.protocol.clone(); + let expected_backend_kind = selected_descriptor.backend_kind; + let mut member_prepared = prepare_member_runtime_startup_for_descriptor( + &startup_context, + selected_descriptor, + &initial_world_binding, + None, + ) + .expect("member runtime prepare should succeed"); + member_prepared.descriptor.protocol = "drifted.protocol".to_string(); + member_prepared.descriptor.backend_kind = AgentRuntimeBackendKind::ClaudeCode; + member_prepared.descriptor.binary_path = PathBuf::from("/tmp/drifted-member-binary"); + + let request = + build_member_dispatch_transport_request(&member_prepared, Some("hello".to_string())) + .expect("member dispatch request"); + + assert_eq!(request.backend_id, "cli:codex"); + assert_eq!(request.protocol, expected_protocol); + assert_eq!(request.binary_path, expected_binary_path); + assert_eq!( + request.backend_kind, + member_runtime_backend_kind(expected_backend_kind) + ); + + std::env::remove_var("SUBSTRATE_HOME"); + } + + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn retained_member_dispatch_parity_subset_prefers_pending_replacement_truth() { + let _world_env_guard = crate::execution::world_env_guard(); + let temp = TempDir::new().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let substrate_home = temp.path().join("substrate-home"); + fs::create_dir_all(&workspace_root).expect("workspace root"); + fs::create_dir_all(&substrate_home).expect("substrate home"); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let fake_orchestrator = + write_fake_codex_script_with_running_and_shutdown_delay(&temp, 1, 1); + let fake_member = write_fake_codex_script_with_running_and_shutdown_delay(&temp, 1, 1); + + std::env::set_var("SUBSTRATE_HOME", &substrate_home); + write_runtime_inventory_with_world_member( + &substrate_home, + &fake_orchestrator, + &fake_member, + ); + + let config = Arc::new(test_shell_config(&workspace_root, &substrate_home)); + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async { + let prepared = prepare_host_orchestrator_runtime_startup(&config) + .expect("prepare host runtime should succeed") + .expect("host runtime should be configured"); + let startup_context = prepared.startup_context.clone(); + let host_manifest = prepared.manifest.clone(); + let initial_world_binding = PersistedWorldBinding { + world_id: "wld_member_old".to_string(), + world_generation: 2, + }; + let mut telemetry = ReplSessionTelemetry::new(config.clone(), "async-test"); + seed_live_orchestrator_parent(&startup_context, &host_manifest, &initial_world_binding); + + let selected_descriptor = select_member_runtime_descriptor(&startup_context) + .expect("member selection should succeed") + .expect("one world-scoped member should be selected"); + let backend_id = selected_descriptor.backend_id.clone(); + let member_prepared = prepare_member_runtime_startup_for_descriptor( + &startup_context, + selected_descriptor.clone(), + &initial_world_binding, + None, + ) + .expect("member runtime prepare should succeed"); + let member_runtime = start_member_runtime_with_prepared( + Some(member_prepared), + &ReplPrinter::Stdout, + &mut telemetry, + ) + .await + .expect("member runtime start should succeed") + .expect("member runtime"); + + let previous_member = runtime_manifest_snapshot(&member_runtime); + let mut pending_member_replacements = PendingMemberReplacementMap::new(); + pending_member_replacements.insert(backend_id.clone(), previous_member.clone()); + + let mut drifted_startup_context = startup_context.clone(); + drifted_startup_context.inventory.remove("codex"); + + let parity = retained_member_dispatch_parity_subset( + Some(&drifted_startup_context), + &RetainedMemberRuntimeMap::new(), + &pending_member_replacements, + &backend_id, + ) + .expect("parity lookup should succeed") + .expect("pending retained member parity"); + + assert_eq!(parity.agent_id, previous_member.handle.agent_id); + assert_eq!(parity.backend_id, previous_member.handle.backend_id); + assert_eq!(parity.protocol, previous_member.handle.protocol); + assert_eq!( + parity.execution_scope, + previous_member.handle.execution.scope + ); + assert_eq!( + parity.binary_path, + PathBuf::from(previous_member.internal.resolved_binary_path.clone()) + ); + + shutdown_host_orchestrator_runtime( + member_runtime, + &ReplPrinter::Stdout, + &mut telemetry, + ) + .await; + }); + + std::env::remove_var("SUBSTRATE_HOME"); + } + #[cfg(unix)] #[test] fn write_best_effort_unix_completes_after_single_full_write() { diff --git a/crates/shell/tests/agent_public_control_surface_v1.rs b/crates/shell/tests/agent_public_control_surface_v1.rs index ddee9209b..e2ba55143 100644 --- a/crates/shell/tests/agent_public_control_surface_v1.rs +++ b/crates/shell/tests/agent_public_control_surface_v1.rs @@ -15,6 +15,7 @@ use std::process::{Child, Output, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use substrate_broker::Policy; use support::{ binary_path, ensure_substrate_built, persist_runtime_alert_for_substrate_home, substrate_shell_driver, @@ -268,7 +269,7 @@ fn write_fake_codex_script_turn_requires_helper_cancel_after_prompt(dir: &Path) let path = dir.join("fake-codex-turn-requires-helper-cancel.sh"); let count_path = dir.join("fake-codex-turn-requires-helper-cancel.count"); let body = format!( - "#!/bin/sh\nSTATE_FILE='{}'\nSCRIPT_DIR='{}'\ncount=0\nif [ -f \"$STATE_FILE\" ]; then\n count=$(cat \"$STATE_FILE\")\nfi\ncount=$((count + 1))\nprintf '%s' \"$count\" > \"$STATE_FILE\"\nprintf '%s\\n' \"$@\" > \"$SCRIPT_DIR/fake-codex-$count.args\"\ncat > \"$SCRIPT_DIR/fake-codex-$count.stdin\"\nif [ \"$count\" -eq 1 ]; then\n trap 'exit 0' INT TERM\n printf '{{\"type\":\"thread.resumed\",\"thread_id\":\"thread-test\"}}\\r\\n'\n printf '{{\"type\":\"turn.started\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-bootstrap\"}}\\r\\n'\n while :; do sleep 1; done\nfi\nif [ \"$count\" -eq 2 ]; then\n printf '{{\"type\":\"thread.resumed\",\"thread_id\":\"thread-test\"}}\\r\\n'\n printf '{{\"type\":\"turn.started\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\"}}\\r\\n' \"$count\"\n printf '{{\"type\":\"item.completed\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\",\"item_id\":\"msg-%s\",\"status\":\"completed\",\"item_type\":\"agent_message\",\"content\":{{\"text\":\"follow-up prompt success\"}}}}\\r\\n' \"$count\" \"$count\"\n printf '{{\"type\":\"turn.completed\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\"}}\\r\\n' \"$count\"\n exit 0\nfi\ntrap 'exit 0' INT TERM\nprintf '{{\"type\":\"thread.resumed\",\"thread_id\":\"thread-test\"}}\\r\\n'\nprintf '{{\"type\":\"turn.started\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\"}}\\r\\n' \"$count\"\nwhile :; do sleep 1; done\n", + "#!/bin/sh\nSTATE_FILE='{}'\nSCRIPT_DIR='{}'\ncount=0\nif [ -f \"$STATE_FILE\" ]; then\n count=$(cat \"$STATE_FILE\")\nfi\ncount=$((count + 1))\nprintf '%s' \"$count\" > \"$STATE_FILE\"\nprintf '%s\\n' \"$@\" > \"$SCRIPT_DIR/fake-codex-$count.args\"\ncat > \"$SCRIPT_DIR/fake-codex-$count.stdin\"\nif [ \"$count\" -eq 1 ]; then\n printf '{{\"type\":\"thread.resumed\",\"thread_id\":\"thread-test\"}}\\r\\n'\n printf '{{\"type\":\"turn.started\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\"}}\\r\\n' \"$count\"\n printf '{{\"type\":\"item.completed\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\",\"item_id\":\"msg-%s\",\"status\":\"completed\",\"item_type\":\"agent_message\",\"content\":{{\"text\":\"follow-up prompt success\"}}}}\\r\\n' \"$count\" \"$count\"\n printf '{{\"type\":\"turn.completed\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\"}}\\r\\n' \"$count\"\n exit 0\nfi\ntrap 'exit 0' INT TERM\nprintf '{{\"type\":\"thread.resumed\",\"thread_id\":\"thread-test\"}}\\r\\n'\nprintf '{{\"type\":\"turn.started\",\"thread_id\":\"thread-test\",\"turn_id\":\"turn-%s\"}}\\r\\n' \"$count\"\nwhile :; do sleep 1; done\n", count_path.display(), dir.display(), ); @@ -760,6 +761,48 @@ fn write_json_file(path: &Path, value: &Value) { .unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display())); } +fn host_attach_contract_manifest( + fixture: &AgentControlFixture, + agent_id: &str, + orchestration_session_id: &str, +) -> Value { + let backend_kind = if agent_id == "claude_code" { + "claude_code" + } else { + "codex" + }; + json!({ + "backend_id": format!("cli:{agent_id}"), + "execution_scope": "host", + "protocol": PURE_AGENT_PROTOCOL, + "launch_descriptor": { + "agent_id": agent_id, + "backend_id": format!("cli:{agent_id}"), + "backend_kind": backend_kind, + "protocol": PURE_AGENT_PROTOCOL, + "execution_scope": "host", + "binary_path": fixture.fake_codex.display().to_string() + }, + "capabilities": { + "session_resume": true, + "session_fork": true, + "session_stop": true, + "status_snapshot": true, + "event_stream": true + }, + "attach_launch_knobs": { + "requested_execution_scope": "host", + "host_execution_client_start": "start_now", + "attach_mode_preference": "continuity_required" + }, + "effective_policy": serde_json::to_value(Policy { + agents_allowed_backends: vec![format!("cli:{agent_id}")], + ..Policy::default() + }).expect("serialize effective policy"), + "continuity_uaa_session_id": format!("uaa-{orchestration_session_id}") + }) +} + fn write_active_orchestration_session( fixture: &AgentControlFixture, agent_id: &str, @@ -792,7 +835,12 @@ fn write_active_orchestration_session( "world_id": Value::Null, "world_generation": Value::Null, "invalidation_reason": Value::Null, - "closed_at": Value::Null + "closed_at": Value::Null, + "host_attach_contract": host_attach_contract_manifest( + fixture, + agent_id, + orchestration_session_id + ) }), ); } @@ -829,7 +877,12 @@ fn write_parked_orchestration_session( "world_id": Value::Null, "world_generation": Value::Null, "invalidation_reason": Value::Null, - "closed_at": Value::Null + "closed_at": Value::Null, + "host_attach_contract": host_attach_contract_manifest( + fixture, + agent_id, + orchestration_session_id + ) }), ); } @@ -869,7 +922,12 @@ fn write_parked_world_orchestration_session( "world_id": world_id, "world_generation": world_generation, "invalidation_reason": Value::Null, - "closed_at": Value::Null + "closed_at": Value::Null, + "host_attach_contract": host_attach_contract_manifest( + fixture, + agent_id, + orchestration_session_id + ) }), ); } @@ -937,7 +995,12 @@ fn write_orchestration_session( "world_id": world_id, "world_generation": world_generation, "invalidation_reason": Value::Null, - "closed_at": closed_at + "closed_at": closed_at, + "host_attach_contract": host_attach_contract_manifest( + fixture, + agent_id, + orchestration_session_id, + ) }), ); } @@ -1492,6 +1555,17 @@ fn public_start_turn_and_stop_emit_streaming_ndjson_and_authoritative_state() { turn_stdin.contains("hello from turn"), "resume-backed follow-up turns must continue to send the prompt on stdin: {turn_stdin:?}" ); + let reparked_session = wait_for_session_posture( + &fixture, + &orchestration_session_id, + "parked_resumable", + Duration::from_secs(5), + ); + let authoritative_participant_id = reparked_session + .get("active_session_handle_id") + .and_then(Value::as_str) + .expect("authoritative participant id after detached turn") + .to_string(); let stop_output = fixture.run(&[ "agent", @@ -1517,7 +1591,7 @@ fn public_start_turn_and_stop_emit_streaming_ndjson_and_authoritative_state() { ); assert_eq!( stop_json.get("participant_id").and_then(Value::as_str), - Some(participant_id.as_str()) + Some(authoritative_participant_id.as_str()) ); assert_eq!( stop_json.get("backend_id").and_then(Value::as_str), @@ -1626,6 +1700,11 @@ fn public_reattach_and_fork_preserve_exact_session_and_lineage_contracts() { pid_is_alive(resumed_owner_pid), "reattach must leave a live owner loop" ); + let reattach_stdin = fixture.read_fake_codex_stdin(1); + assert!( + reattach_stdin.trim().is_empty(), + "reattach must not send a hidden bootstrap prompt or any other user prompt payload: {reattach_stdin:?}" + ); fixture.reset_fake_codex_state(); let fork_output = fixture.run(&["agent", "fork", "--session", "sess_resume_source", "--json"]); @@ -1651,7 +1730,11 @@ fn public_reattach_and_fork_preserve_exact_session_and_lineage_contracts() { assert_eq!(fork_json.get("scope").and_then(Value::as_str), Some("host")); assert_eq!( fork_json.get("state").and_then(Value::as_str), - Some("active") + Some("parked_resumable") + ); + assert!( + fork_json.get("participant_id").is_none(), + "fork should return only the successor orchestration session handle: {fork_json}" ); assert_empty_warnings(&fork_json); @@ -1663,9 +1746,10 @@ fn public_reattach_and_fork_preserve_exact_session_and_lineage_contracts() { fork_session_id, "sess_resume_source", "fork must allocate a new orchestration session id" ); - let fork_participant_id = fork_json["participant_id"] + let fork_session = fixture.load_orchestration_session(&fork_session_id); + let fork_participant_id = fork_session["active_session_handle_id"] .as_str() - .expect("fork participant id") + .expect("fork successor active participant id") .to_string(); let fork_participant = fixture.load_participant(&fork_session_id, &fork_participant_id); assert_eq!( @@ -1675,16 +1759,265 @@ fn public_reattach_and_fork_preserve_exact_session_and_lineage_contracts() { Some(resumed_participant_id.as_str()), "fork successor persistence must point lineage at the exact live source participant" ); - let fork_owner_pid = fixture.load_orchestration_session(&fork_session_id)["shell_owner_pid"] - .as_u64() - .expect("fork owner pid") as u32; assert!( - pid_is_alive(fork_owner_pid), - "fork must leave a live owner loop" + matches!( + fork_session.get("posture").and_then(Value::as_str), + Some("parked_resumable") + ), + "fork successor must persist detached resumable posture: {fork_session}" + ); + assert!( + matches!( + fork_session.get("shell_owner_pid").and_then(Value::as_u64), + Some(0) + ), + "fork successor allocation should not leave an attached owner loop: {fork_session}" ); terminate_pid(resumed_owner_pid); - terminate_pid(fork_owner_pid); +} + +#[test] +#[serial] +fn public_reattach_uses_persisted_attach_continuity_selector_for_resume_args() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory(false); + + let ts = "2026-05-05T00:00:00Z"; + write_parked_orchestration_session( + &fixture, + "codex", + "sess_resume_contract_args", + "ash_resume_contract_args", + ts, + ); + write_runtime_participant( + &fixture, + "ash_resume_contract_args", + "codex", + "sess_resume_contract_args", + "running", + false, + Some("uaa-ambient-detached"), + None, + ts, + ); + + let output = fixture.run(&[ + "agent", + "reattach", + "--session", + "sess_resume_contract_args", + "--json", + ]); + assert!( + output.status.success(), + "public reattach should resume from the persisted attach contract: {output:?}" + ); + + let args = fixture.read_fake_codex_args(1); + assert!( + args.iter().any(|arg| arg == "resume"), + "reattach must issue a resume-backed invocation: {args:?}" + ); + assert!( + args.iter() + .any(|arg| arg == "uaa-sess_resume_contract_args"), + "reattach must use the persisted continuity selector from host_attach_contract: {args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "uaa-ambient-detached"), + "reattach must not recover continuity from the detached participant snapshot: {args:?}" + ); + + let owner_pid = fixture.load_orchestration_session("sess_resume_contract_args") + ["shell_owner_pid"] + .as_u64() + .expect("reattach owner pid") as u32; + terminate_pid(owner_pid); +} + +#[test] +#[serial] +fn public_reattach_fails_closed_when_persisted_attach_contract_disables_resume() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory(false); + + let ts = "2026-05-05T00:00:00Z"; + let orchestration_session_id = "sess_resume_disabled_contract"; + write_parked_orchestration_session( + &fixture, + "codex", + orchestration_session_id, + "ash_resume_disabled_contract", + ts, + ); + write_runtime_participant( + &fixture, + "ash_resume_disabled_contract", + "codex", + orchestration_session_id, + "running", + false, + Some("uaa-ambient-detached"), + None, + ts, + ); + + let mut session = fixture.load_orchestration_session(orchestration_session_id); + session["host_attach_contract"]["capabilities"] = json!({ + "session_resume": false, + "session_fork": true, + "session_stop": true, + "status_snapshot": true, + "event_stream": true + }); + write_json_file( + &canonical_orchestration_session_path(&fixture.substrate_home, orchestration_session_id), + &session, + ); + + let output = fixture.run(&[ + "agent", + "reattach", + "--session", + orchestration_session_id, + "--json", + ]); + assert!( + !output.status.success(), + "public reattach must fail closed when durable attach truth disables resume: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("durable host attach contract does not allow resume"), + "reattach denial must explain the persisted attach capability gate: {output:?}" + ); + assert!( + !fixture.fake_codex_args_path(1).exists(), + "reattach denial must fail before launching the backend runtime" + ); +} + +#[test] +#[serial] +fn public_fork_and_stop_fail_closed_when_persisted_attach_contract_disables_capabilities() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory(false); + + let ts = "2026-05-05T00:00:00Z"; + let orchestration_session_id = "sess_capability_gated_public_control"; + write_parked_orchestration_session( + &fixture, + "codex", + orchestration_session_id, + "ash_capability_gated_source", + ts, + ); + write_runtime_participant( + &fixture, + "ash_capability_gated_source", + "codex", + orchestration_session_id, + "running", + false, + Some("uaa-capability-gated"), + None, + ts, + ); + + let reattach_output = fixture.run(&[ + "agent", + "reattach", + "--session", + orchestration_session_id, + "--json", + ]); + assert!( + reattach_output.status.success(), + "public reattach should succeed before fork/stop capability denial is exercised: {reattach_output:?}" + ); + + let mut session = fixture.load_orchestration_session(orchestration_session_id); + session["host_attach_contract"]["capabilities"] = json!({ + "session_resume": true, + "session_fork": false, + "session_stop": false, + "status_snapshot": true, + "event_stream": true + }); + write_json_file( + &canonical_orchestration_session_path(&fixture.substrate_home, orchestration_session_id), + &session, + ); + + let fork_output = fixture.run(&[ + "agent", + "fork", + "--session", + orchestration_session_id, + "--json", + ]); + assert!( + !fork_output.status.success(), + "public fork must fail closed when durable attach truth disables fork: {fork_output:?}" + ); + let fork_stderr = String::from_utf8_lossy(&fork_output.stderr); + assert!( + fork_stderr.contains("durable host attach contract does not allow fork"), + "fork denial must explain the persisted attach capability gate: {fork_output:?}" + ); + + let stopped_session_id = "sess_capability_gated_public_stop"; + write_parked_orchestration_session( + &fixture, + "codex", + stopped_session_id, + "ash_capability_gated_stop_source", + ts, + ); + write_runtime_participant( + &fixture, + "ash_capability_gated_stop_source", + "codex", + stopped_session_id, + "running", + false, + Some("uaa-capability-gated-stop"), + None, + ts, + ); + let mut stopped_session = fixture.load_orchestration_session(stopped_session_id); + stopped_session["host_attach_contract"]["capabilities"] = json!({ + "session_resume": true, + "session_fork": true, + "session_stop": false, + "status_snapshot": true, + "event_stream": true + }); + write_json_file( + &canonical_orchestration_session_path(&fixture.substrate_home, stopped_session_id), + &stopped_session, + ); + + let stop_output = fixture.run(&["agent", "stop", "--session", stopped_session_id, "--json"]); + assert!( + !stop_output.status.success(), + "public stop must fail closed when parked durable attach truth disables stop: {stop_output:?}" + ); + let stop_stderr = String::from_utf8_lossy(&stop_output.stderr); + assert!( + stop_stderr.contains("durable host attach contract does not allow stop"), + "stop denial must explain the persisted attach capability gate: {stop_output:?}" + ); + + let owner_pid = fixture.load_orchestration_session(orchestration_session_id)["shell_owner_pid"] + .as_u64() + .expect("reattach owner pid") as u32; + terminate_pid(owner_pid); } #[test] @@ -1898,6 +2231,64 @@ fn public_turn_resumes_parked_host_session_and_preserves_exact_session_selector_ ); } +#[test] +#[serial] +fn public_turn_uses_persisted_attach_continuity_selector_when_recovering_detached_host_turns() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory(false); + + let ts = "2026-05-05T00:00:00Z"; + write_parked_orchestration_session( + &fixture, + "codex", + "sess_turn_contract_args", + "ash_turn_contract_args", + ts, + ); + write_runtime_participant( + &fixture, + "ash_turn_contract_args", + "codex", + "sess_turn_contract_args", + "running", + false, + Some("uaa-ambient-turn"), + None, + ts, + ); + + let output = fixture.run(&[ + "agent", + "turn", + "--session", + "sess_turn_contract_args", + "--backend", + "cli:codex", + "--prompt", + "resume detached host turn via persisted contract", + "--json", + ]); + assert!( + output.status.success(), + "detached host turn should recover through the persisted attach contract: {output:?}" + ); + + let args = fixture.read_fake_codex_args(1); + assert!( + args.iter().any(|arg| arg == "resume"), + "detached host turn recovery must use a resume-backed invocation: {args:?}" + ); + assert!( + args.iter().any(|arg| arg == "uaa-sess_turn_contract_args"), + "detached host turn recovery must use the persisted continuity selector: {args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "uaa-ambient-turn"), + "detached host turn recovery must not derive continuity from the detached participant snapshot: {args:?}" + ); +} + #[test] #[serial] fn public_same_session_parked_status_turn_reattach_and_stop_stay_on_one_orchestration_session_id() { @@ -3393,6 +3784,42 @@ fn public_root_start_rejects_world_scoped_backends_in_v1() { ); } +#[test] +#[serial] +fn public_root_start_denials_name_field_layer_and_reason() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory(true); + + let output = fixture.run(&[ + "agent", + "start", + "--backend", + "cli:claude_code", + "--prompt", + "hello", + "--json", + ]); + assert_eq!( + output.status.code(), + Some(2), + "world-scoped root start must fail closed: {output:?}" + ); + let stderr = stderr_text(&output); + assert!( + stderr.contains("unsupported_platform_or_posture"), + "start denial must keep the posture classifier: {stderr}" + ); + assert!( + stderr.contains("baseline truth rejected field 'requested_execution_scope'"), + "start denial must name the rejecting layer and field: {stderr}" + ); + assert!( + stderr.contains("public root start is host-only in v1"), + "start denial must preserve the concrete posture reason: {stderr}" + ); +} + #[test] #[serial] fn public_command_mode_remains_shell_wrap_not_agent_prompt() { diff --git a/crates/shell/tests/agent_successor_contract_ahcsitc0.rs b/crates/shell/tests/agent_successor_contract_ahcsitc0.rs index f2945292c..797623b65 100644 --- a/crates/shell/tests/agent_successor_contract_ahcsitc0.rs +++ b/crates/shell/tests/agent_successor_contract_ahcsitc0.rs @@ -7,6 +7,7 @@ use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Output; +use substrate_broker::Policy; use tempfile::{Builder, TempDir}; const PURE_AGENT_PROTOCOL: &str = "substrate.agent.session"; @@ -740,6 +741,7 @@ struct OrchestrationSessionManifestOptions<'a> { last_parked_at: Option>, last_attention_at: Option>, parked_reason: Option>, + host_attach_contract: Option>, } fn write_orchestration_session_with_manifest_options( @@ -851,6 +853,13 @@ fn orchestration_session_manifest_with_options( Some(None) => Value::Null, None => Value::Null, }; + let host_attach_contract = match options.host_attach_contract { + Some(Some(continuity_uaa_session_id)) => { + host_attach_contract_manifest(agent_id, continuity_uaa_session_id) + } + Some(None) => Value::Null, + None => host_attach_contract_manifest(agent_id, &format!("uaa-{orchestration_session_id}")), + }; json!({ "orchestration_session_id": orchestration_session_id, "shell_trace_session_id": "ses_agent_hub", @@ -874,7 +883,46 @@ fn orchestration_session_manifest_with_options( "world_id": world_id, "world_generation": world_generation, "invalidation_reason": if state == "active" { Value::Null } else { json!("fixture stopped parent") }, - "closed_at": closed_at + "closed_at": closed_at, + "host_attach_contract": host_attach_contract + }) +} + +fn host_attach_contract_manifest(agent_id: &str, continuity_uaa_session_id: &str) -> Value { + let backend_kind = if agent_id == "claude_code" { + "claude_code" + } else { + "codex" + }; + json!({ + "backend_id": format!("cli:{agent_id}"), + "execution_scope": "host", + "protocol": PURE_AGENT_PROTOCOL, + "launch_descriptor": { + "agent_id": agent_id, + "backend_id": format!("cli:{agent_id}"), + "backend_kind": backend_kind, + "protocol": PURE_AGENT_PROTOCOL, + "execution_scope": "host", + "binary_path": "sh" + }, + "capabilities": { + "session_resume": true, + "session_fork": true, + "session_stop": true, + "status_snapshot": true, + "event_stream": true + }, + "attach_launch_knobs": { + "requested_execution_scope": "host", + "host_execution_client_start": "start_now", + "attach_mode_preference": "continuity_required" + }, + "effective_policy": serde_json::to_value(Policy { + agents_allowed_backends: vec![format!("cli:{agent_id}")], + ..Policy::default() + }).expect("serialize effective policy"), + "continuity_uaa_session_id": continuity_uaa_session_id }) } @@ -2656,6 +2704,7 @@ fn agent_status_json_surfaces_parked_resumable_fields_from_parent_session_truth( last_parked_at: Some(Some("2026-04-05T00:00:02Z")), last_attention_at: Some(None), parked_reason: Some(Some("owner detached cleanly")), + host_attach_contract: Some(Some("external-session-1")), }, ); @@ -2721,6 +2770,7 @@ fn agent_status_json_surfaces_awaiting_attention_fields_from_parent_session_trut last_parked_at: Some(None), last_attention_at: Some(Some("2026-04-05T00:00:03Z")), parked_reason: Some(None), + host_attach_contract: Some(Some("external-session-1")), }, ); @@ -3643,6 +3693,89 @@ fn agent_status_surfaces_host_successor_lineage_for_active_orchestrator_sessions ); } +#[test] +fn host_successor_session_persists_durable_attach_continuity_contract() { + let fixture = AgentSuccessorFixture::new(); + fixture.init_workspace(); + fixture.seed_inventory_for_list_and_status_contracts(); + write_orchestration_session_with_manifest_options( + &fixture, + OrchestrationSessionManifestSpec { + agent_id: "claude_code", + orchestration_session_id: "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fb5", + active_session_handle_id: Some("ash_orchestrator_new_attach_contract"), + state: "active", + ts: "2026-04-05T00:00:05Z", + world_binding: None, + }, + OrchestrationSessionManifestOptions { + host_attach_contract: Some(Some("uaa-successor-continuity-1")), + ..OrchestrationSessionManifestOptions::default() + }, + ); + write_invalidated_runtime_manifest( + &fixture, + "claude_code", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fb5", + "ash_orchestrator_old_attach_contract", + "2026-04-05T00:00:04Z", + ); + write_runtime_participant( + &fixture, + "ash_orchestrator_new_attach_contract", + "claude_code", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fb5", + RuntimeParticipantOptions::host_orchestrator("running", true, "2026-04-05T00:00:05Z") + .with_resumed_from_participant_id(Some("ash_orchestrator_old_attach_contract")), + ); + + let output = fixture.run(&["agent", "status", "--json"]); + assert!( + output.status.success(), + "agent status should succeed for successor attach-contract fixtures: {output:?}" + ); + + let json = parse_json_output(&output); + let sessions = json["sessions"] + .as_array() + .expect("sessions should be an array"); + let successor = find_session_by_participant(sessions, "ash_orchestrator_new_attach_contract"); + assert_eq!( + successor.pointer("/participant_id").and_then(Value::as_str), + Some("ash_orchestrator_new_attach_contract") + ); + assert_eq!( + successor + .pointer("/attached_participant_id") + .and_then(Value::as_str), + Some("ash_orchestrator_new_attach_contract"), + "status must keep the live successor attached to the canonical participant id" + ); + + let persisted = serde_json::from_str::( + &fs::read_to_string(canonical_orchestration_session_path( + &fixture, + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fb5", + )) + .expect("host successor session should be readable"), + ) + .expect("host successor session should be valid JSON"); + assert_eq!( + persisted + .pointer("/host_attach_contract/continuity_uaa_session_id") + .and_then(Value::as_str), + Some("uaa-successor-continuity-1"), + "successor sessions must retain the durable attach continuity selector" + ); + assert_eq!( + persisted + .pointer("/host_attach_contract/backend_id") + .and_then(Value::as_str), + Some("cli:claude_code"), + "successor sessions must keep the durable attach contract bound to the same backend" + ); +} + #[test] fn agent_status_json_prefers_live_runtime_member_manifest_with_top_level_world_identity() { let fixture = AgentSuccessorFixture::new(); @@ -5107,12 +5240,16 @@ metadata: {} #[test] fn doctor_member_selection_contract_source_locks_ambiguous_fail_closed_path() { - let validator = read_repo_file("crates/shell/src/execution/agent_runtime/validator.rs"); + let dispatch_contract = + read_repo_file("crates/shell/src/execution/agent_runtime/dispatch_contract.rs"); assert!( - validator.contains( - "ambiguous world member selection: multiple eligible world-scoped members found" - ), - "validator.rs must retain the shared ambiguous world-member fail-closed reason" + dispatch_contract + .contains("ambiguous world member selection: multiple eligible {} members found"), + "dispatch_contract.rs must retain the shared ambiguous world-member fail-closed format" + ); + assert!( + dispatch_contract.contains("AgentExecutionScope::World => \"world-scoped\""), + "dispatch_contract.rs must keep world member ambiguity locked to the world-scoped label" ); let agents_cmd = read_repo_file("crates/shell/src/execution/agents_cmd.rs"); diff --git a/crates/shell/tests/repl_world_first_routing_v1.rs b/crates/shell/tests/repl_world_first_routing_v1.rs index 3b0a26eb0..6764676da 100644 --- a/crates/shell/tests/repl_world_first_routing_v1.rs +++ b/crates/shell/tests/repl_world_first_routing_v1.rs @@ -2320,6 +2320,11 @@ fn c3_targeted_world_turn_uses_typed_submit_route_without_relaunching_member() { .expect("typed submit route output"); let guard = records.lock().expect("lock records"); + let member_dispatch = guard + .member_dispatch_requests + .first() + .and_then(|request| request.member_dispatch.as_ref()) + .expect("member dispatch request"); assert_eq!( guard.member_dispatch_requests.len(), 1, @@ -2339,6 +2344,22 @@ fn c3_targeted_world_turn_uses_typed_submit_route_without_relaunching_member() { assert_eq!(submit.world_id, world_id); assert_eq!(submit.world_generation, world_generation); assert_eq!(submit.prompt, "second"); + assert_eq!( + member_dispatch.backend_id, "cli:codex", + "retained member cold start must preserve the exact backend identity in the typed dispatch request" + ); + assert_eq!( + member_dispatch.protocol, "substrate.agent.session", + "retained member cold start must keep the shared dispatch protocol unchanged" + ); + assert_eq!( + member_dispatch.resolved_runtime.binary_path, + member + .pointer("/internal/resolved_binary_path") + .and_then(Value::as_str) + .expect("member resolved binary path"), + "retained member dispatch must carry the persisted resolved binary path rather than reconstructing a hidden descriptor" + ); drop(guard); let live_members_after = wait_for_live_world_member_count( @@ -2475,7 +2496,7 @@ fn c3_targeted_world_turn_relaunches_exact_backend_after_world_restart() { &substrate_home, &orchestration_session_id, 0, - Duration::from_secs(15), + Duration::from_secs(30), ); wait_for_min_member_dispatch_requests(&records, 2, Duration::from_secs(3)); repl.wait_for_prompt(Duration::from_secs(2)) @@ -2546,6 +2567,14 @@ fn c3_targeted_world_turn_relaunches_exact_backend_after_world_restart() { replacement_dispatch.initial_prompt.as_deref(), Some("second") ); + assert_eq!( + replacement_dispatch.resolved_runtime.binary_path, + replacement + .pointer("/internal/resolved_binary_path") + .and_then(Value::as_str) + .expect("replacement resolved binary path"), + "replacement member relaunch must use the retained parity subset for resolved runtime truth" + ); drop(guard); let stale = read_participant_manifest(&substrate_home, &first_member_id); diff --git a/crates/transport-api-types/src/lib.rs b/crates/transport-api-types/src/lib.rs index 04037bd53..c86935f2b 100644 --- a/crates/transport-api-types/src/lib.rs +++ b/crates/transport-api-types/src/lib.rs @@ -1376,7 +1376,7 @@ pub fn validate_gateway_integrated_auth_payload( } if let Some(api_env) = api_env { - if !backend_id.starts_with("api:") { + if !backend_id.starts_with("api:") && backend_id != "cli:claude_code" { return Err(format!( "request-provided integrated auth payload for '{}' uses incompatible auth facet 'api_env'", backend_id @@ -2243,6 +2243,19 @@ mod tests { .expect("valid api:openai"); } + #[test] + fn gateway_integrated_auth_validation_accepts_cli_claude_code_api_env() { + GatewayIntegratedAuthPayloadV1 { + backend_id: "cli:claude_code".to_string(), + cli_codex: None, + api_env: Some(GatewayApiEnvIntegratedAuthV1 { + env: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-ant-test".to_string())]), + }), + } + .validate() + .expect("valid cli:claude_code api_env"); + } + #[test] fn serialize_stream_frame_roundtrip() { let frame = ExecuteStreamFrame::Exit { diff --git a/crates/world-service/Cargo.toml b/crates/world-service/Cargo.toml index ea8ab4ba5..1ee54092a 100644 --- a/crates/world-service/Cargo.toml +++ b/crates/world-service/Cargo.toml @@ -40,6 +40,7 @@ once_cell = "1" libc = "0.2" sha2 = { workspace = true } substrate-common = { path = "../common" } +substrate-gateway = { path = "../gateway" } transport-api-core = { path = "../transport-api-core" } transport-api-types = { path = "../transport-api-types" } world-api = { path = "../world-api" } diff --git a/crates/world-service/src/gateway_runtime.rs b/crates/world-service/src/gateway_runtime.rs index 31299abdc..7a5e301a0 100644 --- a/crates/world-service/src/gateway_runtime.rs +++ b/crates/world-service/src/gateway_runtime.rs @@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; use substrate_common::{ GatewayAuthBundleV1, GATEWAY_AUTH_BUNDLE_SCHEMA_VERSION, SUBSTRATE_LLM_AUTH_BUNDLE_FD, + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCOUNT_ID, @@ -35,13 +36,18 @@ const GATEWAY_MODE_IN_WORLD: &str = "in_world"; const GATEWAY_MODE_HOST_ONLY: &str = "host_only"; const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; +const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; const GATEWAY_BINARY_OVERRIDE_ENV: &str = "SUBSTRATE_GATEWAY_BINARY"; const HEALTH_PATH: &str = "/health"; const DEFAULT_BACKEND: &str = "cli:codex"; +const CLI_CLAUDE_CODE_BACKEND: &str = "cli:claude_code"; const API_OPENAI_BACKEND: &str = "api:openai"; const DEFAULT_ROUTED_MODEL: &str = "codex"; const DEFAULT_ACTUAL_MODEL: &str = "codex-mini-latest"; const DEFAULT_PROVIDER_NAME: &str = "openai-codex"; +const CLAUDE_ROUTED_MODEL: &str = "claude-sonnet-4.5"; +const CLAUDE_ACTUAL_MODEL: &str = "claude-sonnet-4-5-20250929"; +const CLAUDE_PROVIDER_NAME: &str = "anthropic-api"; const OPENAI_ROUTED_MODEL: &str = "gpt-4.1-mini"; const OPENAI_ACTUAL_MODEL: &str = "gpt-4.1-mini"; const OPENAI_PROVIDER_NAME: &str = "openai-api"; @@ -54,7 +60,9 @@ const WORLD_ENTRY_WRAPPER_MODE: u32 = 0o755; const KNOWN_GATEWAY_AUTH_ENV_VARS: &[&str] = &[ SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCOUNT_ID, SUBSTRATE_LLM_BACKEND_AUTH_CLI_CODEX_ACCESS_TOKEN, + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, SUBSTRATE_LLM_BACKEND_AUTH_API_OPENAI_API_KEY, + ANTHROPIC_API_KEY_ENV, OPENAI_API_KEY_ENV, ]; const REQUIRED_GATEWAY_CAPABILITIES: &[&str] = @@ -203,8 +211,26 @@ const API_OPENAI_BACKEND_BINDING: GatewayBackendBinding = GatewayBackendBinding auth_kind: GatewayIntegratedAuthKind::ApiEnv, }; -const GATEWAY_BACKEND_BINDINGS: &[GatewayBackendBinding] = - &[CLI_CODEX_BACKEND_BINDING, API_OPENAI_BACKEND_BINDING]; +const CLI_CLAUDE_CODE_BACKEND_BINDING: GatewayBackendBinding = GatewayBackendBinding { + backend_id: CLI_CLAUDE_CODE_BACKEND, + routed_model: CLAUDE_ROUTED_MODEL, + actual_model: CLAUDE_ACTUAL_MODEL, + provider_name: CLAUDE_PROVIDER_NAME, + provider_type: "anthropic", + advertised_capabilities: CLI_CODEX_BACKEND_BINDING.advertised_capabilities, + required_capabilities: REQUIRED_GATEWAY_CAPABILITIES, + provider_auth: GatewayProviderAuthConfig::ApiKey { + env_var: ANTHROPIC_API_KEY_ENV, + bundle_field: SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY, + }, + auth_kind: GatewayIntegratedAuthKind::ApiEnv, +}; + +const GATEWAY_BACKEND_BINDINGS: &[GatewayBackendBinding] = &[ + CLI_CODEX_BACKEND_BINDING, + CLI_CLAUDE_CODE_BACKEND_BINDING, + API_OPENAI_BACKEND_BINDING, +]; pub(crate) fn resolve_gateway_backend_binding( backend_id: &str, @@ -1628,11 +1654,22 @@ mod tests { }), api_env: None, }), + CLI_CLAUDE_CODE_BACKEND => Some(anthropic_integrated_auth_payload("sk-ant-proof")), API_OPENAI_BACKEND => Some(openai_integrated_auth_payload("sk-openai-test")), _ => None, } } + fn anthropic_integrated_auth_payload(api_key: &str) -> GatewayIntegratedAuthPayloadV1 { + let mut env = HashMap::new(); + env.insert(ANTHROPIC_API_KEY_ENV.to_string(), api_key.to_string()); + GatewayIntegratedAuthPayloadV1 { + backend_id: CLI_CLAUDE_CODE_BACKEND.to_string(), + cli_codex: None, + api_env: Some(GatewayApiEnvIntegratedAuthV1 { env }), + } + } + fn openai_integrated_auth_payload(api_key: &str) -> GatewayIntegratedAuthPayloadV1 { let mut env = HashMap::new(); env.insert(OPENAI_API_KEY_ENV.to_string(), api_key.to_string()); @@ -2064,6 +2101,14 @@ exec python3 -m http.server "$port" --bind 127.0.0.1 --directory "$root" assert_eq!(binding.provider_name, OPENAI_PROVIDER_NAME); } + #[test] + fn binding_lookup_includes_explicit_claude_code_proof_target() { + let binding = + resolve_gateway_backend_binding(CLI_CLAUDE_CODE_BACKEND).expect("claude binding"); + assert_eq!(binding.backend_id, CLI_CLAUDE_CODE_BACKEND); + assert_eq!(binding.provider_name, CLAUDE_PROVIDER_NAME); + } + #[test] fn binding_lookup_returns_none_for_unbound_backend() { assert!(resolve_gateway_backend_binding("api:anthropic").is_none()); @@ -2084,6 +2129,14 @@ exec python3 -m http.server "$port" --bind 127.0.0.1 --directory "$root" assert!(openai_config.contains("auth_type = \"apikey\"")); assert!(openai_config.contains("api_key = \"$OPENAI_API_KEY\"")); assert!(!openai_config.contains("oauth_provider =")); + + let claude_binding = + resolve_gateway_backend_binding(CLI_CLAUDE_CODE_BACKEND).expect("claude binding"); + let claude_config = render_integrated_config(4319, claude_binding); + assert!(claude_config.contains("provider_type = \"anthropic\"")); + assert!(claude_config.contains("auth_type = \"apikey\"")); + assert!(claude_config.contains("api_key = \"$ANTHROPIC_API_KEY\"")); + assert!(!claude_config.contains("oauth_provider =")); } #[test] @@ -2104,6 +2157,25 @@ exec python3 -m http.server "$port" --bind 127.0.0.1 --directory "$root" ); } + #[test] + fn claude_auth_handoff_uses_api_env_when_available() { + let binding = + resolve_gateway_backend_binding(CLI_CLAUDE_CODE_BACKEND).expect("claude binding"); + let auth = resolve_integrated_auth_handoff( + binding, + Some(anthropic_integrated_auth_payload("sk-ant-proof")), + ) + .expect("claude auth handoff"); + + assert_eq!( + auth.bundle.fields, + HashMap::from([( + SUBSTRATE_LLM_BACKEND_AUTH_API_ANTHROPIC_API_KEY.to_string(), + "sk-ant-proof".to_string(), + )]) + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn capability_gate_fails_before_runtime_artifacts_are_created() { let _env_lock = ENV_LOCK.lock().await; diff --git a/crates/world-service/src/lib.rs b/crates/world-service/src/lib.rs index 6d6629cc0..e40bdd634 100644 --- a/crates/world-service/src/lib.rs +++ b/crates/world-service/src/lib.rs @@ -8,6 +8,8 @@ pub mod handlers; pub mod internal_exec; #[cfg(target_os = "linux")] mod member_runtime; +#[cfg(target_os = "linux")] +mod prompt_fulfillment; pub mod pty; mod request_routing; pub mod service; diff --git a/crates/world-service/src/member_runtime.rs b/crates/world-service/src/member_runtime.rs index 01bf976ce..3d5ce1e7c 100644 --- a/crates/world-service/src/member_runtime.rs +++ b/crates/world-service/src/member_runtime.rs @@ -1,11 +1,6 @@ use agent_api::{ - backends::{ - claude_code::{ClaudeCodeBackend, ClaudeCodeBackendConfig}, - codex::{CodexBackend, CodexBackendConfig}, - }, AgentWrapperCancelHandle, AgentWrapperCompletion, AgentWrapperError, AgentWrapperEvent, - AgentWrapperEventKind, AgentWrapperGateway, AgentWrapperKind, AgentWrapperRunControl, - AgentWrapperRunRequest, + AgentWrapperEventKind, AgentWrapperRunControl, AgentWrapperRunRequest, }; use anyhow::{anyhow, Result}; use axum::{ @@ -31,6 +26,7 @@ use transport_api_types::{ use world_api::SharedWorldBindingSnapshot; use crate::gateway_runtime::{prepare_linux_world_entry_launcher, LinuxWorldPlacementContext}; +use crate::prompt_fulfillment::PromptFulfillmentBridge; const MEMBER_ROLE: &str = "member"; const SESSION_HANDLE_SCHEMA_V1: &str = "agent_api.session.handle.v1"; @@ -122,25 +118,23 @@ impl MemberRuntimeManager { .chain(prepared_launcher.env.iter().cloned()) .collect::>(); - let (gateway, agent_kind) = build_gateway_for_backend( + let prompt_fulfillment = PromptFulfillmentBridge::for_member_backend( &dispatch.resolved_runtime.backend_kind, prepared_launcher.launcher_path.clone(), )?; - let initial_prompt = dispatch - .initial_prompt - .clone() - .unwrap_or_else(|| runtime_bootstrap_prompt().to_string()); - let AgentWrapperRunControl { handle, cancel } = match gateway - .run_control( - &agent_kind, - AgentWrapperRunRequest { - prompt: initial_prompt, - working_dir: Some(placement.working_dir.clone()), - timeout: None, - env: runtime_env.clone(), - extensions: BTreeMap::new(), - }, + let initial_prompt = dispatch.initial_prompt.clone().ok_or_else(|| { + crate::service::BadRequestError::new( + "member_dispatch.initial_prompt is required for launch-time first turn".to_string(), ) + })?; + let AgentWrapperRunControl { handle, cancel } = match prompt_fulfillment + .run_control(AgentWrapperRunRequest { + prompt: initial_prompt, + working_dir: Some(placement.working_dir.clone()), + timeout: None, + env: runtime_env.clone(), + extensions: BTreeMap::new(), + }) .await { Ok(control) => control, @@ -260,8 +254,10 @@ impl MemberRuntimeManager { let span_id = format!("spn_{}", uuid::Uuid::now_v7()); self.reserve_turn_slot(&active, &span_id)?; - let (gateway, agent_kind) = - build_gateway_for_backend(&active.backend_kind, active.binary_path.clone())?; + let prompt_fulfillment = PromptFulfillmentBridge::for_member_backend( + &active.backend_kind, + active.binary_path.clone(), + )?; let mut extensions = BTreeMap::new(); extensions.insert( SESSION_RESUME_EXTENSION_V1.to_string(), @@ -271,17 +267,14 @@ impl MemberRuntimeManager { }), ); - let AgentWrapperRunControl { handle, cancel } = match gateway - .run_control( - &agent_kind, - AgentWrapperRunRequest { - prompt: req.prompt.clone(), - working_dir: Some(active.working_dir.clone()), - timeout: None, - env: active.env.clone(), - extensions, - }, - ) + let AgentWrapperRunControl { handle, cancel } = match prompt_fulfillment + .run_control(AgentWrapperRunRequest { + prompt: req.prompt.clone(), + working_dir: Some(active.working_dir.clone()), + timeout: None, + env: active.env.clone(), + extensions, + }) .await { Ok(control) => control, @@ -537,10 +530,6 @@ impl MemberRuntimeManager { } } -fn runtime_bootstrap_prompt() -> &'static str { - "Enter persistent Substrate world-scoped member mode. Keep this control session attached for the lifetime of the parent REPL session and do not exit until the client cancels the run." -} - fn validate_member_runtime_binary( dispatch: &MemberDispatchRequestV1, ) -> Result { @@ -554,37 +543,6 @@ fn validate_member_runtime_binary( Ok(path.to_path_buf()) } -fn build_gateway_for_backend( - backend_kind: &MemberRuntimeBackendKindV1, - binary_path: std::path::PathBuf, -) -> Result<(AgentWrapperGateway, AgentWrapperKind)> { - let mut gateway = AgentWrapperGateway::new(); - let binary_path = Some(binary_path); - - let agent_kind = match backend_kind { - MemberRuntimeBackendKindV1::Codex => { - gateway - .register(Arc::new(CodexBackend::new(CodexBackendConfig { - binary: binary_path, - ..Default::default() - }))) - .map_err(map_wrapper_error)?; - AgentWrapperKind::new("codex").map_err(map_wrapper_error)? - } - MemberRuntimeBackendKindV1::ClaudeCode => { - gateway - .register(Arc::new(ClaudeCodeBackend::new(ClaudeCodeBackendConfig { - binary: binary_path, - ..Default::default() - }))) - .map_err(map_wrapper_error)?; - AgentWrapperKind::new("claude_code").map_err(map_wrapper_error)? - } - }; - - Ok((gateway, agent_kind)) -} - struct PreparedMemberRuntimeLauncher { launcher_path: std::path::PathBuf, launcher_dir: std::path::PathBuf, diff --git a/crates/world-service/src/prompt_fulfillment.rs b/crates/world-service/src/prompt_fulfillment.rs new file mode 100644 index 000000000..4775eb105 --- /dev/null +++ b/crates/world-service/src/prompt_fulfillment.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +use agent_api::{AgentWrapperError, AgentWrapperRunControl, AgentWrapperRunRequest}; +use anyhow::Result; +use substrate_gateway::adapter_runtime::{GatewayAdapterBackendKind, GatewayAdapterRuntime}; +use transport_api_types::MemberRuntimeBackendKindV1; + +pub(crate) struct PromptFulfillmentBridge { + runtime: GatewayAdapterRuntime, +} + +impl PromptFulfillmentBridge { + pub(crate) fn for_member_backend( + backend_kind: &MemberRuntimeBackendKindV1, + binary_path: PathBuf, + ) -> Result { + let backend_kind = match backend_kind { + MemberRuntimeBackendKindV1::Codex => GatewayAdapterBackendKind::Codex, + MemberRuntimeBackendKindV1::ClaudeCode => GatewayAdapterBackendKind::ClaudeCode, + }; + let runtime = GatewayAdapterRuntime::for_backend(backend_kind, binary_path)?; + Ok(Self { runtime }) + } + + pub(crate) async fn run_control( + &self, + request: AgentWrapperRunRequest, + ) -> Result { + self.runtime.run_control(request).await + } +} diff --git a/crates/world-service/tests/member_runtime_world_placement_v1.rs b/crates/world-service/tests/member_runtime_world_placement_v1.rs index 2fb65f7cd..00d583763 100644 --- a/crates/world-service/tests/member_runtime_world_placement_v1.rs +++ b/crates/world-service/tests/member_runtime_world_placement_v1.rs @@ -69,7 +69,7 @@ fn make_member_dispatch_request( run_id: "run-member-world-placement-test".to_string(), world_id: world_id.to_string(), world_generation, - initial_prompt: None, + initial_prompt: Some("first turn".to_string()), resolved_runtime: ResolvedMemberRuntimeDescriptorV1 { backend_kind: MemberRuntimeBackendKindV1::Codex, binary_path: binary_path.display().to_string(), diff --git a/crates/world-service/tests/streamed_execute_cancel_v1.rs b/crates/world-service/tests/streamed_execute_cancel_v1.rs index 43887b9c0..4857451fe 100644 --- a/crates/world-service/tests/streamed_execute_cancel_v1.rs +++ b/crates/world-service/tests/streamed_execute_cancel_v1.rs @@ -102,7 +102,7 @@ fn make_member_dispatch_request_with_backend( run_id: run_id.to_string(), world_id: world_id.to_string(), world_generation, - initial_prompt: None, + initial_prompt: Some("first turn".to_string()), resolved_runtime: ResolvedMemberRuntimeDescriptorV1 { backend_kind, binary_path: binary_path.display().to_string(), diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 06a90d654..8cc5c34b1 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -124,14 +124,64 @@ Keep concise, actionable, and security-focused. - Add fields as optional/backwards-compatible (or bump schema with clear migration rules) and update any schema/fixtures/tests/docs. - Acceptance: `substrate world doctor --json` includes the world OS identity fields when world is enabled; missing data degrades gracefully; docs explain the semantics. +## Deferred Product Follow-Ups — Guest World Images / Linux `guest_rootfs` + +- **P2 – Additional guest distro support beyond Ubuntu/Debian** + - Problem: `guest_rootfs` is intentionally Ubuntu/Debian-first, but the long-term goal is guest-distro flexibility decoupled from the host distro. + - Work: + - Define how additional blessed guest images are introduced, validated, and documented. + - Add per-image compatibility rules so unsupported images fail closed with clear remediation. + - Extend validation to prove cross-host-distro behavior remains deterministic. + - Acceptance: Substrate can support more than one blessed Linux guest distro without redefining the backend contract. + +- **P2 – Non-APT guest-image provisioning support** + - Problem: provisioning-time system-package support is currently manager-limited; additional guest distros will require non-APT provisioning paths. + - Work: + - Add manager-aware provisioning support for guest images that do not use APT (`pacman`, `dnf`, `yum`, `apk`, `zypper`, etc.). + - Keep runtime `world deps current sync|install` probe-only for system packages across all managers. + - Ensure manager selection is derived from in-world identity/probes rather than host PATH. + - Acceptance: supported guest images can provision system packages with their native package manager while preserving the explicit provisioning-only posture. + +- **P2 – User-imported or arbitrary guest images** + - Problem: the first ship should bless one built-in image family, but advanced users may eventually want to bring their own guest images. + - Work: + - Define an allowlisted import/registration workflow for operator-supplied guest images. + - Specify provenance, validation, and fail-closed handling for untrusted or malformed images. + - Clarify how imported images participate in doctor output, compatibility checks, and remediation. + - Acceptance: operators can register approved guest images without weakening image provenance or safety guarantees. + - **P2 – World image selection/pinning (Linux guest-rootfs, Lima, WSL)** - Problem: once we have guest-like world images across platforms, operators will want to pin the world OS/distro per workspace for reproducibility and parity (instead of relying on whatever Substrate ships by default or whatever the host happens to be). - Work: - Add a first-class “world image” identity and selection surface (workspace + global), surfaced in `substrate world doctor --json` and health output. - Support pinning per workspace (e.g., `.substrate/settings.yaml`) with a stable identifier and upgrade story. - - Ensure `world deps provision` and other guest-only flows validate that the active image is supported and report actionable remediation when it is not. + - Ensure `substrate world enable --provision-deps` and other guest-only flows validate that the active image is supported and report actionable remediation when it is not. - Acceptance: operators can pin the world image per workspace and see the effective image in doctor/health; behavior is reproducible across machines; unsafe/unknown images fail closed with clear guidance. +- **P3 – Make Linux `guest_rootfs` the default backend** + - Problem: first ship should keep `host_native` as the default, but long term the stronger guest-backed model may become the preferred Linux execution posture. + - Work: + - Gather validation and operator evidence that `guest_rootfs` is stable enough for default use. + - Define the exact rollout criteria and fallback/remediation expectations before changing the default. + - Update docs, install defaults, doctor messaging, and smoke coverage if the default flips. + - Acceptance: Linux can move to `guest_rootfs` by default without surprising existing operators or weakening fail-closed guarantees. + +- **P3 – Dedicated CLI surface for guest-rootfs/image lifecycle** + - Problem: first ship uses a script-first warm flow, but long term image lifecycle likely needs a first-class CLI contract. + - Work: + - Design a dedicated CLI for rootfs/image bootstrap, repair, reset, and inspection. + - Keep the CLI aligned with existing world enable/provisioning flows rather than creating competing operator workflows. + - Define cross-platform boundaries so Linux guest-rootfs lifecycle and future image lifecycle stay coherent with Lima/WSL. + - Acceptance: operators can manage guest-rootfs/image lifecycle through a stable CLI surface instead of helper scripts alone. + +- **P4 – Full VM-backed Linux backend (only if later justified)** + - Problem: the current direction intentionally avoids a heavyweight Linux VM, but some future constraints may justify an optional VM-backed Linux world. + - Work: + - Define concrete criteria that would justify a VM-backed Linux backend instead of continuing to invest in host-kernel guest-rootfs execution. + - Compare resource cost, isolation guarantees, and operator complexity against `guest_rootfs`. + - If pursued, keep the backend contract explicit so VM-backed Linux does not silently replace the lighter-weight path. + - Acceptance: any future Linux VM backend is introduced as an explicit, justified backend choice rather than accidental scope creep. + - **P1 fs_diff parity (agent HTTP + PTY)** - *Agent HTTP path:* Today only replay/local backends attach `fs_diff`; agent-routed non-PTY commands drop the diff. Extend `transport-api-types::ExecuteResponse` / `world-service` so `/v1/execute` returns `fs_diff: Option` and update the shell to record it in completion spans. Acceptance: `fs_diff` shows up in `trace.jsonl` for agent HTTP runs. - *PTY sessions:* Interactive runs still lack filesystem diffs. Explore capturing post-exit diffs via overlayfs/copydiff and plumb the result through the PTY telemetry path so REPL + `substrate -i` sessions produce the same audit artifacts as non-PTY commands. Document caveats (long-running PTYs, partial diffs) and add tests to prove PTY diffs land in spans. diff --git a/docs/USAGE.md b/docs/USAGE.md index df52c5592..421a14138 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -114,11 +114,13 @@ substrate agent stop --session --json - `substrate agent start` is the canonical public root prompt-taking surface and remains host-only in v1. - `substrate agent turn` is the canonical public follow-up surface and requires the exact pair `(--session , --backend )`. -- `substrate agent reattach` is attached-owner recovery only for the same durable session; it does not submit a prompt. +- `substrate agent reattach` is attached-owner recovery only for the same durable session; it does not submit a prompt and fails closed when durable continuity is absent or stale. +- `substrate agent fork` allocates a successor durable host session without reinterpreting it as a prompt-taking action; the returned successor starts as `parked_resumable` with no attached owner loop. - `substrate agent stop` is the canonical closeout path for attached and parked durable host sessions. - `substrate agent status --json` is the authoritative parked-session read surface for live-runtime `posture`, `attached_participant_id`, and `pending_inbox_count`. - Public follow-up never falls back to `participant_id`, legacy `session_handle_id`, `active_session_handle_id`, or `internal.uaa_session_id`; those selector shapes fail closed. - There is still no default-agent routing and there is still no public world-root `start`. +- Prompt-bearing host execution and Linux world-member execution now fulfill through the gateway-mediated adapter seam while preserving the same visible `start` / `turn` / `reattach` / `stop` lifecycle contract. - On Linux, exact world-member follow-up reuses the retained member slot and submits through the typed `/v1/member_turn/stream` path. - Detached host recovery stays on `substrate agent reattach --session `. - Detached world follow-up fails closed until `reattach` restores an active host owner. diff --git a/docs/contracts/substrate-gateway-backend-adapter-protocol.md b/docs/contracts/substrate-gateway-backend-adapter-protocol.md index e875d546e..6d2bdef41 100644 --- a/docs/contracts/substrate-gateway-backend-adapter-protocol.md +++ b/docs/contracts/substrate-gateway-backend-adapter-protocol.md @@ -18,6 +18,9 @@ Concrete rules: - Adapter dispatch starts only after the stable `:` backend id from the selection contract has been accepted as a fixed input. +- Prompt-bearing host orchestration and world-member execution must reach adapter dispatch through + the gateway-owned runtime seam rather than by rebuilding direct backend-registration tables in + shell-local or world-local runtime code. - One selected backend id maps to one adapter dispatch target for the duration of a run. Internal provider or wrapper mechanics remain hidden behind that adapter identity. - If adapter resolution fails for a backend id that has already passed selection and allowlist @@ -86,5 +89,8 @@ The implementation and verification surfaces for this contract are expected to s - `docs/project_management/packs/draft/substrate-gateway-backend-adapter-contract/gateway-backend-adapter-protocol-spec.md` - `docs/project_management/adrs/draft/ADR-0041-substrate-gateway-backend-adapter-contract.md` +- `crates/gateway/src/adapter_runtime.rs` +- `crates/shell/src/execution/prompt_fulfillment.rs` +- `crates/world-service/src/prompt_fulfillment.rs` - the Unified Agent API normative specs and backend harnesses cited by ADR-0041 - the standalone gateway structured-event normalization surfaces cited by ADR-0041 diff --git a/docs/contracts/substrate-gateway-runtime-parity.md b/docs/contracts/substrate-gateway-runtime-parity.md index b5370c50a..82dc25a53 100644 --- a/docs/contracts/substrate-gateway-runtime-parity.md +++ b/docs/contracts/substrate-gateway-runtime-parity.md @@ -11,6 +11,7 @@ The owned runtime/parity surface covers: - the typed lifecycle/status control path behind `substrate world gateway sync`, `status`, and `restart` - the authority boundary between shell/operator surfaces and the world/backend runtime +- the gateway-owned prompt-fulfillment seam used by prompt-bearing host orchestration and world-member execution - the Linux/macOS/Windows parity guarantees for lifecycle/status semantics - the allowed divergence list for backend transport and bootstrap mechanics @@ -19,6 +20,7 @@ Concrete rules: - Shell/operator entrypoints consume a typed runtime surface; they must not rebuild gateway state via raw exec probing, backend-private config files, or log scraping. - `crates/shell/src/builtins/world_gateway.rs` and later shell execution wiring are consumers of that typed surface. They may render operator output and exit codes, but they do not own runtime truth. - The typed runtime surface is owned by the world/backend boundary: `crates/world-service`, shared request/response models in `crates/transport-api-types`, and transport helpers in `crates/transport-api-client`. +- Prompt-bearing shell/world orchestration paths that consume integrated gateway bindings must enter the gateway-owned runtime seam (`crates/gateway/src/adapter_runtime.rs`) through their bridge layers, not through duplicated shell-local or world-local backend-registration tables. - Non-isolated gateway lifecycle/status flows must not depend on reusable session-world creation or recovery when no world-backed isolation is required for the gateway runtime. In that posture, the typed runtime surface may use a stable synthetic runtime identity derived from the effective lifecycle binding inputs. - Isolated gateway lifecycle/status flows must continue to require a real compatible world/session identity and any required world-backed attachment primitives. - `substrate world gateway status --json` remains governed by `docs/contracts/substrate-gateway-status-schema.md`; this contract may not widen the JSON field list or redefine `client_wiring.*`. @@ -56,9 +58,12 @@ Concrete rules: The later execution slices must keep the runtime/parity contract aligned across these surfaces: - `crates/shell/src/builtins/world_gateway.rs` +- `crates/gateway/src/adapter_runtime.rs` +- `crates/shell/src/execution/prompt_fulfillment.rs` - `crates/shell/tests/world_gateway.rs` - `crates/world-service/src/lib.rs` - `crates/world-service/src/gateway_runtime.rs` +- `crates/world-service/src/prompt_fulfillment.rs` - `crates/world-service/tests/socket_activation.rs` - `crates/transport-api-types/src/lib.rs` - `crates/transport-api-client/src/lib.rs` diff --git a/docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md b/docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md index 74748ef9d..595fc2616 100644 --- a/docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md +++ b/docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md @@ -1,13 +1,254 @@ -# linux_guest_rootfs_backend — Decision Register (Stub) +# linux_guest_rootfs_backend — Decision Register -This decision register is required by `docs/project_management/standards/ADR_STANDARD_AND_TEMPLATE.md`. +Status: draft decisions accepted for ADR-0009 alignment. -Status: stub (intentionally incomplete). +## DR-0001 — Linux guest image format and builder -Planned decision entries (to be written before execution begins): -- DR-0001 — Linux guest rootfs image format and builder (A: debootstrap; B: OCI rootfs unpack) -- DR-0002 — Rootfs persistence model for system packages (A: persistent overlay upper; B: rebuild-on-demand) -- DR-0003 — Storage locations and ownership (A: `/var/lib/substrate/world-rootfs`; B: `$SUBSTRATE_HOME`-scoped) -- DR-0004 — Warm/provision command surface (A: CLI `substrate world rootfs warm`; B: script-only `scripts/linux/world-rootfs-warm.sh`) -- DR-0005 — How `world deps provision` detects readiness (A: doctor capability; B: on-demand probing) +**Decision owner(s):** Shell / World / Installer maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` +**Problem / Context** +- Linux needs a lightweight guest userspace that is not coupled to the host distro. +- The first shipped guest image should be Ubuntu/Debian-family, but the backend contract must stay distro-flexible. + +**Option A — Build the guest rootfs with `debootstrap`** +- **Pros:** straightforward Ubuntu-first bootstrap; apt-native. +- **Cons:** host-side tooling is Debian-shaped; weak fit for non-Debian hosts; future distro expansion becomes awkward. +- **Cascading implications:** image creation logic would be implicitly tied to one distro family. +- **Risks:** architectural overfitting to Ubuntu as the backend concept rather than the first shipped image. +- **Unlocks:** quick first prototype for Ubuntu-only hosts. +- **Quick wins / low-hanging fruit:** easy first implementation if host distro alignment is acceptable. + +**Option B — OCI-style rootfs unpack** +- **Pros:** host-distro agnostic; fits the goal of decoupling guest distro from host distro; preserves future image flexibility. +- **Cons:** slightly more image acquisition/unpack machinery in first ship. +- **Cascading implications:** image identity and unpack lifecycle become first-class backend concerns. +- **Risks:** more upfront work around provenance and unpack flow. +- **Unlocks:** Ubuntu-first without baking Ubuntu into the backend abstraction. +- **Quick wins / low-hanging fruit:** first ship can still bless one Ubuntu/Debian image while keeping the contract general. + +**Recommendation** +- **Selected:** Option B — OCI-style rootfs unpack +- **Rationale (crisp):** the backend exists to decouple world distro from host distro; `debootstrap` would hardcode the wrong abstraction too early. + +**Follow-up tasks (explicit)** +- Define the blessed Ubuntu/Debian image source and unpack procedure in planning specs. +- Specify immutable base-image storage and verification requirements. +- Add smoke/manual validation that a non-Debian host can run the Ubuntu/Debian guest world. + +## DR-0002 — Guest rootfs persistence model + +**Decision owner(s):** Shell / World maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` + +**Problem / Context** +- Provisioning only adds value if installed guest packages remain available across later executions. +- The persistence model also determines whether workspaces can contaminate one another. + +**Option A — Immutable base image plus persistent writable overlay** +- **Pros:** keeps image base immutable; preserves apt-installed state; avoids cross-workspace mutation of the shared base. +- **Cons:** overlay lifecycle and cleanup are more complex. +- **Cascading implications:** planning must define overlay keying, persistence, and repair rules. +- **Risks:** more moving pieces in readiness checks and cleanup. +- **Unlocks:** cleaner future image pinning and reproducibility. +- **Quick wins / low-hanging fruit:** provisioning and repair can target the same persistent overlay path. + +**Option B — One persistent mutable rootfs per image** +- **Pros:** simpler initial mental model; fewer storage layers. +- **Cons:** image base drifts over time; cross-workspace package bleed is likely; reproducibility degrades. +- **Cascading implications:** future image pinning or reset semantics become much messier. +- **Risks:** mutable global guest state becomes hard to reason about and debug. +- **Unlocks:** lower first-pass implementation complexity only. +- **Quick wins / low-hanging fruit:** minimal storage bookkeeping. + +**Recommendation** +- **Selected:** Option A — Immutable base image plus persistent writable overlay +- **Rationale (crisp):** persistence is required, but a mutable global rootfs would undercut the reproducibility and safety story too early. + +**Follow-up tasks (explicit)** +- Define overlay keying and lifecycle in the planning specs. +- Define what counts as repair vs reset for the persistent overlay. +- Add tests proving apt-installed packages persist while the base image remains immutable. + +## DR-0003 — Storage location and ownership + +**Decision owner(s):** Shell / World / Installer maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` + +**Problem / Context** +- Guest images and overlays are backend infrastructure, not per-workspace artifacts. +- Storage location determines privilege posture, cleanup semantics, and multi-user behavior. + +**Option A — System-owned Substrate storage under `/var/lib/substrate/...`** +- **Pros:** strong ownership boundary; clearer multi-user semantics; naturally stays out of workspaces and `$SUBSTRATE_HOME`. +- **Cons:** warm/repair requires explicit privilege use. +- **Cascading implications:** warm script and doctor/remediation must acknowledge privileged setup. +- **Risks:** slightly higher operator friction for initial warm flow. +- **Unlocks:** consistent backend storage posture with current Linux world-service/systemd model. +- **Quick wins / low-hanging fruit:** easier to reason about cleanup, ownership, and access boundaries. + +**Option B — User-scoped storage under `$SUBSTRATE_HOME/...`** +- **Pros:** lower privilege friction. +- **Cons:** blurs backend vs user-cache ownership; weaker multi-user story; more duplication. +- **Cascading implications:** backend infrastructure would be mixed into user state. +- **Risks:** accidental drift into workspace/user-managed paths and weaker operational boundaries. +- **Unlocks:** easier local-only experimentation. +- **Quick wins / low-hanging fruit:** fewer privileged steps. + +**Recommendation** +- **Selected:** Option A — System-owned Substrate storage under `/var/lib/substrate/...` +- **Rationale (crisp):** this is backend infrastructure, not a user convenience cache; it needs a system-owned boundary. + +**Follow-up tasks (explicit)** +- Define exact directory layout under `/var/lib/substrate/...`. +- Add safety checks that reject workspace or `$SUBSTRATE_HOME` placement. +- Document required ownership and repair expectations in the warm playbook. + +## DR-0004 — Warm command surface + +**Decision owner(s):** Shell / Installer maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` + +**Problem / Context** +- Operators need an explicit way to create and repair the guest rootfs/image store. +- The surface should match current Substrate backend bootstrap posture without freezing a premature CLI. + +**Option A — New first-class CLI command** +- **Pros:** polished discoverability; future-friendly if Substrate grows a full image-management platform. +- **Cons:** commits a new CLI surface too early; increases current contract and docs scope. +- **Cascading implications:** CLI semantics and cross-platform parity would need to be locked now. +- **Risks:** the first command surface may ossify before the backend lifecycle is understood. +- **Unlocks:** eventual unified world-image UX. +- **Quick wins / low-hanging fruit:** none beyond discoverability. + +**Option B — Script-first warm flow** +- **Pros:** matches the existing `scripts/mac/lima-warm.sh` pattern; keeps first ship operationally explicit; avoids premature CLI expansion. +- **Cons:** less elegant than a CLI surface; discoverability depends on doctor/remediation messaging. +- **Cascading implications:** warm/remediation text becomes part of the operator contract. +- **Risks:** script-first flows can feel less integrated if docs are weak. +- **Unlocks:** lets the backend lifecycle settle before freezing a CLI. +- **Quick wins / low-hanging fruit:** fastest alignment with existing Substrate backend bootstrap patterns. + +**Recommendation** +- **Selected:** Option B — Script-first warm flow +- **Rationale (crisp):** the rootfs warm path is backend bootstrap, not a second provisioning UX; script-first matches current Substrate patterns and keeps the CLI surface smaller in v1. + +**Follow-up tasks (explicit)** +- Create `scripts/linux/world-rootfs-warm.sh`. +- Ensure doctor and provisioning remediation point to that script. +- Define idempotent repair behavior and explicit privilege use in specs/playbooks. + +## DR-0005 — Readiness and doctor surface + +**Decision owner(s):** Shell / World maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md`, `docs/BACKLOG.md` + +**Problem / Context** +- Operators and planning need one public source of truth for whether guest-rootfs is selected, what image is active, and whether provisioning is actually possible. + +**Option A — Doctor-backed readiness contract** +- **Pros:** one shared truth for operators, planning, and runtime remediation; aligns with the backlog need for world OS identity in doctor output. +- **Cons:** requires additive doctor schema work. +- **Cascading implications:** runtime provisioning and error handling should reuse the same readiness logic. +- **Risks:** doctor fields must stay stable once published. +- **Unlocks:** better troubleshooting and planning clarity. +- **Quick wins / low-hanging fruit:** one clear operator check before provisioning or execution. + +**Option B — On-demand probing only** +- **Pros:** smaller initial surface; fewer immediately documented fields. +- **Cons:** poor observability; failures only appear at execution time; hidden duplicate readiness logic is likely. +- **Cascading implications:** provisioning and execution paths each need their own probe/remediation flow. +- **Risks:** operational confusion and inconsistent failure reporting. +- **Unlocks:** reduced upfront schema work only. +- **Quick wins / low-hanging fruit:** none that justify the visibility loss. + +**Recommendation** +- **Selected:** Option A — Doctor-backed readiness contract +- **Rationale (crisp):** guest-rootfs state must be explicit and inspectable; hidden readiness logic would just recreate the same ambiguity this ADR is trying to remove. + +**Follow-up tasks (explicit)** +- Define the additive `world doctor --json` fields for backend kind, image id, world OS identity, and provisioning readiness. +- Ensure provisioning and runtime remediation reuse that readiness logic. +- Add tests and docs for the new doctor fields. + +## DR-0006 — Backend selection vs image selection + +**Decision owner(s):** Shell / World maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` + +**Problem / Context** +- The backend choice and the guest-image choice are different concerns. +- Collapsing them would make the first shipped Ubuntu image look like the architecture rather than the first implementation. + +**Option A — Separate backend and image config surfaces** +- **Pros:** correct abstraction; future image expansion does not require redefining the backend concept; clearer doctor/runtime reporting. +- **Cons:** more validation rules. +- **Cascading implications:** config docs and planning specs must define the relationship between the two keys. +- **Risks:** slightly more surface area for misconfiguration. +- **Unlocks:** distro-flexible backend contract with Ubuntu-first implementation. +- **Quick wins / low-hanging fruit:** easier later addition of more guest images. + +**Option B — One combined backend/profile selector** +- **Pros:** smaller first-ship surface; fewer invalid states in v1. +- **Cons:** bakes the first image into the backend abstraction; later growth becomes awkward. +- **Cascading implications:** every new image would force CLI/config contract churn. +- **Risks:** architectural overfitting to the first shipped image. +- **Unlocks:** marginally simpler initial config. +- **Quick wins / low-hanging fruit:** shorter docs for v1 only. + +**Recommendation** +- **Selected:** Option A — Separate backend and image config surfaces +- **Rationale (crisp):** the point is to decouple host distro from world distro; that requires backend and image to be separate concepts, even if v1 only ships one blessed image. + +**Follow-up tasks (explicit)** +- Define `world.linux.backend` and `world.linux.image` behavior in planning specs/docs. +- Define default-image behavior when backend is `guest_rootfs` and image is unset. +- Add config-validation tests and doctor rendering for both surfaces. + +## DR-0007 — Linux backend rollout posture + +**Decision owner(s):** Shell / World / Installer maintainers +**Date:** 2026-05-24 +**Status:** Accepted +**Related docs:** `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` + +**Problem / Context** +- The backend is a substantial change in Linux execution posture. +- The rollout posture determines how much existing Linux behavior changes by default. + +**Option A — Make `guest_rootfs` the new Linux default** +- **Pros:** maximum parity and faster adoption. +- **Cons:** high blast radius; increases operational and debugging risk for existing Linux users. +- **Cascading implications:** every Linux flow becomes subject to the new backend immediately. +- **Risks:** regressions are harder to contain; warm/readiness issues would impact all Linux users. +- **Unlocks:** strongest immediate dogfooding. +- **Quick wins / low-hanging fruit:** none that outweigh the rollout risk. + +**Option B — Keep `host_native` default and make `guest_rootfs` explicit opt-in** +- **Pros:** preserves existing Linux behavior; constrains risk; makes the new backend a deliberate operator choice. +- **Cons:** adoption is slower; parity benefits are not automatic. +- **Cascading implications:** planning can validate the new backend without destabilizing existing Linux workflows. +- **Risks:** some users may not discover the new backend quickly. +- **Unlocks:** safer rollout and clearer troubleshooting. +- **Quick wins / low-hanging fruit:** immediate availability for targeted users without forcing migration. + +**Recommendation** +- **Selected:** Option B — Keep `host_native` default and make `guest_rootfs` explicit opt-in +- **Rationale (crisp):** the backend is strategically important, but it is too large a shift to make default before it proves itself in real Linux usage. + +**Follow-up tasks (explicit)** +- Define opt-in config and remediation docs. +- Keep existing Linux host-native behavior unchanged by default. +- Add explicit doctor output showing whether `guest_rootfs` is active or merely configured. diff --git a/docs/project_management/_archived/next/linux_guest_rootfs_backend/integration_map.md b/docs/project_management/_archived/next/linux_guest_rootfs_backend/integration_map.md deleted file mode 100644 index ab2cba4cc..000000000 --- a/docs/project_management/_archived/next/linux_guest_rootfs_backend/integration_map.md +++ /dev/null @@ -1,9 +0,0 @@ -# linux_guest_rootfs_backend — Integration Map (Stub) - -Status: stub (intentionally incomplete). - -This document will track dependencies on: -- `docs/project_management/_archived/world_deps_selection_layer/` (WDL2 `world deps provision` contract) -- `docs/project_management/_archived/p0-agent-hub-isolation-hardening/` (full cage / Landlock prerequisites) -- `docs/project_management/_archived/next/world-sync/` (future image pinning via workspace init; out of scope for the initial ship) - diff --git a/docs/project_management/_archived/next/linux_guest_rootfs_backend/manual_testing_playbook.md b/docs/project_management/_archived/next/linux_guest_rootfs_backend/manual_testing_playbook.md deleted file mode 100644 index 925f574ea..000000000 --- a/docs/project_management/_archived/next/linux_guest_rootfs_backend/manual_testing_playbook.md +++ /dev/null @@ -1,9 +0,0 @@ -# linux_guest_rootfs_backend — Manual Testing Playbook (Stub) - -Status: stub (intentionally incomplete). - -This playbook will define manual validation for: -- Linux guest-rootfs warm flow -- `world_fs.isolation=full` executing inside the guest rootfs -- Linux `substrate world deps provision` installing apt packages into the guest rootfs without mutating host OS packages - diff --git a/docs/project_management/_archived/next/linux_guest_rootfs_backend/plan.md b/docs/project_management/_archived/next/linux_guest_rootfs_backend/plan.md index 13726d1b9..80f21b98f 100644 --- a/docs/project_management/_archived/next/linux_guest_rootfs_backend/plan.md +++ b/docs/project_management/_archived/next/linux_guest_rootfs_backend/plan.md @@ -1,7 +1,247 @@ -# Linux Guest RootFS Backend — Plan (Stub) +# Plan: Linux Guest RootFS Backend and Guest-Only Linux Provisioning -This planning pack will define the execution triads (code/test/integration), tasks, specs, smoke scripts, and validation artifacts for: -- `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md` +This is the Phase 2 `PLAN` artifact for: +- `docs/project_management/_archived/next/linux_guest_rootfs_backend/spec.md` -Status: stub (intentionally incomplete). +## Goal +Turn the approved spec into an implementation approach that is reviewable before task breakdown or code starts. The plan must preserve Linux host safety, keep rollout opt-in, and realize the exact storage, readiness, doctor, and provisioning contract defined in `spec.md`. +## Major Components + +### 1. Built-in image manifest and verification +- `crates/world/` +- Responsibilities: + - define the built-in image id `ubuntu-24.04-amd64` + - define the pinned OCI reference and digest metadata + - verify digest, `/etc/os-release`, and architecture after unpack + +### 2. Guest-rootfs storage and lifecycle +- `crates/world/` +- `scripts/linux/world-rootfs-warm.sh` +- Responsibilities: + - create and repair `/var/lib/substrate/world-rootfs/` + - materialize the immutable base image + - scaffold generic storage layout without requiring workspace-specific input + - preserve overlays during repair + - avoid broadening warm into scope-local runtime lifecycle + +### 3. Shared readiness evaluator +- `crates/world/` +- `crates/world-service/` +- `crates/shell/` +- Responsibilities: + - compute one readiness result reused by doctor, provisioning, and runtime remediation + - distinguish host-native, ready guest-rootfs, unready guest-rootfs, and unsupported-image states + +### 4. Shell contract and config routing +- `crates/shell/` +- `crates/world-backend-factory/` +- Responsibilities: + - resolve `world.linux.backend` + - resolve `world.linux.image` + - apply default-image behavior only for `guest_rootfs` + - route `substrate world enable --provision-deps` + +### 5. World execution path +- `crates/world/` +- `crates/world-service/` +- Responsibilities: + - bootstrap guest-backed non-PTY and PTY execution + - enforce fail-closed runtime behavior when guest-rootfs is configured but not ready + +### 6. Full-isolation guest-root semantics +- `crates/world/` +- Responsibilities: + - make the guest rootfs the effective system root for `world_fs.isolation=full` + - ensure host `/usr`, `/etc`, and similar directories do not act as guest system state + +### 7. Doctor schema and reporting +- `crates/world-service/` +- Responsibilities: + - emit additive `world doctor --json` fields for backend, image id, world OS identity, and provisioning readiness + - distinguish configured backend from ready or unready state + +## Dependencies +- Image manifest and verification rules must exist before warm or readiness logic can be trusted. +- Storage layout and lifecycle rules must exist before shell routing can reliably distinguish ready from unready guest-rootfs. +- Shared readiness logic must exist before doctor, provisioning, and runtime remediation can stay consistent. +- Shell/backend selection must be fixed before world execution can safely branch between host-native and guest-rootfs. +- World execution must exist before full-isolation re-rooting can be validated meaningfully. +- Full-isolation guest-root behavior must be trustworthy before provisioning is enabled. + +## Implementation Order + +### Step 1: Lock the built-in image contract +- Define the built-in `ubuntu-24.04-amd64` image manifest shape. +- Define pinned OCI reference, digest, expected OS identity, and expected architecture. +- Define post-unpack verification rules. + +Why first: +- warm, readiness, and doctor reporting all depend on a single authoritative image contract. + +### Step 2: Implement `/var/lib/substrate/world-rootfs/` layout and warm behavior +- Create the exact directory layout from the spec. +- Enforce `root:substrate` ownership and required permissions. +- Implement idempotent warm behavior: + - no-op when healthy + - repair missing or damaged image/layout state + - preserve healthy overlays + - clean incomplete `tmp/` artifacts only + - avoid pre-creating workspace-scoped overlays +- Implement explicit privilege posture through `sudo` or immediate failure. + +Why second: +- selection and runtime cannot depend on guest-rootfs until the storage model is real. + +### Step 3: Implement overlay keying and scope resolution +- Resolve workspace root canonical path. +- Derive `ws-` for workspace-scoped overlays. +- Use `global` when no workspace root exists. +- Create scope-local overlays lazily on first execution or provisioning for that scope. +- Reuse the same overlay for execution and provisioning within one scope. +- Keep lazy scope-local overlay creation narrow: no image fetch, provenance repair, base-store repair, ownership repair, or generic warm work in runtime paths. + +Why third: +- persistence, no-cross-workspace contamination, and provisioning semantics depend on exact scope keying. + +### Step 4: Implement the shared readiness evaluator +- Evaluate: + - configured backend + - selected image id + - built-in image support + - base-image presence and verification + - `/etc/os-release` verification + - architecture match + - permissions and directory layout +- Return structured readiness state reused verbatim by: + - doctor + - provisioning + - runtime remediation +- Do not fail readiness solely because a scope-local overlay has not yet been created. + +Why fourth: +- this is the contract seam that keeps operator reporting and runtime behavior aligned. + +### Step 5: Wire backend and image semantics through shell and backend factory +- Parse `world.linux.backend`. +- Parse `world.linux.image`. +- Apply default-image behavior only when backend is `guest_rootfs`. +- Ignore image activation for `host_native`. +- Reject unsupported images deterministically. + +Why fifth: +- only after readiness exists can the shell route backend behavior without inventing duplicate logic. + +### Step 6: Introduce guest-rootfs execution +- Add guest-backed non-PTY execution. +- Add guest-backed PTY execution. +- Refuse execution with shared readiness reasons when guest-rootfs is configured but unavailable. + +Why sixth: +- guest execution must exist before isolation or provisioning can be proven against the real backend. + +### Step 7: Re-root full isolation onto the guest rootfs +- Make `world_fs.isolation=full` use the guest rootfs as the effective system root. +- Remove dependence on host system directories as the guest system view. +- Fail closed with exit `5` when invariants break. + +Why seventh: +- isolation is the core safety property that makes guest-rootfs materially different from host-native execution. + +### Step 8: Enable `substrate world enable --provision-deps` +- Gate provisioning on: + - `guest_rootfs` selected + - built-in image supported + - shared readiness true +- Install packages into the scope-local overlay only. +- Keep host-native refusal explicit and unchanged. + +Why eighth: +- provisioning is valid only after storage, execution, and isolation guarantees are real. + +### Step 9: Finish doctor output, docs, and validation flow +- Emit the additive doctor fields from the spec. +- Update docs to match warm, readiness, and provisioning behavior exactly. +- Add manual validation and smoke coverage for repair, readiness, execution, isolation, and provisioning persistence. + +Why last: +- docs and validation must describe the final runtime contract, not an earlier design draft. + +## Risks and Mitigation + +### Risk: image verification and runtime behavior drift apart +- Mitigation: + - define the built-in image manifest first + - make warm and readiness use the same verification rules + +### Risk: directory ownership or permissions become inconsistent across repair paths +- Mitigation: + - keep one canonical layout under `/var/lib/substrate/world-rootfs/` + - make warm the only repair entrypoint + - test repair idempotence explicitly + +### Risk: overlays contaminate each other across workspaces +- Mitigation: + - key overlays by image id plus scope + - hash canonical workspace roots + - test cross-workspace separation explicitly + +### Risk: doctor, runtime, and provisioning disagree about readiness +- Mitigation: + - implement one shared readiness evaluator + - forbid duplicate probing logic in shell or doctor code paths + +### Risk: full isolation still leaks host system assumptions +- Mitigation: + - validate guest `/etc/os-release` + - inspect mount and root layout in integration tests + - treat invariant failure as exit `5` + +### Risk: Linux-specific work regresses other platforms +- Mitigation: + - preserve platform seams + - keep macOS and Windows in compile/test parity coverage + +## Parallel vs Sequential Work + +### Must stay sequential +- Built-in image contract before storage implementation +- Storage implementation before overlay scope keying +- Overlay scope keying before shared readiness +- Shared readiness before shell routing and doctor schema +- Shell routing before guest execution +- Guest execution before full-isolation re-rooting +- Full-isolation re-rooting before provisioning enablement + +### Can run in parallel after prerequisites exist +- Shell config tests and doctor-schema rendering after shared readiness exists +- PTY and non-PTY execution work after backend bootstrap semantics are stable +- Warm-script repair tests and storage-permission tests after storage layout is implemented +- Manual playbook drafting and smoke-script authoring after readiness and provisioning semantics are fixed + +## Verification Checkpoints + +### Checkpoint 1: image and storage contract +- The built-in image manifest, verification rules, directory layout, ownership, and permissions behave exactly as specified. + +### Checkpoint 2: scope-local persistence +- Execution and provisioning reuse one overlay within a scope, and different workspaces do not share overlays. + +### Checkpoint 3: readiness consistency +- Doctor, runtime remediation, and provisioning report the same readiness status and reason for the same configuration. + +### Checkpoint 4: guest execution +- Commands run against guest userspace instead of the host distro userspace. + +### Checkpoint 5: full isolation +- `world_fs.isolation=full` uses guest-root semantics and fails closed when that invariant breaks. + +### Checkpoint 6: provisioning +- `substrate world enable --provision-deps` works only for supported ready guest worlds and leaves host package state unchanged. + +### Checkpoint 7: operator truthfulness +- Doctor output and docs reflect configured backend, active backend kind, image id, OS identity, and provisioning readiness exactly. + +## Phase 3 Readiness +- No blocking design questions remain in the spec. +- Phase 3 can break the work into task-sized units directly from this plan. diff --git a/docs/project_management/_archived/next/linux_guest_rootfs_backend/spec.md b/docs/project_management/_archived/next/linux_guest_rootfs_backend/spec.md new file mode 100644 index 000000000..3d5a68b3b --- /dev/null +++ b/docs/project_management/_archived/next/linux_guest_rootfs_backend/spec.md @@ -0,0 +1,314 @@ +# Spec: Linux Guest RootFS Backend and Guest-Only Linux Provisioning + +## Assumptions +1. This is the Phase 1 `SPECIFY` artifact for the Linux guest-rootfs feature and remains implementation-free. +2. Linux keeps `host_native` as the default backend in the first ship; `guest_rootfs` is explicit opt-in. +3. The first shipped guest image family is Ubuntu/Debian and the only built-in Linux guest image in v1 is `ubuntu-24.04-amd64`. +4. The warm and repair surface is script-first through `scripts/linux/world-rootfs-warm.sh`, not a new CLI verb in v1. +5. macOS and Windows do not change behavior in this feature; they remain compile/test parity and non-regression platforms only. + +## Objective +Build a Linux `guest_rootfs` backend that lets Substrate run world execution against a guest userspace decoupled from the host distro, while preserving fail-closed isolation and enabling explicit guest-only APT provisioning through `substrate world enable --provision-deps` without mutating the Linux host OS. + +Success means: +- Linux operators can opt into a guest userspace without using a full VM. +- `world_fs.isolation=full` uses the guest rootfs as the effective system root. +- `substrate world enable --provision-deps` succeeds only inside supported ready guest worlds and never mutates the host OS. + +## Tech Stack +- Language: Rust 2021 +- Core crates: + - `crates/shell` + - `crates/world` + - `crates/world-service` + - `crates/world-backend-factory` +- Supporting automation: + - `scripts/linux/world-rootfs-warm.sh` +- Supporting docs: + - `docs/WORLD.md` + - `docs/reference/world/deps/README.md` + +## Commands +- Build: `cargo build --workspace` +- Format check: `cargo fmt --all -- --check` +- Lint: `cargo clippy --workspace --all-targets -- -D warnings` +- Full tests: `cargo test --workspace -- --nocapture` +- Shell-focused tests: `cargo test -p shell -- --nocapture` +- World backend tests: `cargo test -p world -- --nocapture` +- World-service tests: `cargo test -p world-service -- --nocapture` + +## Project Structure +- `src/` -> top-level CLI entrypoints only +- `crates/shell/` -> config resolution, CLI behavior, provisioning UX, remediation +- `crates/world/` -> Linux backend behavior, rootfs lifecycle, isolation, overlays +- `crates/world-service/` -> execution endpoints and doctor/readiness reporting +- `crates/world-backend-factory/` -> backend selection plumbing if routing changes +- `scripts/linux/` -> privileged Linux warm and repair helpers +- `docs/` -> operator and architecture documentation +- `docs/project_management/_archived/next/linux_guest_rootfs_backend/` -> feature-local skill artifacts + +## Behavior Contract + +### Backend and image semantics +- `world.linux.backend` is required to resolve to exactly one of: + - `host_native` + - `guest_rootfs` +- Default value of `world.linux.backend` is `host_native`. +- `world.linux.image` is a stable image-identity string and applies only when `world.linux.backend=guest_rootfs`. +- If `world.linux.backend=host_native`: + - Linux uses the existing host-native backend. + - `world.linux.image` is ignored for activation and provisioning. + - `substrate world enable --provision-deps` MUST fail with exit `4`. +- If `world.linux.backend=guest_rootfs` and `world.linux.image` is unset: + - Substrate MUST select the built-in default image `ubuntu-24.04-amd64`. +- If `world.linux.backend=guest_rootfs` and `world.linux.image` is set to any value other than `ubuntu-24.04-amd64` in v1: + - world execution MUST reject activation with exit `4`, + - provisioning MUST reject activation with exit `4`, + - doctor MUST report the backend as configured but not ready, + - the error and doctor reason MUST identify the unsupported image id. +- Setting `world.linux.image` MUST NOT implicitly switch `world.linux.backend` from `host_native` to `guest_rootfs`. + +### Blessed image source, provenance, and verification +- The only built-in guest image id in v1 is `ubuntu-24.04-amd64`. +- Its source of truth is a Substrate-owned built-in image manifest that maps image id to: + - an immutable OCI image reference, + - an expected OCI manifest digest, + - expected guest OS identity, + - expected architecture. +- `scripts/linux/world-rootfs-warm.sh` MUST fetch the image by pinned digest, not by floating tag. +- Warm-up MUST use OCI-style layer unpack semantics to materialize a root filesystem tree. +- Warm-up MUST verify, in order: + - the fetched OCI manifest digest matches the built-in manifest, + - the unpacked rootfs contains a valid `/etc/os-release`, + - `/etc/os-release` identifies an Ubuntu or Debian-family guest matching the built-in manifest, + - the unpacked rootfs architecture matches the host architecture expected by the built-in manifest. +- If any verification step fails: + - the base image MUST be treated as unusable, + - readiness MUST be false, + - runtime execution and provisioning MUST fail with exit `4`, + - doctor and remediation MUST surface the verification failure reason. + +### Storage layout, ownership, and permissions +- All guest-rootfs assets MUST live under `/var/lib/substrate/world-rootfs/`. +- The exact v1 directory layout is: + - `global/` MAY be created during warm as generic storage scaffolding. + - `ws-/` directories are created lazily on first execution or provisioning for that resolved scope. + +```text +/var/lib/substrate/world-rootfs/ + images/ + ubuntu-24.04-amd64/ + manifest.json + digest + rootfs/ + overlays/ + ubuntu-24.04-amd64/ + global/ + upper/ + work/ + state.json + ws-/ + upper/ + work/ + state.json + tmp/ +``` + +- Ownership and permissions are: + - `/var/lib/substrate/world-rootfs/` -> `root:substrate`, mode `0750` + - `images/` and `overlays/` -> `root:substrate`, mode `0750` + - unpacked base-image directories under `images//rootfs/` -> `root:substrate`, directories `0755`, files `0644`, with the tree treated as immutable after verification + - overlay directories under `overlays/...` -> `root:substrate`, mode `0750` + - `tmp/` -> `root:substrate`, mode `0750` +- Guest images or overlays MUST NEVER be stored inside a workspace or under `$SUBSTRATE_HOME`. + +### Overlay keying, persistence, cleanup, repair, and reset +- The persistence boundary for writable guest state is one overlay per: + - image id, and + - execution scope. +- Execution scope is: + - `ws-` when a workspace root is resolved, + - `global` when no workspace root is resolved. +- `` is the SHA-256 of the canonical realpath of the workspace root. +- Scope-local overlays are created lazily by runtime execution or `substrate world enable --provision-deps` when that scope is used for the first time. +- `scripts/linux/world-rootfs-warm.sh` MUST NOT require a workspace input and MUST NOT be responsible for pre-creating `ws-` overlays. +- Provisioning and execution within the same scope MUST reuse the same overlay. +- In v1, one scope-local overlay is reused per workspace scope; future internal concurrency or layering may evolve without changing the warm contract. +- Different workspaces MUST NOT share overlays. +- The immutable base image is shared by all overlays for the same image id. +- Overlay cleanup rules: + - `scripts/linux/world-rootfs-warm.sh` MAY delete incomplete download or unpack artifacts under `tmp/`, + - `scripts/linux/world-rootfs-warm.sh` MUST NOT delete healthy overlays, + - v1 does not perform automatic garbage collection of unused overlays. +- Repair vs reset rules: + - repair means restoring missing directories, correcting ownership or permissions, re-fetching or re-unpacking a missing or digest-mismatched base image, and preserving existing overlays, + - reset means explicit operator deletion of an overlay directory before re-running warm or execution, + - warm MUST perform repair automatically when possible, + - warm MUST NOT perform reset implicitly. + +### Warm-script behavior and privilege posture +- `scripts/linux/world-rootfs-warm.sh` is the only v1 warm and repair entrypoint. +- The script MUST be idempotent: + - if the built-in image, directory layout, permissions, and generic storage scaffolding are already correct, it exits successfully without changing semantic state, + - if directories, permissions, or the base-image materialization are missing or damaged, it repairs them in place, + - it never provisions APT packages, + - it never deletes healthy overlays, + - it never pre-creates workspace-scoped overlays. +- The script MUST require explicit privilege for writes under `/var/lib/substrate/world-rootfs/`. +- If invoked as a non-root user: + - it MUST either re-exec via `sudo` or fail immediately with actionable instructions that `sudo` is required, + - it MUST NOT partially create root-owned state as an unprivileged user. +- Runtime execution and `substrate world enable --provision-deps` MUST NOT broaden lazy scope-local overlay creation into image fetch, provenance repair, base-store repair, ownership repair, or generic warm work. + +### Shared readiness logic +- Substrate MUST compute guest-rootfs readiness through one shared readiness evaluator reused by: + - `substrate world doctor --json`, + - `substrate world enable --provision-deps`, + - runtime execution and runtime remediation. +- The shared readiness evaluator MUST consider: + - configured backend, + - configured image id, + - built-in image support, + - base-image presence and digest verification, + - `/etc/os-release` verification, + - architecture match, + - required directory layout and permissions. +- The shared readiness evaluator MUST return enough structured state to determine: + - whether provisioning is supported, + - whether execution is ready, + - the exact reason when readiness is false, + - the correct remediation message when readiness is false. +- Missing scope-local overlay state for an otherwise valid scope MUST NOT make readiness false by itself. +- On first execution or provisioning for a scope: + - Substrate MUST attempt to create the scope-local overlay lazily, + - successful creation makes that overlay reusable for later execution and provisioning in the same scope, + - failure to create the overlay MUST fail the triggering operation with an actionable error. + +### `substrate world doctor --json` schema +- `substrate world doctor --json` MUST expose the following additive fields: + +```json +{ + "world": { + "backend": { + "configured": "host_native | guest_rootfs", + "kind": "linux_host_native | linux_guest_rootfs", + "ready": true + }, + "image": { + "id": "ubuntu-24.04-amd64 | null" + }, + "os": { + "id": "ubuntu | debian | | null", + "version_id": "24.04 | | null", + "pretty_name": "Ubuntu 24.04 LTS | | null", + "arch": "x86_64 | aarch64 | | null" + }, + "provisioning": { + "supported": true, + "ready": true, + "reason": null + } + } +} +``` + +- Field semantics are: + - `world.backend.configured` -> config-selected backend value before readiness checks + - `world.backend.kind` -> actual backend class for this platform + - `world.backend.ready` -> whether the configured backend is ready for runtime execution + - `world.image.id` -> active built-in guest image id when `guest_rootfs` is configured; `null` for `host_native` + - `world.os.*` -> guest OS identity for ready `guest_rootfs`, host OS identity for `host_native`, `null` values when `guest_rootfs` is configured but not yet verified + - `world.provisioning.supported` -> whether provisioning is allowed for the configured backend/image + - `world.provisioning.ready` -> whether provisioning can run immediately + - `world.provisioning.reason` -> `null` when ready, otherwise the exact blocking reason reused by provisioning and runtime remediation + +### Runtime execution behavior +- When `world.linux.backend=host_native`: + - Linux world execution keeps the existing host-native behavior. +- When `world.linux.backend=guest_rootfs`: + - Linux world execution MUST run against the selected guest userspace rather than the host distro userspace, + - the backend MUST NOT silently fall back to `host_native`, + - if readiness is false, runtime remediation MUST reuse the shared readiness reason and point to `scripts/linux/world-rootfs-warm.sh` when warm or repair is required. +- When `world.linux.backend=guest_rootfs` and `world_fs.isolation=full` is requested: + - the guest rootfs MUST be the isolation root, + - host `/usr`, `/etc`, and related system directories MUST NOT be rebound into the command view as the effective world system directories, + - if the backend cannot satisfy that guarantee, execution MUST fail closed with exit `5`. + +### Provisioning behavior +- The Linux provisioning command is exactly: + - `substrate world enable --provision-deps` +- When `world.linux.backend=host_native`: + - behavior remains explicit failure with exit `4`, + - output MUST state that provisioning is unsupported because it would mutate the host OS, + - doctor MUST report `world.backend.configured=host_native` and `world.provisioning.supported=false`. +- When `world.linux.backend=guest_rootfs` and readiness is true: + - provisioning is supported, + - package-manager support is apt-only in v1, + - package installation MUST occur inside the persistent guest overlay for the resolved scope only, + - host OS package state MUST remain unchanged. +- When `world.linux.backend=guest_rootfs` and readiness is false: + - provisioning MUST fail with exit `4`, + - the failure MUST reuse the shared readiness reason, + - remediation MUST point to `scripts/linux/world-rootfs-warm.sh` when warm or repair is the blocking step. + +## Code Style +```rust +fn ensure_guest_rootfs_ready(state: &GuestRootfsState) -> anyhow::Result<()> { + if !state.ready { + anyhow::bail!("guest_rootfs is selected but the warmed rootfs is unavailable"); + } + Ok(()) +} +``` + +Conventions: +- Return `Result` on fallible paths. +- Add `anyhow::Context` at filesystem, mount, unpack, and process-execution boundaries. +- Keep Linux-specific behavior behind platform seams or `#[cfg(target_os = "linux")]`. +- Keep operator-facing failures singular and actionable. + +## Testing Strategy +- Unit tests: + - config parsing for backend and image selection + - default-image behavior and unsupported-image failures + - readiness-state mapping + - guest-rootfs path safety and storage invariants + - doctor-field rendering for `host_native`, ready `guest_rootfs`, and unready `guest_rootfs` +- Integration tests: + - prove `guest_rootfs` runs against guest userspace rather than host distro userspace + - prove `world_fs.isolation=full` uses guest root semantics + - prove provisioning persists in guest overlays across later runs in the same scope + - prove different workspaces do not share overlays + - prove `host_native` provisioning refuses without invoking host package-manager behavior +- Manual validation: + - warm and repair flow + - backend selection and doctor output + - guest execution on a non-Debian host + - provisioning persistence and no-host-mutation evidence + +## Boundaries +- Always: + - keep `host_native` as the Linux default in v1 + - fail closed when readiness, image support, or isolation guarantees are missing + - preserve `substrate world enable --provision-deps` as the Linux provisioning surface + - reuse one readiness evaluator for doctor, provisioning, and runtime remediation +- Ask first: + - adding a new public CLI verb for rootfs or image management + - expanding beyond `ubuntu-24.04-amd64` in this feature + - changing storage ownership away from `/var/lib/substrate/world-rootfs/` +- Never: + - mutate the Linux host OS package set through Substrate + - silently fall back from `guest_rootfs` to `host_native` + - store guest base images or overlays in the workspace or under `$SUBSTRATE_HOME` + +## Success Criteria +- Linux can opt into `world.linux.backend=guest_rootfs` without changing existing default behavior for `host_native`. +- `guest_rootfs` execution proves guest OS identity distinct from the host distro on a non-Debian Linux host. +- `world_fs.isolation=full` uses the guest rootfs as the effective system root and fails closed when that guarantee breaks. +- `substrate world enable --provision-deps` succeeds only for ready `ubuntu-24.04-amd64` guest worlds and installs packages into scope-local guest overlays only. +- Linux host-native provisioning still rejects and leaves host package state untouched. +- `substrate world doctor --json` exposes backend, image, OS identity, and readiness truth that matches runtime and provisioning behavior exactly. + +## Open Questions +- None blocking Phase 3 task breakdown. diff --git a/docs/project_management/_archived/next/linux_guest_rootfs_backend/tasks.md b/docs/project_management/_archived/next/linux_guest_rootfs_backend/tasks.md new file mode 100644 index 000000000..87eaa02e2 --- /dev/null +++ b/docs/project_management/_archived/next/linux_guest_rootfs_backend/tasks.md @@ -0,0 +1,72 @@ +# Phase 3 Tasks: Linux Guest RootFS Backend and Guest-Only Linux Provisioning + +Phase 1 `spec.md` and Phase 2 `plan.md` were loaded and accepted as the authoritative inputs for Phase 3. `ADR-0009` and the decision register were also reviewed as supporting context. No contradiction was found that requires reopening Phase 1 or Phase 2. + +- [ ] Task: Introduce the built-in guest image contract for `ubuntu-24.04-amd64`. + - Acceptance: A single authoritative image-manifest surface defines the built-in image id, pinned OCI reference, expected manifest digest, expected guest OS identity, and expected architecture; unsupported image ids are classified deterministically for later exit-4 handling. + - Verify: `cargo test -p world -- --nocapture` + - Files: `crates/world/src/lib.rs`, new `crates/world/src/guest_rootfs/mod.rs`, new `crates/world/src/guest_rootfs/image_manifest.rs`, new `crates/world/tests/guest_rootfs_manifest.rs` + +- [ ] Task: Add canonical guest-rootfs storage-path and permission helpers for `/var/lib/substrate/world-rootfs/`. + - Acceptance: The exact v1 layout is modeled in one place; ownership and mode expectations are explicit; helpers reject workspace or `$SUBSTRATE_HOME` placement; scope resolution primitives can distinguish `global` from workspace-scoped storage. + - Verify: `cargo test -p world -- --nocapture` + - Files: new `crates/world/src/guest_rootfs/storage.rs`, new `crates/world/src/guest_rootfs/scope.rs`, `crates/world/src/guest_rootfs/mod.rs`, new `crates/world/tests/guest_rootfs_storage.rs` + +- [ ] Task: Implement `scripts/linux/world-rootfs-warm.sh` as the only global warm/repair entrypoint. + - Acceptance: The script fetches by pinned digest, unpacks OCI layers into the immutable base-image tree, verifies digest plus `/etc/os-release` plus architecture, repairs directory layout and permissions, cleans incomplete `tmp/` artifacts only, preserves healthy overlays, and never pre-creates workspace overlays. + - Verify: `sudo scripts/linux/world-rootfs-warm.sh`; rerun the same command to confirm idempotence + - Files: `scripts/linux/world-rootfs-warm.sh`, new `crates/world/src/guest_rootfs/verification.rs`, `crates/world/src/guest_rootfs/image_manifest.rs`, new `crates/world/tests/guest_rootfs_warm_contract.rs` + +- [ ] Task: Add lazy scope-local overlay creation and reuse rules. + - Acceptance: The runtime/provisioning path can derive `global` or `ws-`, create an empty scope-local overlay lazily on first use, reuse the same overlay for later execution and provisioning in that scope, and avoid broadening lazy creation into fetch/repair/permission work. + - Verify: `cargo test -p world -- --nocapture` + - Files: new `crates/world/src/guest_rootfs/overlay.rs`, `crates/world/src/guest_rootfs/scope.rs`, `crates/world/src/guest_rootfs/mod.rs`, new `crates/world/tests/guest_rootfs_overlay_scope.rs` + +- [ ] Task: Implement the shared guest-rootfs readiness evaluator. + - Acceptance: One structured readiness result covers configured backend, selected image id, built-in image support, base-image presence, digest verification, `/etc/os-release`, architecture match, and directory/permission health; missing scope-local overlay state alone does not make readiness false. + - Verify: `cargo test -p world -- --nocapture` + - Files: new `crates/world/src/guest_rootfs/readiness.rs`, `crates/world/src/guest_rootfs/mod.rs`, `crates/world/src/lib.rs`, new `crates/world/tests/guest_rootfs_readiness.rs` + +- [ ] Task: Wire `world.linux.backend` and `world.linux.image` semantics through config resolution and Linux backend selection. + - Acceptance: `host_native` remains the default; `guest_rootfs` is explicit opt-in; default-image behavior applies only when `guest_rootfs` is selected; setting `world.linux.image` never flips the backend implicitly; unsupported images resolve to deterministic exit-4-ready state. + - Verify: `cargo test -p shell -- --nocapture`; `cargo test -p world-backend-factory -- --nocapture` + - Files: `crates/shell/src/execution/config_model.rs`, `crates/world-backend-factory/src/lib.rs`, `crates/world/src/lib.rs`, new `crates/shell/tests/world_linux_backend_config.rs` + +- [ ] Task: Add the guest-rootfs non-PTY execution path. + - Acceptance: Linux non-PTY execution uses the guest userspace when `guest_rootfs` is selected, never falls back silently to `host_native`, consults shared readiness before execution, and surfaces shared remediation when the backend is unready. + - Verify: `cargo test -p world-service -- --nocapture` + - Files: `crates/world/src/lib.rs`, `crates/world/src/session.rs`, `crates/world/src/exec.rs`, `crates/world-service/src/service.rs`, new `crates/world-service/tests/guest_rootfs_nonpty.rs` + +- [ ] Task: Add the guest-rootfs PTY/stream execution path. + - Acceptance: PTY execution honors the same backend/image selection and readiness rules as non-PTY execution and runs against the guest userspace rather than the host distro userspace. + - Verify: `cargo test -p world-service -- --nocapture` + - Files: `crates/world/src/stream.rs`, `crates/world/src/session.rs`, `crates/world-service/src/pty.rs`, new `crates/world-service/tests/guest_rootfs_pty.rs` + +- [ ] Task: Enforce full-isolation guest-root semantics for `world_fs.isolation=full`. + - Acceptance: Full isolation uses the guest rootfs as the effective system root, host `/usr`, `/etc`, and related system directories are not the effective guest system view, and invariant failure exits fail-closed with code `5`. + - Verify: `cargo test -p world-service full_isolation_nonpty -- --nocapture`; `cargo test -p world-service full_isolation_pty -- --nocapture` + - Files: `crates/world/src/isolation.rs`, `crates/world/src/session.rs`, `crates/world-service/src/service.rs`, `crates/world-service/tests/full_isolation_nonpty.rs`, `crates/world-service/tests/full_isolation_pty.rs` + +- [ ] Task: Gate `substrate world enable --provision-deps` on guest-rootfs readiness and scope-local overlay semantics. + - Acceptance: `host_native` continues to fail with exit `4` and no host package mutation; `guest_rootfs` provisioning reuses shared readiness, lazily creates the scope-local overlay when needed, installs only into that overlay, and surfaces unsupported-image or warm-required failures without attempting host mutation. + - Verify: `cargo test -p shell -- --nocapture` + - Files: `crates/shell/src/builtins/world_enable/runner.rs`, `crates/shell/src/builtins/world_enable/runner/provision_deps.rs`, `crates/shell/src/builtins/world_deps/surfaces.rs`, new `crates/shell/tests/world_enable_provision_deps_guest_rootfs.rs` + +- [ ] Task: Extend `substrate world doctor --json` with the additive guest-rootfs fields. + - Acceptance: Doctor reports `world.backend.configured`, `world.backend.kind`, `world.backend.ready`, `world.image.id`, `world.os.*`, and `world.provisioning.{supported,ready,reason}` with the exact host-native vs guest-rootfs semantics from the spec and shared readiness as the single source of truth. + - Verify: `cargo test -p transport-api-types -- --nocapture`; `cargo test -p shell -- --nocapture`; `substrate world doctor --json | jq '.world.backend, .world.image, .world.os, .world.provisioning'` + - Files: `crates/transport-api-types/src/lib.rs`, `crates/world-service/src/handlers.rs`, `crates/shell/src/execution/platform/linux.rs`, new `crates/shell/tests/doctor_scopes_guest_rootfs.rs` + +- [ ] Task: Publish operator-facing config and runtime docs for guest-rootfs. + - Acceptance: Docs describe the script-first warm flow, `world.linux.backend` / `world.linux.image` behavior, guest-rootfs-only provisioning, doctor semantics, and fail-closed remediation; operator-facing provisioning references remain `substrate world enable --provision-deps`. + - Verify: `cargo test -p shell world_deps_apt_fail_early_wdap1 -- --nocapture`; `rg -n "substrate world enable --provision-deps" docs/WORLD.md docs/reference/world/deps/README.md docs/reference/config/world.md docs/CONFIGURATION.md` + - Files: `docs/WORLD.md`, `docs/reference/world/deps/README.md`, `docs/reference/config/world.md`, `docs/CONFIGURATION.md` + +- [ ] Task: Add Linux regression, smoke, and manual verification coverage for guest-rootfs. + - Acceptance: Coverage proves image verification, overlay reuse/separation, readiness consistency, guest execution, full isolation, provisioning persistence, and host-native rejection; manual validation lives in normal doc/smoke surfaces rather than planning-pack artifacts. + - Verify: `cargo test -p world -- --nocapture`; `cargo test -p world-service -- --nocapture`; `cargo test -p shell -- --nocapture`; `bash docs/project_management/_archived/next/linux_guest_rootfs_backend/smoke/linux-smoke.sh` + - Files: new `crates/world/tests/guest_rootfs_integration.rs`, new `crates/world-service/tests/guest_rootfs_execution.rs`, new `crates/shell/tests/world_enable_provision_deps_guest_rootfs.rs`, new `docs/project_management/_archived/next/linux_guest_rootfs_backend/smoke/linux-smoke.sh`, new `docs/manual_verification/linux_guest_rootfs.md` + +Assumptions carried into this breakdown: +- I assumed the implementation will introduce a dedicated `crates/world/src/guest_rootfs/` module; if the code lands in a different existing Linux-world seam, the ordering still holds. +- The handoff’s missing `manual_testing_playbook.md` and `integration_map.md` references are non-blocking because those stubs were intentionally deleted; manual verification is therefore scoped to ordinary docs/smoke surfaces, not planning-pack artifacts. diff --git a/docs/project_management/adrs/PROVISIONING_SURFACE_RECONCILIATION.md b/docs/project_management/adrs/PROVISIONING_SURFACE_RECONCILIATION.md new file mode 100644 index 000000000..8fffb774c --- /dev/null +++ b/docs/project_management/adrs/PROVISIONING_SURFACE_RECONCILIATION.md @@ -0,0 +1,104 @@ +# Provisioning Surface Reconciliation + +Status: reference note +Date: 2026-05-24 + +## Purpose + +This note reconciles the currently implemented world-deps provisioning surface with older ADRs and archived planning material that still describe superseded commands or selection models. + +## Current authoritative sources + +The live operator-facing provisioning surface is: + +- `substrate world enable --provision-deps` + +The most authoritative current references are: + +- [`docs/reference/world/deps/README.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/reference/world/deps/README.md) + - authoritative operator-facing summary + - states that runtime `substrate world deps current sync|install` never mutates system packages + - points missing-package remediation at `substrate world enable --provision-deps` +- [`docs/project_management/packs/implemented/world-deps-apt-provisioning/contract.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/implemented/world-deps-apt-provisioning/contract.md) + - authoritative contract for APT-backed system-package provisioning +- [`docs/project_management/packs/implemented/add-non-apt-system-package-provisioning-support/contract.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/implemented/add-non-apt-system-package-provisioning-support/contract.md) + - authoritative manager-aware contract for `apt` and `pacman` +- [`docs/project_management/packs/implemented/world-deps-packages-bundles-contract/contract.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/implemented/world-deps-packages-bundles-contract/contract.md) + - authoritative contract for inventory structure, enabled-set resolution, and runtime fail-early posture +- [`docs/WORLD.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/WORLD.md) + - authoritative world/runtime architecture summary for provisioning request profiles + +The current code surfaces that implement the live contract are: + +- [`crates/shell/src/execution/cli.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/cli.rs) + - defines `--provision-deps` on `substrate world enable` +- [`crates/shell/src/builtins/world_enable/runner.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/builtins/world_enable/runner.rs) + - implements `world enable --provision-deps` +- [`crates/shell/src/builtins/world_enable/runner/provision_deps.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/builtins/world_enable/runner/provision_deps.rs) + - implements backend support gates and manager-aware provisioning +- [`crates/shell/src/builtins/world_deps/surfaces.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/builtins/world_deps/surfaces.rs) + - implements runtime probe-only behavior and missing-package remediation + +## Current implemented support model + +The current implemented model is: + +- Inventory may declare system-package requirements using `install.method=apt` or `install.method=pacman`. +- Runtime `substrate world deps current sync|install` is probe-only for system-package items. +- Runtime probes use read-only package-manager queries and fail early with remediation. +- The only Substrate surface that performs system-package mutation for world-deps is `substrate world enable --provision-deps`. +- Mixed `apt` and `pacman` requirement sets are rejected before mutation. + +Current platform/backend posture: + +| Platform/backend | `substrate world enable --provision-deps` | Runtime `substrate world deps current sync|install` | +| --- | --- | --- | +| macOS Lima guest backend | Supported | Probe-only; fails early when required system packages are missing | +| Linux host-native backend | Unsupported; no host OS mutation | Probe-only; fails early with “no host OS mutation” guidance | +| Windows | Unsupported | Probe-only; fails early with “unsupported on Windows” guidance | + +## Historically useful but stale on command surface + +These documents are still useful for understanding why the system evolved the way it did, but they are not authoritative for the current operator-facing provisioning command or current world-deps selection model. + +- [`docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md) + - historically useful for install-class framing + - stale on command surface because it still specifies `substrate world deps provision` + - stale on enabled-set selection because it still specifies `world-deps.selection.yaml` +- [`docs/project_management/_archived/world_deps_selection_layer/`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/_archived/world_deps_selection_layer/) + - historical planning/archive material + - stale on both command surface and selection model +- [`docs/project_management/adrs/draft/ADR-0030-provisioning-otter.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0030-provisioning-otter.md) + - still valuable as the rationale for the explicit provisioning-time workflow + - includes option analysis for the older `substrate world deps provision` command + - authoritative direction in this ADR is still `substrate world enable --provision-deps`, not the rejected option text + +## Relationship between the ADR chain + +- [`ADR-0002`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md) is best treated as historical framing, not as the current command contract. +- [`ADR-0011`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/implemented/ADR-0011-world-deps-packages-bundles-contract.md) is the stronger source for today’s inventory and enabled-set model. +- [`ADR-0030`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0030-provisioning-otter.md) defines the explicit provisioning-time posture that the current implementation follows. +- [`ADR-0033`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0033-routing-weasel.md) extends that provisioning surface to manager-aware routing for `pacman` as well as `apt`. +- [`ADR-0009`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md) extends the existing `world enable --provision-deps` contract for future Linux guest-rootfs support; it must not reintroduce `world deps provision`. + +## What should be updated next + +To make the documentation chain consistent end to end, the next updates should be: + +1. Add an explicit supersession note to [`ADR-0002`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md) stating that: + - the current provisioning command is `substrate world enable --provision-deps` + - the current enabled-set model comes from the packages/bundles contract rather than `world-deps.selection.yaml` +2. Update any lingering non-archived docs that still say `substrate world deps provision` to `substrate world enable --provision-deps`, unless they are intentionally discussing rejected historical options. +3. Update any lingering non-archived docs that still present `world-deps.selection.yaml` as current operator truth. +4. Keep archived materials archived; do not “fix” them into looking current unless they are explicitly being revived. +5. If the team wants `ADR-0030`, `ADR-0031`, `ADR-0032`, `ADR-0033`, and `ADR-0035` treated as fully landed, move or restate them in a way that matches their implemented status instead of leaving them only under `draft/`. + +## Practical reading order + +If someone wants the current truth quickly, they should read in this order: + +1. [`docs/reference/world/deps/README.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/reference/world/deps/README.md) +2. [`docs/project_management/packs/implemented/world-deps-packages-bundles-contract/contract.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/implemented/world-deps-packages-bundles-contract/contract.md) +3. [`docs/project_management/packs/implemented/world-deps-apt-provisioning/contract.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/implemented/world-deps-apt-provisioning/contract.md) +4. [`docs/project_management/packs/implemented/add-non-apt-system-package-provisioning-support/contract.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/implemented/add-non-apt-system-package-provisioning-support/contract.md) +5. The implementing code in [`crates/shell/src/builtins/world_enable/runner.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/builtins/world_enable/runner.rs), [`crates/shell/src/builtins/world_enable/runner/provision_deps.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/builtins/world_enable/runner/provision_deps.rs), and [`crates/shell/src/builtins/world_deps/surfaces.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/builtins/world_deps/surfaces.rs) diff --git a/docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md b/docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md index 8ab6586a0..52e58f0bb 100644 --- a/docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md +++ b/docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md @@ -1,183 +1,296 @@ -# ADR-0009 — Linux Guest RootFS Backend + Linux System Packages Provisioning +# ADR-0009 — Linux Guest RootFS Backend + Distro-Decoupled Full Isolation and Provisioning ## Status - Status: Draft -- Date (UTC): 2026-01-11 +- Date (UTC): 2026-05-24 - Owner(s): Shell / World / Installer maintainers ## Scope - Feature directory: `docs/project_management/_archived/next/linux_guest_rootfs_backend/` - Sequencing spine: `docs/project_management/packs/sequencing.json` - Standards: + - `docs/project_management/system/standards/adr/ADR_STANDARD_AND_TEMPLATE.md` - `docs/project_management/system/standards/planning/PLANNING_RESEARCH_AND_ALIGNMENT_STANDARD.md` - `docs/project_management/system/standards/triad/TASK_TRIADS_AND_FEATURE_SETUP.md` - - `docs/project_management/system/standards/triad/TASK_TRIADS_WORKTREE_EXECUTION_STANDARD.md` (automation/worktree execution) + - `docs/project_management/system/standards/triad/TASK_TRIADS_WORKTREE_EXECUTION_STANDARD.md` ## Related Docs -- Prior ADR (world-deps provisioning posture): `docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md` +- Prior ADR (world-deps install-class and provisioning posture): `docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md` +- Current provisioning surface: `docs/project_management/adrs/draft/ADR-0030-provisioning-otter.md` +- Future non-APT guest-image routing context: `docs/project_management/adrs/draft/ADR-0033-routing-weasel.md` +- Future backend capability alignment: `docs/project_management/adrs/draft/ADR-0010-world-backend-contract-and-capability-divergence.md` - Prior hardening track (full cage / Landlock): `docs/project_management/_archived/p0-agent-hub-isolation-hardening/ADR-0001-agent-hub-runtime-config-and-isolation.md` -- Plan (stub): `docs/project_management/_archived/next/linux_guest_rootfs_backend/plan.md` -- Decision Register (stub; required): `docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md` -- Integration Map (stub): `docs/project_management/_archived/next/linux_guest_rootfs_backend/integration_map.md` -- Manual Playbook (stub): `docs/project_management/_archived/next/linux_guest_rootfs_backend/manual_testing_playbook.md` - World architecture: `docs/WORLD.md` -- World-sync (future pinning surface; out of scope here): `docs/project_management/_archived/next/world-sync/plan.md` +- Operator reference for provisioning/runtime world-deps behavior: `docs/reference/world/deps/README.md` +- Skill Phase 2 plan: `docs/project_management/_archived/next/linux_guest_rootfs_backend/plan.md` +- Decision Register: `docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md` +- Skill Phase 1 spec: `docs/project_management/_archived/next/linux_guest_rootfs_backend/spec.md` +- World-image pinning roadmap (explicitly out of scope here): `docs/BACKLOG.md` ## Executive Summary (Operator) -ADR_BODY_SHA256: a253781bcf9a23768d525e62ff91b771be1b3aa4594ad4c30fd48b2e7ccda3c7 - -ADR_BODY_SHA256: +ADR_BODY_SHA256: 056a47bf1016afd1fc53a0b8ec7bf419da811ee3b7bdcf0ce77abca92cc5b875 ### Changes (operator-facing) -- Linux can provision `system_packages` without mutating the host OS - - Existing: on Linux, `world-service` runs on the host and `world deps provision` is intentionally unsupported because installing OS packages would mutate the workstation. - - New: Linux gains a guest-like backend based on a Substrate-managed Linux root filesystem (“guest rootfs”), enabling `world deps provision` to install apt packages into the guest rootfs (not the host) while keeping the same explicit/selection-driven UX as macOS (Lima) and Windows (WSL). - - Why: restores parity for `system_packages` while preserving the agent-hub threat model (“no privileged host mutation as a side effect of tool selection”). +- Linux can run against an explicit guest distro without a full VM + - Existing: Linux world execution is tied to the host distro/userspace, and `world_fs.isolation=full` still depends on host system content mounted read-only into the cage. + - New: Linux can opt into a Substrate-managed `guest_rootfs` backend that runs world execution against a guest userspace unpacked from an OCI-style rootfs image on the host kernel. Backend selection and guest-image selection are separate concepts; the first shipped guest image family is Ubuntu/Debian. + - Why: decouples host distro from world distro, enables a Manjaro host to run an Ubuntu world, and strengthens Linux isolation semantics without paying full-VM cost. + - Links: + - `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md#user-contract-authoritative` + - `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md#architecture-shape` + - `docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md#dr-0001--linux-guest-image-format-and-builder` +- Linux provisioning stays explicit and never mutates the host OS + - Existing: `substrate world enable --provision-deps` is unsupported on Linux host-native because provisioning would mutate the workstation OS package set. + - New: when Linux uses the `guest_rootfs` backend, the guest rootfs is warmed, and the active guest image is the blessed Ubuntu/Debian image, `substrate world enable --provision-deps` installs apt packages into a persistent guest overlay only. Linux `host_native` remains unsupported for provisioning. + - Why: preserves the ADR-0002 / ADR-0030 threat model while making guest-backed Linux provisioning possible. - Links: - - `docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md` - - `docs/WORLD.md` + - `docs/project_management/adrs/draft/ADR-0009-linux-guest-rootfs-backend-and-linux-system-packages-provisioning.md#user-contract-authoritative` + - `docs/project_management/adrs/draft/ADR-0030-provisioning-otter.md#user-contract-authoritative` + - `docs/reference/world/deps/README.md#provisioning-contract` ## Problem / Context -- Substrate’s Linux world backend is “host-native”: the agent runs directly on the workstation (`docs/WORLD.md`), so any OS package install performed during provisioning would change the host OS package set. -- The world-deps selection layer formalizes `system_packages` as “must never be installed during runtime sync/install; must be fulfilled by an explicit provisioning command on supported guests” (`docs/project_management/adrs/implemented/ADR-0002-world-deps-install-classes-and-world-provisioning.md`). -- Substrate’s Linux “full isolation” mode (mount namespace + `pivot_root`) is designed to make host paths unnameable and constrain writes, but today it still relies on read-only bind mounts of host `/usr`, `/etc`, etc. That deliberately prevents any OS package mutation inside the cage, so apt/dpkg cannot be used as a “guest provisioning” mechanism without changing the design. +- Substrate's current Linux world backend is `host_native`: the agent and the effective Linux userspace are the workstation's own distro (`docs/WORLD.md`). +- Linux `world_fs.isolation=full` already hides host paths by building a minimal rootfs and `pivot_root`ing into it, but that cage still depends on read-only bind mounts of host `/usr`, `/etc`, and related system paths. That means the world's distro identity is still coupled to the host distro. +- ADR-0002 and ADR-0030 already locked the security posture that OS/system package mutation must be explicit, provisioning-time only, and must not occur on Linux host-native workstations. +- The current operator-facing provisioning surface is `substrate world enable --provision-deps`; this ADR must extend that contract for Linux guest worlds and must not resurrect the older `substrate world deps provision` shape. -We need a Linux “guest-like” environment that: -- runs on the host kernel (no VM required), -- isolates the filesystem root via `pivot_root` (same hardening posture), -- provides persistent OS package state for apt installs, -- does not mutate the host OS package set. +Without a guest-rootfs backend: +- a Manjaro host cannot run an Ubuntu world without introducing a VM, +- Linux full isolation cannot be both distro-decoupled and package-provisionable, +- Linux remains permanently behind macOS Lima for guest-backed system-package flows. ## Goals -- Provide a Linux “guest rootfs” backend that Substrate manages and can execute inside when `world_fs.isolation=full` is requested. -- Enable `substrate world deps provision` on Linux when and only when the guest-rootfs backend is active, using the existing `system_packages` routing contract (apt-only in the initial ship). -- Make failure modes explicit and scriptable (exit codes), including clear remediation when the guest rootfs is not present/ready. -- Preserve the security posture of ADR-0002 (no implicit OS mutation; explicit provisioning only). +- Add an optional Linux `guest_rootfs` backend that decouples the world distro from the host distro while still running on the host kernel. +- Keep `host_native` as the default Linux backend in the first ship. +- Make backend selection and guest-image selection separate config surfaces. +- Run Linux world execution against the selected guest userspace when `guest_rootfs` is active, and ensure `world_fs.isolation=full` uses the guest rootfs rather than host system-directory bind mounts. +- Support `substrate world enable --provision-deps` on Linux when and only when the `guest_rootfs` backend is selected, warmed, and using the supported Ubuntu/Debian guest image. +- Make rootfs/image warm-up explicit, doctor-visible, and fail-closed when prerequisites are missing. ## Non-Goals -- Distro selection/pinning and cross-platform “world image” management UI (explicitly deferred; see `docs/BACKLOG.md`). -- Supporting non-apt package managers (`dnf`, `pacman`, `apk`) for provisioning in this ship. -- Any opt-in flow that mutates host OS packages on Linux (still forbidden by default posture). -- Replacing Lima (macOS) or WSL (Windows) backends. -- Reworking world-sync semantics; image pinning may later integrate with workspace init but is out of scope here. +- Making `guest_rootfs` the default Linux backend in the first ship. +- Introducing a full VM-backed Linux backend. +- Supporting arbitrary guest distros, user-imported guest images, or a general world-image catalog in this ship. +- Supporting non-APT Linux guest-image provisioning in this ADR (`pacman`, `dnf`, `yum`, `apk`, `zypper` remain follow-on work). +- Replacing or reworking the macOS Lima backend or the current Windows posture. +- Solving workspace-level world-image pinning and upgrade policy; that remains follow-on work under the backlog world-image/pinning track. ## User Contract (Authoritative) ### CLI -#### Linux guest-rootfs warm/provisioning -- A guest rootfs MUST be created by an explicit operator action (no implicit creation during `world deps` runtime flows). -- Initial ship command surface is script-first: - - `scripts/linux/world-rootfs-warm.sh` (new): provisions or repairs the guest rootfs used by the Linux guest-rootfs backend. - -If `world deps provision` requires guest rootfs and it is missing/unready, Substrate MUST print a remediation block that points to `scripts/linux/world-rootfs-warm.sh` (similar to Lima/WSL warm guidance in S2). - -#### `substrate world deps provision` (Linux behavior) -- When Linux is using the host-native backend (no guest rootfs active): - - Behavior remains unchanged from ADR-0002: exit `4` and print manual package guidance (no host mutation). -- When Linux is using the guest-rootfs backend and the guest rootfs is ready: - - `substrate world deps provision` is supported and installs apt packages inside the guest rootfs only (no host OS mutation). - - Package manager support remains apt-only in this ship (Ubuntu/Debian-family guest rootfs). - -Exit codes: +#### Linux guest-rootfs warm flow +- The Linux guest rootfs/image store MUST be created or repaired by an explicit operator action. +- The initial warm surface is script-first: + - `scripts/linux/world-rootfs-warm.sh` +- The warm script MUST: + - fetch or otherwise obtain the blessed Ubuntu/Debian guest rootfs as an OCI-style rootfs image source, + - verify image digest and provenance against the built-in manifest, + - unpack the immutable base image into a Substrate-owned system path, + - create and repair the system-path storage layout required by the backend, + - repair ownership and permissions under the backend storage root, + - perform generic backend warm and repair work that is not scope-local overlay creation, + - be idempotent and safe to re-run as the repair path. +- World execution and `substrate world enable --provision-deps` MUST NOT implicitly: + - fetch images, + - verify or re-verify image provenance as repair work, + - recreate or repair the base-image store, + - fix ownership or permissions, + - perform generic warm or repair. +- After the backend is otherwise ready, world execution and `substrate world enable --provision-deps` MAY lazily create the empty scope-local overlay directory for the resolved scope under `/var/lib/substrate/world-rootfs/overlays/...`. +- Failure to create that scope-local overlay MUST fail the triggering operation with actionable remediation. +- When Linux is configured for `guest_rootfs` and the guest rootfs is missing or unready, operator-facing remediation MUST point to `scripts/linux/world-rootfs-warm.sh`. + +#### `substrate world enable --provision-deps` (Linux behavior) +- When `world.linux.backend=host_native`: + - behavior remains explicit failure with exit `4`, + - output MUST state that provisioning is unsupported because it would mutate the host OS, + - Substrate MUST NOT execute host package-manager commands. +- When `world.linux.backend=guest_rootfs` and the guest rootfs is warmed and ready: + - provisioning is supported, + - package-manager support is apt-only in this ship, + - package installation MUST occur inside the persistent guest overlay only, + - host OS package state MUST remain unchanged. +- When `world.linux.backend=guest_rootfs` but the guest rootfs is missing, unready, or not provision-capable: + - exit `4`, + - print actionable remediation that points to `scripts/linux/world-rootfs-warm.sh` or the specific unsupported-image reason. +- When `world.linux.backend=guest_rootfs` and the active guest image is not the blessed Ubuntu/Debian image: + - exit `4`, + - do not attempt provisioning, + - print that the active guest image does not support apt provisioning in this ship. + +#### Linux world execution behavior +- When `world.linux.backend=host_native`: + - Linux world execution keeps the existing host-native behavior. +- When `world.linux.backend=guest_rootfs`: + - Linux world execution MUST run against the selected guest userspace rather than the host distro userspace, + - the backend MUST NOT silently fall back to `host_native`, + - `substrate world doctor --json` MUST report the active backend, active guest image, and guest OS identity. +- When `world.linux.backend=guest_rootfs` and `world_fs.isolation=full` is requested: + - the guest rootfs MUST be the isolation root, + - host `/usr`, `/etc`, and related system directories MUST NOT be rebound into the command view as the effective world system directories, + - if the backend cannot satisfy that guarantee, execution MUST fail closed. + +#### Exit codes - Exit code taxonomy: `docs/project_management/system/standards/shared/EXIT_CODE_TAXONOMY.md` -- `0`: success (including “no system packages required”) -- `2`: configuration/usage error (invalid selection YAML, unknown tool id, schema mismatch) -- `3`: world backend unavailable when required (e.g., world-service/socket unavailable) -- `4`: unsupported/missing prerequisites (e.g., Linux host-native backend selected; guest rootfs missing; guest rootfs does not support apt) -- `5`: safety-rail refusal (hardening/cage conflict prevents operation; protected-path violation) +- `0`: success +- `2`: configuration or usage error (invalid backend value, malformed image selection, incompatible config combination) +- `3`: world backend unavailable when execution is required (socket/service/connectivity failure) +- `4`: unsupported operation or missing prerequisite (host-native provisioning request, missing guest rootfs warm-up, unsupported guest image, guest provisioning unavailable) +- `5`: fail-closed safety refusal (hardening invariant or protected-path invariant could not be preserved) ### Config -This ADR introduces a new Linux backend selection knob (exact schema location is implemented by the planning pack; contract-level behavior is fixed here): -- Default: Linux uses the host-native backend. -- When the Linux guest-rootfs backend is enabled for the session/workspace: - - `world_fs.isolation=full` pivots into the guest rootfs (not host `/usr`/`/etc` bind mounts). - - `world deps provision` is permitted to install `system_packages` into the guest rootfs. +Config files and precedence: +- Global config: `$SUBSTRATE_HOME/config.yaml` +- Workspace config: `/.substrate/workspace.yaml` +- Precedence: workspace config overrides global config for the keys defined below. + +Logical config surfaces introduced by this ADR: +- `world.linux.backend` + - allowed values: `host_native | guest_rootfs` + - default: `host_native` +- `world.linux.image` + - type: stable image-identity string + - applies only when `world.linux.backend=guest_rootfs` + +Behavioral constraints: +- Backend selection and image selection are separate concepts. Setting an image MUST NOT implicitly switch the backend from `host_native` to `guest_rootfs`. +- If `world.linux.backend=guest_rootfs` and `world.linux.image` is unset, Substrate MUST select the built-in default Ubuntu/Debian guest image. +- In the first ship, exactly one built-in guest image family is supported for Linux `guest_rootfs`: Ubuntu/Debian, apt-capable. +- If `world.linux.image` is set to any other image identity in the first ship: + - world execution MAY still reject activation with exit `4`, + - provisioning MUST reject activation with exit `4`, + - the error MUST identify the unsupported image as the reason. ### Platform guarantees - Linux: - - Host-native backend: provisioning-time OS package installation remains unsupported (manual guidance only). - - Guest-rootfs backend: `system_packages` provisioning is supported and MUST NOT mutate the host OS package set. + - `host_native` remains the default Linux backend. + - `guest_rootfs` is explicit opt-in. + - The first shipped guest image family is Ubuntu/Debian and apt-capable. + - Linux provisioning support exists only through `substrate world enable --provision-deps` on `guest_rootfs`. - macOS: - - No behavior changes required by this ADR (Lima remains the guest; apt-only provisioning remains supported there). + - no behavior change in this ADR; Lima remains the guest backend and the current provisioning contract remains authoritative there. - Windows: - - No behavior changes required by this ADR (WSL remains the guest; apt-only provisioning remains supported there). + - no behavior change in this ADR; + - the current unsupported posture for `substrate world enable` / provisioning remains authoritative. ## Architecture Shape ### Components -- `crates/world`: - - Add guest-rootfs mount/overlay plumbing used by `world_fs.isolation=full` (Linux). - - Ensure the guest rootfs provides persistent system state for apt installs (no host `/usr` mutation). -- `crates/world-service`: - - Expose capability/doctor readiness for guest-rootfs availability. - - Ensure both non-PTY (`/v1/execute`) and PTY (`/v1/stream`) enter the guest-rootfs-backed full cage when configured. -- `crates/shell`: - - Plumb the backend selection from config/env into the world-service request environment (or explicit request fields, as implemented). - - Ensure error messaging points to the warm script when guest rootfs prerequisites are missing. -- `scripts/linux/world-rootfs-warm.sh` (new): - - Build/repair the guest rootfs under a Substrate-owned location, with explicit privilege use (sudo) and idempotence. +- `crates/world` + - add a Linux `guest_rootfs` backend path that runs commands against a guest userspace unpacked from an OCI-style rootfs image, + - maintain an immutable base-image store plus persistent writable overlays, + - ensure `world_fs.isolation=full` uses the guest rootfs as the effective root rather than host system-directory bind mounts. +- `crates/world-service` + - surface backend/image readiness and guest OS identity in doctor responses, + - ensure both non-PTY (`/v1/execute`) and PTY (`/v1/stream`) executions honor the selected Linux backend and image. +- `crates/shell` + - resolve `world.linux.backend` and `world.linux.image` from config precedence, + - pass backend/image selection into world execution and provisioning flows, + - route Linux `substrate world enable --provision-deps` through the guest-rootfs readiness gate and remediation path. +- `scripts/linux/world-rootfs-warm.sh` + - obtain and unpack the blessed guest rootfs image, + - verify provenance and digest before making the image available, + - prepare the immutable base-image store and backend storage layout under a Substrate-owned system path, + - perform explicit privileged setup and repair. ### End-to-end flow - Inputs: - - world-deps selection + install class metadata (ADR-0002 / WDL0–WDL2) - - backend selection (host-native vs guest-rootfs) for Linux - - world fs isolation settings (`world_fs.isolation=full`, allowlists) + - `world.linux.backend` + - `world.linux.image` + - `world_fs` isolation/mode policy + - effective enabled world-deps set for the current workspace - Derived state: - - whether guest-rootfs is active - - whether guest-rootfs is ready and apt-capable - - computed apt package list for the selected tools + - active Linux backend kind + - active guest image identity + - guest rootfs warm/readiness state + - guest OS identity (`/etc/os-release`, arch, kernel where available) + - normalized apt package requirement set for provisioning - Actions: - - warm: create/repair guest rootfs (operator action) - - execute: pivot_root into guest rootfs for full isolation (Linux guest-rootfs backend) - - provision: run `apt-get update` + `apt-get install` inside the guest rootfs + - warm: `scripts/linux/world-rootfs-warm.sh` prepares the image/overlay store + - lazy overlay create: runtime execution or `substrate world enable --provision-deps` may create the empty scope-local overlay for the resolved scope once the backend is otherwise ready + - execute: world-service runs commands against the selected backend and guest userspace + - full isolation: `guest_rootfs` mode uses the guest image as the world root and applies the existing full-isolation semantics inside that guest-backed world + - provision: `substrate world enable --provision-deps` installs apt packages into the guest overlay only - Outputs: - explicit success/failure with stable exit codes - - no host OS package mutation on Linux in guest-rootfs mode + - no host OS package mutation on Linux + - doctor-visible backend/image/readiness truth ## Sequencing / Dependencies -- Sequencing entry: `docs/project_management/packs/sequencing.json` → `linux_guest_rootfs_backend` (to be added) -- Prerequisites: - - WDL2 (`docs/project_management/_archived/world_deps_selection_layer/`) must land first so `world deps provision` exists and has a stable contract for `system_packages`. - - Linux full-cage mechanics (I2/I3) must already be available (or this feature must fail closed when requested but unavailable). +- Sequencing entry: `docs/project_management/packs/sequencing.json` → `linux_guest_rootfs_backend` (to be added during planning) +- Dependencies: + - The current `substrate world enable --provision-deps` operator contract from ADR-0030 and `docs/reference/world/deps/README.md` is the provisioning surface this ADR extends for Linux guest worlds. + - This ADR MUST NOT reintroduce or depend on the older `substrate world deps provision` command surface. + - Linux full-isolation mechanics from the prior hardening track remain prerequisites; if those mechanics are unavailable, `guest_rootfs` requests must fail closed. + - Future non-APT guest-image support aligns with ADR-0033 and is explicitly out of scope for this first ship. + - Future world-image pinning and cross-platform image identity work remains follow-on planning under the backlog world-image track. ## Security / Safety Posture - Fail-closed rules: - - If guest-rootfs-backed full isolation is required but the guest rootfs is unavailable/unhealthy, Substrate MUST fail closed (do not silently run a weaker mode). -- Protected paths/invariants: - - Guest rootfs storage MUST live under a Substrate-owned, non-workspace path (e.g., under `/var/lib/substrate/...`) and must never be placed inside the workspace. - - `world deps provision` MUST NOT execute host package-manager operations on Linux host-native backend. - - All mounts and overlay operations MUST remain confined to Substrate-managed roots and the project mount points established by full isolation. + - When `world.linux.backend=guest_rootfs`, Substrate MUST NOT silently fall back to `host_native`. + - When `guest_rootfs` is selected but the rootfs/image store is missing or unhealthy, guest-rootfs execution and provisioning MUST fail closed. + - When `world_fs.isolation=full` is requested under `guest_rootfs`, failure to preserve the guest-rootfs-as-root guarantee MUST be treated as exit `5`. +- Protected paths and invariants: + - Base images and overlays MUST live under a Substrate-owned system path (for example, under `/var/lib/substrate/...`) and MUST NEVER be placed in the workspace or under `$SUBSTRATE_HOME`. + - The base guest image MUST be treated as immutable after warm/unpack. + - Package provisioning MUST mutate only the persistent guest overlay, never the base image and never the host OS package set. + - Runtime execution and provisioning MAY create the empty scope-local overlay directory for a resolved scope, but they MUST NOT broaden that behavior into generic backend repair. + - All mount, overlay, and unpack operations MUST remain confined to Substrate-managed paths plus the explicit project mounts used for world execution. - Observability requirements: - - `substrate world doctor --json` MUST surface whether the guest-rootfs backend is active and ready (exact field names defined by the planning pack). - - Trace spans SHOULD annotate whether full isolation used host-native binds vs guest-rootfs (no schema-breaking changes; add fields only if required). + - `substrate world doctor --json` MUST surface, at minimum: + - `world.backend.kind` + - `world.image.id` when `guest_rootfs` is active + - `world.os` for the active world + - `world.provisioning.supported` + - `world.provisioning.ready` + - `world.provisioning.reason` when ready is false + - Trace spans SHOULD record the active backend kind, active guest image identity when present, and effective isolation mode using additive fields only. ## Validation Plan (Authoritative) ### Tests - Unit tests: - - Validate guest rootfs path computation, safety checks (no workspace paths), and readiness detection. - - Validate that Linux `world deps provision` selects “unsupported/manual guidance” when host-native backend is active. + - validate backend/image config parsing and precedence, + - validate guest-image selection rules and unsupported-image failures, + - validate guest-rootfs path safety (no workspace or `$SUBSTRATE_HOME` placement), + - validate readiness-state mapping for doctor/provisioning. - Integration tests: - - A Linux integration test that enables guest-rootfs mode, provisions a trivial apt package, and verifies it is available in subsequent executions (persistence). - - A regression test proving no host OS package-manager calls occur when host-native backend is active. + - on Linux, prove that `guest_rootfs` execution runs against the Ubuntu/Debian guest userspace rather than the host distro userspace, + - prove that `world_fs.isolation=full` under `guest_rootfs` does not depend on host `/usr` / `/etc` content as the effective guest system directories, + - provision a trivial apt package via `substrate world enable --provision-deps` and verify it remains available in subsequent guest-rootfs executions, + - prove that Linux `host_native` still rejects provisioning and never invokes host package-manager commands. ### Manual validation -- Manual playbook: `docs/project_management/_archived/next/linux_guest_rootfs_backend/manual_testing_playbook.md` +- Manual validation MUST cover at least: + - warm and repair flow, + - backend selection and doctor output, + - guest-rootfs execution on a non-Debian Linux host running an Ubuntu/Debian guest world, + - provisioning success and persistence, + - host-native rejection and no-host-mutation evidence, + - first-use lazy creation of a scope-local overlay. ### Smoke scripts - Linux: `docs/project_management/_archived/next/linux_guest_rootfs_backend/smoke/linux-smoke.sh` (to be created) -- macOS: N/A (no behavior change in this ADR) -- Windows: N/A (no behavior change in this ADR) +- macOS: N/A (no behavior delta in this ADR) +- Windows: N/A (no behavior delta in this ADR) ## Rollout / Backwards Compatibility - Policy: greenfield breaking is allowed -- Compat work: none (feature is gated behind explicit enablement; existing Linux behavior remains unchanged by default) +- Compat work: + - none required for the first ship because `host_native` remains the default Linux backend, + - `guest_rootfs` is explicit opt-in, + - unsupported or missing guest-image prerequisites fail with explicit exit `4` instead of changing existing Linux behavior. ## Decision Summary - Decision Register entries: - - `docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md`: - - DR-0001, DR-0002, DR-0003, DR-0004, DR-0005 (to be written before execution) - + - `docs/project_management/_archived/next/linux_guest_rootfs_backend/decision_register.md` + - DR-0001 — Linux guest image format and builder + - DR-0002 — Guest rootfs persistence model + - DR-0003 — Storage location and ownership + - DR-0004 — Warm command surface + - DR-0005 — Readiness and doctor surface + - DR-0006 — Backend selection vs image selection + - DR-0007 — Linux backend rollout posture diff --git a/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md b/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md index 84af18a5a..66e4d1533 100644 --- a/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md +++ b/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md @@ -200,6 +200,14 @@ Constraints: Notes: - Detailed agent runtime behavior (roles, tool gating, steering) is defined by the Agent Hub ADRs. This ADR defines the config/policy storage surface and the inventory directory pattern that those ADRs depend on. +- Slice 29 additive note: + - Runtime code now resolves agent launch semantics through one shared internal dispatch contract rooted in `dispatch_contract.rs`. + - That contract starts from explicit baseline domains: + - inventory-backed defaults for new dispatch, + - persisted-attach-backed truth for later host attach and detached host follow-up recovery. + - `DispatchCapabilityOverrideSet` and `AttachLaunchKnobs` are the frozen override families consumed by runtime code. + - `HostAttachContract` is the durable host-attach truth written under the orchestration session; later attach must not reconstruct launch truth from the last live participant snapshot. + - Downstream truth docs: `llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md`, `llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md`, and `llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md`. #### Agent inventory (new; file-based) Agent definitions are stored as inventory items, one file per agent, mirroring the deps inventory model (ADR-0011): diff --git a/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md b/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md index 7e965631d..678b6f208 100644 --- a/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md +++ b/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md @@ -259,6 +259,18 @@ These decisions are no longer open in this ADR. They are pinned here so the dura - preserves the distinction between operational ownership recovery and foreground turn submission, - and avoids reintroducing ambiguous `resume` semantics into the public lifecycle surface. +### `fork` semantics are successor allocation only +- `substrate agent fork --session ` is the explicit successor-allocation verb. +- `fork` MUST allocate a new durable orchestration session before any later follow-up work is considered. +- `fork` MUST copy the source session's persisted host attach-contract shape but MUST clear inherited `continuity_uaa_session_id` on the successor. +- `fork` MUST return the successor honestly as `parked_resumable` with `attached_participant_id = null`. +- `fork` MUST preserve source lineage truth through the successor participant/session records. +- `fork` MUST NOT submit a prompt, attach a live owner loop immediately, or reuse the parent's continuity token as if the successor were already attached. +- Rationale: + - successor allocation is durable-state work, not prompt execution, + - keeping the successor unattached avoids ownership theater and false `active_attached` truth, + - and clearing inherited continuity preserves the distinction between "resume this exact backend session" and "allocate a new durable successor session". + ### Resolved inbox items remain retained for audit, then compacted later - Resolving an inbox item MUST remove it from authoritative pending/live counts immediately. - Resolving an inbox item MUST NOT immediately delete the persisted inbox artifact. diff --git a/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md b/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md index 5982a5d46..a61945ada 100644 --- a/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md +++ b/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md @@ -1,6 +1,6 @@ # SOW: Gateway-Mediated LLM Fulfillment Without Lifecycle Regression -Status: remaining-work draft. This SOW closes the runtime seam after [25-host-durable-session-closeout-and-qa-hardening.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) and [27-uaa-boundary-and-naming-cleanup.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/27-uaa-boundary-and-naming-cleanup.md). It is anchored to the lifecycle truth in [ADR-0047 — Host Orchestrator Durable Session and Parked-Resumable Ownership](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md), the gateway ownership split in [ADR-0040 — Substrate Gateway Boundary and Runtime Ownership](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0040-substrate-gateway-boundary-and-runtime-ownership.md), the adapter intent in [ADR-0041 — Substrate Gateway Backend Adapter Contract](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0041-substrate-gateway-backend-adapter-contract.md), and the already-landed gateway contracts under [docs/contracts/](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/contracts). +Status: truth-aligned closeout draft. This SOW closes the runtime seam after [25-host-durable-session-closeout-and-qa-hardening.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) and [27-uaa-boundary-and-naming-cleanup.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/27-uaa-boundary-and-naming-cleanup.md). It is anchored to the lifecycle truth in [ADR-0047 — Host Orchestrator Durable Session and Parked-Resumable Ownership](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md), the gateway ownership split in [ADR-0040 — Substrate Gateway Boundary and Runtime Ownership](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0040-substrate-gateway-boundary-and-runtime-ownership.md), the adapter intent in [ADR-0041 — Substrate Gateway Backend Adapter Contract](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0041-substrate-gateway-backend-adapter-contract.md), and the already-landed gateway contracts under [docs/contracts/](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/contracts). This slice is a seam replacement, not a public contract expansion. It must not absorb the shared dispatch-envelope work in [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) or the public `--scope world` / capability-flag work in [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md). @@ -86,15 +86,14 @@ This SOW assumes the following are already true and must be reused rather than r ## Current Repo Truth -### The direct fulfillment bypass is still real +### The direct fulfillment bypass is no longer steady-state truth -The production bypass points are concrete: +The production prompt-bearing seam is now gateway-mediated: -1. Host shell runtime still builds a local `AgentWrapperGateway` and directly registers concrete backends in [crates/shell/src/execution/agent_runtime/registry.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/registry.rs:8). -2. Host follow-up prompt submission still rebuilds that direct gateway in [crates/shell/src/execution/agent_runtime/control.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:1251). -3. REPL-owned host startup and preparation paths still depend on the same direct shell-local builder in [crates/shell/src/repl/async_repl.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs). -4. World member runtime still builds another direct `AgentWrapperGateway` and directly registers concrete backends in [crates/world-service/src/member_runtime.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/world-service/src/member_runtime.rs:557). -5. The shell still prepares a local member gateway for world startup and then throws it away before dispatching `member_dispatch` into world-service. That is a real seam mismatch, not just stylistic duplication. +1. Shell prompt-bearing execution delegates through [crates/shell/src/execution/prompt_fulfillment.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/prompt_fulfillment.rs) instead of rebuilding backend-registration tables in prompt-taking call sites. +2. World-member prompt-bearing execution delegates through [crates/world-service/src/prompt_fulfillment.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/world-service/src/prompt_fulfillment.rs) instead of directly registering concrete backends in member runtime code. +3. Gateway-owned adapter runtime construction lives in [crates/gateway/src/adapter_runtime.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/gateway/src/adapter_runtime.rs), which is now the singular explicit backend-registration seam consumed by those bridges. +4. The discarded shell-local world-member gateway-prep pattern is removed from the production world-launch path; first-turn launch and resumed member follow-up now share the same gateway-mediated fulfillment boundary. ### The public lifecycle is already narrower than the runtime seam @@ -105,12 +104,12 @@ What is already true: 3. Durable parked and attention-needed host semantics are already landed. 4. The gateway lifecycle is already real for `status | sync | restart`. -What is still wrong: +What remains intentionally narrow: -1. production prompt-bearing execution still bypasses the gateway adapter seam, -2. host and world duplicate backend-registration logic, -3. first-turn world launch and resumed world follow-up do not clearly share one backend-fulfillment seam, -4. auth and adapter lifecycle truth exist, but the main agent fulfillment path still does not consume them as its steady-state runtime boundary. +1. public `start`, `turn`, `reattach`, and `stop` semantics remain frozen rather than widened, +2. Linux world follow-up remains on the typed `/v1/member_turn/stream` seam, +3. integrated auth still travels through the inherited FD auth-bundle path, +4. the supported integrated backend matrix remains intentionally limited by the existing binding/runtime contract. ## In Scope diff --git a/llm-last-mile/28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md b/llm-last-mile/28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md new file mode 100644 index 000000000..9252ced7e --- /dev/null +++ b/llm-last-mile/28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md @@ -0,0 +1,426 @@ +# SOW: Explicit Control-Only Session Recovery And Host-Rooted World-Start Alignment + +Status: implementation-ready remaining-work slice. This SOW replaces the prior 28.5 research packet with the actual next execution slice after [28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md](28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md). + +This slice closes the control-plane architecture gap exposed by the current hidden owner-helper runtime. It does not reopen the gateway-mediated prompt-fulfillment decision from 28. It turns the now-validated architecture into an implementable Substrate slice and freezes the downstream stack around that result. + +## Objective + +Implement the Substrate-owned control-plane split that the current repo already implies but does not yet realize in code: + +1. `start` and `turn` remain the only prompt-bearing public verbs. +2. `reattach` becomes an explicit Substrate control-only attach action rather than a blank-prompt interpretation of UAA resume. +3. `fork` becomes a true Substrate successor durable-session allocator rather than a disguised backend resume path. +4. The runtime records the minimum host attach contract truth required for later reattach, public host-rooted `--scope world`, and lazy host attach. +5. The downstream stack is frozen to one path only: + - host-rooted durable orchestration authority, + - shared dispatch resolution and persisted attach contract, + - public world-scoped root start above a world worker, + - lazy host attach without synthetic prompts. + +This slice is done only when blank-prompt UAA behavior is no longer carrying Substrate architecture and the hidden owner-helper seam has been split into explicit responsibilities that match the public contract. + +## Research Closeout + +### Iteration 1: public contract versus live runtime shaping + +Evidence reviewed: + +- [docs/USAGE.md](../docs/USAGE.md) +- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](../HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) +- [ADR-0047](../docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) +- [crates/shell/src/execution/agents_cmd.rs](../crates/shell/src/execution/agents_cmd.rs) +- [crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [crates/shell/src/execution/agent_runtime/state_store.rs](../crates/shell/src/execution/agent_runtime/state_store.rs) + +Confirmed: + +1. The durable authority is already Substrate-owned orchestration state, not the currently attached backend process. +2. Public `reattach` is already frozen as control-only attached-owner recovery. +3. Public `fork` is already frozen as a control action that allocates a successor durable host session. +4. Detached-world follow-up is already intentionally fail-closed until host ownership is restored. + +Invalidated: + +1. Standalone world-root continuity is not the front-running meaning of the current public contract. +2. The hidden owner-helper abstraction is not acceptable as final architecture just because it exists in live code. + +Next highest-leverage step chosen: + +- inspect the live startup/request-shaping path to determine whether the current helper implementation actually requires promptless UAA semantics or only appears to because control, prompt, and successor responsibilities are still conflated. + +### Iteration 2: live Substrate runtime versus local UAA branch + +Evidence reviewed: + +- [crates/shell/src/repl/async_repl.rs](../crates/shell/src/repl/async_repl.rs) +- [crates/shell/src/execution/agent_runtime/control.rs](../crates/shell/src/execution/agent_runtime/control.rs) +- [UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md](../UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md) +- local UAA branch `feat/promptless-resume` at commit `3b7a4ef` +- `unified-agent-api/crates/agent_api/src/backend_harness/normalize.rs` +- `unified-agent-api/crates/agent_api/src/backends/codex/exec.rs` +- `unified-agent-api/crates/agent_api/src/backends/codex/fork.rs` +- `unified-agent-api/crates/agent_api/src/backends/claude_code/util.rs` +- `unified-agent-api/docs/specs/unified-agent-api/run-protocol-spec.md` +- `unified-agent-api/docs/specs/claude-code-session-mapping-contract.md` +- `unified-agent-api/docs/specs/codex-app-server-jsonrpc-contract.md` + +Confirmed: + +1. Live Substrate still routes `OwnerHelperMode::Resume`, `ResumeOneTurn`, and `Fork` through the same `agent_api.session.resume.v1` shaping path in `owner_helper_startup_extensions(...)`. +2. `InitialExecPromptPlan::NoPromptRecovery` still becomes `prompt: ""` in `start_host_orchestrator_runtime_with_prepared_prompt(...)`. +3. Normal prompt-bearing host follow-up already uses prompt-bearing resume through `submit_host_prompt_turn(...)`. +4. Linux world-member follow-up already uses a separate typed `member_turn_submit` path whose prompt is required to be non-empty. +5. Live UAA implementation now accepts blank prompts for resume/fork when those extensions are present, while normative UAA specs still pin prompt-bearing semantics. +6. Live `fork` pressure is not evidence for a real backend-native no-turn fork requirement, because Substrate does not even use `agent_api.session.fork.v1` in the current helper path. + +Invalidated: + +1. Promptless fork is not a defensible forward dependency for this SOW stack. +2. Blank prompt is not a defensible long-term contract signal for control-only recovery. +3. The current helper convergence does not prove that UAA should own Substrate's control-only recovery semantics. + +Next highest-leverage step chosen: + +- determine what durable contract is missing underneath attach, world-start, and future lazy attach so the stack can move forward on one explicit shape instead of continuing to reuse the current active participant as implicit launch truth. + +### Iteration 3: dispatch, inventory, and world-start prerequisites + +Evidence reviewed: + +- [crates/shell/src/execution/agent_inventory.rs](../crates/shell/src/execution/agent_inventory.rs) +- [crates/shell/src/execution/agent_runtime/validator.rs](../crates/shell/src/execution/agent_runtime/validator.rs) +- [crates/shell/src/execution/routing/dispatch/world_ops.rs](../crates/shell/src/execution/routing/dispatch/world_ops.rs) +- [ADR-0025](../docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md) +- [ADR-0026](../docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md) +- [ADR-0027](../docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md) + +Confirmed: + +1. Inventory, scope defaults, capability booleans, policy overlays, and host-only orchestrator selection already exist. +2. World-member launch already consumes a distinct dispatch/build path and does not depend on hidden owner-helper semantics. +3. Public root `start` is still host-only in live code. +4. The Substrate workspace is still locally overriding crates.io `unified-agent-api` through `[patch.crates-io] unified-agent-api = { path = "../unified-agent-api/crates/agent_api" }` in the workspace [Cargo.toml](../Cargo.toml), so the current runtime is still coupled to the neighboring UAA checkout rather than the published crate. +5. The missing durable seam is not "profiles" or "world dispatch." The missing durable seam is the persisted host attach contract that later reattach and future host-rooted world start need. + +Still uncertain but no longer blocking this slice: + +1. Whether a future explicit UAA control-only resume primitive is ever worth standardizing for non-Substrate consumers. +2. Whether 31 should auto-launch the host attach worker on pending work or require explicit operator `reattach`. + +Validated conclusion: + +- the original five-part decomposition was directionally right, but the best validated architectural shape inserts one additional explicit durable seam: + - a persisted host attach contract that the attach worker executes. + +## Validated Architecture + +The current front-running decomposition is validated with one correction: the missing durable seam is the persisted host attach contract. + +The architecture to plan and implement against is: + +1. Durable orchestration session + - Pure Substrate authority. + - Owns session identity, lineage, durable inbox/task state, world binding, posture, and control-plane routing truth. + +2. Persisted host attach contract + - Pure Substrate durable state. + - Stores the exact resolved host launch contract needed to attach a host execution client later without guessing from ambient state. + - Begins as a minimal host launch snapshot in this slice and is generalized by [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md). + +3. Attached host execution client + - Prompt-capable backend process bound to the durable session. + - May be absent, attached, detached cleanly, or replaced. + - Is not the durable authority. + +4. Attach worker + - Substrate-owned control-plane action that executes the persisted host attach contract. + - Supports two modes: + - continuity attach: resume an existing backend-native session when an internal session id exists and is valid; + - fresh attach: start a new attached host execution client from the persisted host attach contract when no backend-native session exists yet. + +5. Prompt-turn launcher + - Prompt-bearing path only. + - Owns `start` and `turn` launches and keeps normal host/world follow-up inside existing prompt-bearing contracts. + +6. Successor allocator + - Substrate-owned control path for `fork`. + - Allocates successor durable-session identity and lineage, copies the host attach contract forward, and does not require a synthetic prompt-free backend turn. + +## Frozen Decisions + +### 1. Control-only recovery is Substrate-owned + +Substrate owns: + +1. durable orchestration-session identity, +2. host attach versus detached posture truth, +3. `reattach` public semantics, +4. `fork` public semantics and successor lineage, +5. world-member fail-closed rules, +6. the persisted host attach contract and any private backend continuity mapping required to honor it. + +### 2. UAA stays prompt-bearing on the forward path + +UAA remains the prompt-bearing execution substrate for: + +1. initial host prompt start, +2. prompt-bearing host follow-up resume, +3. prompt-bearing backend-native fork if a real future prompt-bearing follow-up requires it. + +UAA is not the forward architectural home for: + +1. blank-prompt control-only resume, +2. blank-prompt control-only fork, +3. Substrate durable-session lineage, +4. Substrate host/world posture semantics, +5. Substrate reattach/fork public lifecycle meaning. + +### 3. Public `fork` stops pretending to be an attached live session + +The validated target meaning of public `fork` is: + +1. allocate a successor durable orchestration session, +2. carry forward lineage and the host attach contract, +3. return truthful successor posture, +4. avoid submitting a synthetic prompt-free backend turn. + +This means the current helper behavior that returns `fork` as immediately `active` because a replacement helper attached itself is not target architecture. + +### 4. Public world-scoped root start is host-rooted only + +The only validated forward meaning of `substrate agent start --scope world ...` is: + +1. create a host-rooted durable orchestration session, +2. persist the host attach contract immediately, +3. launch a world worker/member under that session, +4. leave the durable authority host-rooted, +5. defer host execution-client attach until needed. + +Standalone world-root session design stays out of this stack. + +### 5. Posture truth must stop overloading "attached now" as "valid session" + +This slice and the downstream stack must preserve: + +1. durable session validity independent of a currently attached client, +2. explicit distinction between attached client truth and successor/reattach eligibility, +3. future support for born-unattached host-rooted sessions without inventing hidden bootstrap prompts. + +## Ownership Boundaries + +### Substrate owns + +1. durable orchestration-session identity and lineage, +2. public lifecycle semantics for `start`, `turn`, `reattach`, `fork`, and `stop`, +3. the persisted host attach contract, +4. attach-worker behavior and successor allocation behavior, +5. posture truth, world binding, durable inbox state, and fail-closed world-follow-up rules. + +### Inventory and policy own + +1. baseline backend/profile defaults, +2. allowed execution scope and capability declarations, +3. restriction-only narrowing of the resolved launch contract. + +### UAA and backend adapters own + +1. prompt-bearing host start execution, +2. prompt-bearing host resume execution, +3. prompt-bearing backend-native fork execution if a future prompt-bearing follow-up path needs it. + +### UAA and backend adapters do not own + +1. Substrate durable-session identity, +2. public `reattach` meaning, +3. public `fork` meaning, +4. the persisted host attach contract, +5. born-unattached host-rooted world-start semantics. + +## In Scope + +1. Split the current hidden owner-helper convergence into explicit Substrate-owned control and prompt seams. +2. Remove blank-prompt UAA semantics from the architecture of public `reattach` and `fork`. +3. Land a real Substrate successor allocator for public `fork`. +4. Record the minimum persisted host attach contract needed by 29, 30, and 31. +5. Freeze the host-rooted public `--scope world` direction for the downstream stack. +6. Align docs, tests, and truth surfaces so the public lifecycle contract, runtime behavior, and future stack all point to one architecture. + +## Out Of Scope + +1. Reopening the gateway-mediated fulfillment seam from 28. +2. Generalizing a new public UAA control-only contract in this slice. +3. Shipping standalone world-root continuity. +4. Shipping the full public `--scope world` root start surface. +5. Shipping lazy host attach itself. +6. Broadening public inbox workflows or auto-resume product behavior. + +## Concrete Work Breakdown + +### 1. Cut Substrate back to the crates.io UAA release before landing seam work + +Primary anchors: + +- [Cargo.toml](../Cargo.toml) +- [crates/shell/Cargo.toml](../crates/shell/Cargo.toml) +- [crates/gateway/Cargo.toml](../crates/gateway/Cargo.toml) +- [crates/world-service/Cargo.toml](../crates/world-service/Cargo.toml) + +Required outcome: + +1. The workspace removes the local `[patch.crates-io]` override that points at the neighboring UAA checkout. +2. Substrate resolves `unified-agent-api = "=0.3.5"` from crates.io instead of `../unified-agent-api/crates/agent_api`. +3. The lockfile is regenerated so the resolved dependency source matches the manifest intent. +4. Any reliance on the unmerged promptless UAA branch is removed before the Substrate control-plane refactor lands. + +Why this lands first: + +1. The neighboring checkout currently carries the incorrect blank-prompt behavior under review. +2. 28.5 is explicitly about removing blank-prompt UAA semantics from Substrate architecture. +3. Landing the seam work while still patched to the local checkout would hide whether Substrate still depends on the bad behavior. + +### 2. Replace hidden owner-helper convergence with explicit plan types + +Primary anchors: + +- [crates/shell/src/repl/async_repl.rs](../crates/shell/src/repl/async_repl.rs) +- [crates/shell/src/execution/agent_runtime/control.rs](../crates/shell/src/execution/agent_runtime/control.rs) +- [crates/shell/src/execution/agents_cmd.rs](../crates/shell/src/execution/agents_cmd.rs) + +Required outcome: + +1. The runtime no longer models `Resume`, `ResumeOneTurn`, and `Fork` as one shared hidden owner-helper startup shape. +2. Separate internal plan types exist for: + - control-only host attach, + - prompt-bearing resumed turn launch, + - successor durable-session allocation. +3. The code stops treating blank prompt as the thing that differentiates control from prompt. + +### 3. Introduce the minimal persisted host attach contract + +Primary anchors: + +- [crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [crates/shell/src/execution/agent_runtime/state_store.rs](../crates/shell/src/execution/agent_runtime/state_store.rs) +- [crates/shell/src/execution/agent_inventory.rs](../crates/shell/src/execution/agent_inventory.rs) + +Required outcome: + +1. Each durable host orchestration session persists the minimum exact launch truth needed to attach a host execution client later. +2. That contract is durable session state, not an inference from the current active participant. +3. The contract carries forward on successor allocation. +4. 29 can generalize this minimal contract into the shared dispatch envelope without redesigning 28.5. + +Minimum required contents for this slice: + +1. resolved host backend identity, +2. resolved execution scope and protocol, +3. the baseline launch descriptor required to rebuild the host runtime, +4. current continuity selector state when one exists. + +### 4. Implement control-only `reattach` as a Substrate attach action + +Primary anchors: + +- [run_reattach(...)](../crates/shell/src/execution/agents_cmd.rs) +- [resolve_public_control_target(...)](../crates/shell/src/execution/agent_runtime/state_store.rs) +- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](../HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) + +Required outcome: + +1. Public `reattach` no longer relies on blank-prompt UAA semantics as architecture. +2. If backend-native continuity reuse is required, it is executed behind a Substrate-owned private attach action rather than exported as public contract meaning. +3. Success still means the same durable session converges back to truthful attached ownership. +4. The reattach path is compatible with later fresh attach for born-unattached sessions even if this slice only lands continuity attach. + +### 5. Rework `fork` into a real successor allocator + +Primary anchors: + +- [run_fork(...)](../crates/shell/src/execution/agents_cmd.rs) +- [build_successor_launch_plan(...)](../crates/shell/src/execution/agents_cmd.rs) +- [resolve_public_control_target(...)](../crates/shell/src/execution/agent_runtime/state_store.rs) + +Required outcome: + +1. Public `fork` allocates successor orchestration-session identity in Substrate first. +2. Successor lineage and host attach contract are persisted without requiring a prompt-free backend turn. +3. The live code no longer routes `fork` through `agent_api.session.resume.v1`. +4. The returned successor state is honest about attachment posture. + +### 6. Keep prompt-bearing start/turn on prompt-bearing paths only + +Primary anchors: + +- [run_public_prompt_command(...)](../crates/shell/src/execution/agent_runtime/control.rs) +- [submit_host_prompt_turn(...)](../crates/shell/src/execution/agent_runtime/control.rs) +- [MemberTurnSubmitRequestV1](../crates/transport-api-types/src/lib.rs) + +Required outcome: + +1. Host start/turn keep using real prompts. +2. World-member turns keep using the existing non-empty prompt contract. +3. No new control-plane change reopens prompt-bearing follow-up semantics. + +### 7. Truth-sync the downstream stack + +Required outcome: + +1. 29 is rewritten as the shared dispatch-resolution and attach-contract slice. +2. 30 is rewritten as host-rooted public world-start only and clearly marked draft. +3. 31 is rewritten as lazy host attach only and clearly marked draft. +4. No downstream SOW continues to treat standalone world-root or blank-prompt UAA control semantics as live options. + +## Acceptance Criteria + +1. No live Substrate path treats public `fork` as `agent_api.session.resume.v1` plus `prompt: ""`. +2. No live Substrate path depends on blank prompt as the architectural meaning of public `reattach`. +3. The workspace no longer resolves `unified-agent-api` from the neighboring local checkout. +4. The runtime has explicit internal plan separation for attach, prompt-turn launch, and successor allocation. +5. A durable host attach contract is persisted under the orchestration session and copied to successors. +6. Public `fork` returns truthful successor durable-session state without synthetic prompt submission. +7. `start` and `turn` remain prompt-bearing; world-member turns still require non-empty prompt. +8. The downstream 29/30/31 stack is synchronized to this validated architecture. + +## Validation Plan + +Run at minimum: + +1. targeted shell/runtime tests covering control-target resolution, start/turn/reattach/fork behavior, and successor lineage; +2. targeted state-store tests covering persisted host attach contract truth and detached continuity behavior; +3. targeted public control surface tests covering truthful `reattach` and non-prompt `fork`; +4. dependency resolution verification proving `unified-agent-api` is coming from crates.io rather than the local patch override; +5. full workspace tests: + - `cargo test --workspace -- --nocapture` + +Manual validation must prove: + +1. prompt-bearing host `start` still uses a real startup prompt, +2. prompt-bearing host `turn` still uses prompt-bearing resume, +3. the workspace manifest no longer contains the local `unified-agent-api` patch override and lockfile resolution no longer depends on the neighboring checkout, +4. `reattach` restores actual attached ownership for the same durable session, +5. `fork` allocates a successor durable session without sending a blank prompt, +6. detached-world follow-up remains fail-closed until host ownership is reattached. + +## Docs And Truth Sync + +When this slice closes: + +1. update [docs/USAGE.md](../docs/USAGE.md), +2. update [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](../HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md), +3. update [ADR-0047](../docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) if its draft text still allows architectural ambiguity, +4. ensure [UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md](../UAA_PROMPTLESS_RESUME_FORK_SYNTHESIS.md) is treated as evidence and migration rationale, not as durable architecture truth. + +## Downstream Stack After This Slice + +Ready to implement once this architecture is accepted: + +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md) +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md) + +Remain draft pending the earlier landings: + +1. [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md) +2. [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md) + +No extra intermediate numbered SOW is required between 29 and 31. The missing durable seam is the persisted host attach contract, and 29 now absorbs that work explicitly. diff --git a/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md b/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md index 7d66ddd39..8a07ad94f 100644 --- a/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md +++ b/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md @@ -1,223 +1,334 @@ # SOW: Shared Agent Dispatch Envelope And Capability Override Contract -Status: remaining-work draft. This SOW closes the next contract-definition slice after [28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md). It is anchored to [ADR-0025 — Agent Hub Core](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md), [ADR-0026 — Orchestration Toolbox](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md), [ADR-0027 — LLM and Agent Config/Policy Surface](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md), and the current inventory/runtime code in [agent_inventory.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:13). +Status: implementation-ready follow-on slice. This SOW follows [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md) and is the next execution slice after 28.5. It is anchored to [ADR-0025](../docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md), [ADR-0026](../docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md), [ADR-0027](../docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md), and the current inventory/runtime code in [crates/shell/src/execution/agent_inventory.rs](../crates/shell/src/execution/agent_inventory.rs). -This slice is not “invent agent profiles.” The inventory/profile layer already exists. The missing contract is one shared dispatch-time override and resolution model that both human commands and orchestrator-only tool calls can use. +This slice is not a generic capability-injection exercise. It is the contract-definition slice that turns inventory defaults, dispatch-time overrides, and policy narrowing into one resolved launch contract and one generalized persisted host attach contract that later slices can reuse without guessing. + +29.5 and 29.75 closeout have now frozen the shipped contract floor for this SOW: + +1. inventory `policy_overlay` is merged into `ResolvedLaunchContract.effective_policy` as restriction-only truth, +2. dispatch-time capability narrowing is supported only for `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, and `event_stream`, and only from `true` to `false`, +3. `session_start`, `llm`, and `mcp_client` remain dispatch-time unsupported and fail closed with field-scoped diagnostics, +4. persisted host attach launches reuse durable attach capabilities, attach launch defaults, and effective policy from `HostAttachContract` baseline rather than ambient defaults, +5. later attach requests may only honor or narrow that persisted baseline, and may not silently replace or broaden it, +6. missing or invalid durable attach truth now fails closed instead of silently reconstructing from manifest or default state, +7. retained world-member follow-up turns consume a shared-contract-derived parity subset rather than re-running hidden launch selection. ## Objective -Define and implement one shared dispatch envelope that: +Define and implement one shared internal dispatch contract that: + +1. starts from inventory/profile defaults, +2. accepts strict dispatch-time overrides, +3. resolves inventory plus overrides plus policy into one effective launch contract, +4. generalizes the already-landed minimal persisted host-orchestrator attach contract under the durable orchestration session, +5. is consumed by both human launch surfaces and orchestrator-controlled dispatch surfaces. + +This slice is done only when the repo has one deterministic contract that explains: + +1. how a worker launch is resolved, +2. how a future host attach is derived from persisted `HostAttachContract` truth, +3. which capability overrides are allowed, +4. why a requested override was accepted or rejected. + +## Landed Floor From 28.5 + +The current repo already has the minimum durable attach seam that 28.5 said it would land: + +1. exact backend/runtime selection already resolves through `RuntimeSelectionDescriptor`, +2. durable launch-descriptor persistence already exists through `ResolvedRuntimeDescriptor` plus `HostAttachContract`, +3. durable orchestration-session state already stores that contract under `OrchestrationSessionRecord.host_attach_contract`, +4. the initial durable host-attach seam already exists through `HostAttachContract`, `sync_host_attach_contract(...)`, and `fork_successor_attach_contract(...)`, with 29.75 later hardening birth-time authority and fail-closed semantics on top of that seam, +5. public `reattach` already plans from the persisted attach contract and fails closed when required continuity is missing, +6. public `fork` already allocates a successor durable session, derives the successor attach-contract baseline from persisted attach truth, clears inherited continuity, and returns truthful `parked_resumable` posture. + +What is still missing is the broader shared dispatch-envelope layer above that seam: + +1. one first-class dispatch request envelope shared by human and orchestrator-controlled callers, +2. explicit override taxonomy for dispatch-time capability/scope shaping, +3. explanation-ready provenance for why requested fields were accepted, narrowed, or denied, +4. one unified contract surface that all future host/world launch planning reuses. + +## Landed Implementation Truth + +The merged runtime truth for this slice is now: + +1. `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` is the single internal owner of shared dispatch resolution semantics. +2. `DispatchRequestEnvelope` is the shared caller contract for both human control surfaces and orchestrator-controlled dispatch. +3. The baseline domains are explicit: + - inventory-backed resolution for new dispatch, + - persisted-attach-backed resolution for later host attach, detached follow-up turn recovery, and successor allocation. +4. `DispatchCapabilityOverrideSet` and `AttachLaunchKnobs` are the named override families consumed by runtime code. +5. Runtime materialization flows from the resolved shared contract rather than from caller-specific launch planning. +6. `HostAttachContract` persists the generalized resolved host launch truth, including continuity selector state when present, and no second durable attach object exists. +7. Human `start` / `reattach` / `turn` / `fork` and orchestrator-controlled dispatch now consume the same contract semantics. + +## Validated Architecture Assumptions + +This SOW assumes the following from 28.5 and treats them as fixed floor: + +1. the durable authority is the Substrate orchestration session, not the attached host execution client; +2. public `reattach` is a Substrate control action; +3. public `fork` is a Substrate successor allocator; +4. public `--scope world` will mean host-rooted orchestration plus world worker, not standalone world-root continuity; +5. the previously missing durable seam is now present as a minimal persisted host attach contract, and the remaining gap is the shared dispatch-envelope and override contract that generalizes it. + +## Frozen Decisions + +### 1. Inventory remains the baseline truth + +Inventory already owns: + +1. agent identity, +2. backend kind and derived backend id, +3. default execution scope, +4. default CLI mode, +5. baseline capability declarations, +6. restriction-only policy overlays. + +This slice must not bypass that layer. + +### 2. One resolved launch contract feeds both launch and later attach -- uses agent inventory as the baseline profile/default layer, -- accepts dispatch-time overrides in a strict, fail-closed way, -- resolves inventory defaults plus dispatch overrides plus policy restrictions into one effective launch contract, -- and is reusable by both human CLI dispatch and orchestrator-only tool-call dispatch. +The same resolution engine must produce: -This slice is done only when all of the following are true: +1. the contract used to launch a host or world runtime now, +2. the generalized host attach contract persisted for later host attach or lazy attach. -1. One explicit dispatch envelope exists as a shared internal contract. -2. Both human-facing dispatch surfaces and orchestrator-only tool-call dispatch can target that same envelope. -3. Capability overrides are validated, merged, and policy-gated in one place. -4. Inventory defaults remain the source of baseline truth rather than being bypassed by ad hoc flags. +Later attach must not reconstruct launch truth from whatever participant happened to be active most recently. -## Already Landed And Assumed +### 3. Policy remains narrowing-only and fail-closed -This SOW assumes the following are already true and must not be redesigned here: +Policy may: -- agent inventory items already live under `~/.substrate/agents/*.yaml` and workspace overrides under `.substrate/agents/*.yaml` as documented in [ADR-0027](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md:204), -- inventory already carries `config.kind`, `config.execution.scope`, `config.cli.binary`, `config.cli.mode`, and basic capability booleans in [agent_inventory.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:25), -- inventory already supports restriction-only `policy_overlay` in [agent_inventory.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:19), -- derived backend ids already follow the `:` contract in [agent_inventory.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:105), -- the orchestrator remains host-scoped in v1 per [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md:141) and [ADR-0026](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md:56), -- and worker/world launches are expected to be primarily orchestrator-dispatched rather than orchestrator-in-world. +1. deny a backend, +2. deny a scope, +3. deny a capability family, +4. narrow capability selections. -## Current Repo Truth +Policy may not broaden inventory defaults or silently coerce invalid requests into something "close enough." -### Inventory/profile already exists +### 4. Caller parity is mandatory -The repo already has the right baseline concepts: +Human CLI dispatch and orchestrator-controlled dispatch must resolve through the same internal contract. No second worker-launch dialect is allowed. -- one-file-per-agent inventory, -- backend id derivation, -- default scope, -- default CLI mode, -- basic capability booleans, -- and restriction-only policy overlays. +### 5. This slice owns the persisted host attach contract generalization -That means this slice must not pretend the profile layer is missing. +28.5 already introduced the minimum durable host attach contract needed to break the hidden owner-helper coupling. This slice expands the existing `RuntimeSelectionDescriptor` / `ResolvedRuntimeDescriptor` plus `HostAttachContract` path into the stable shared resolved-contract shape that 30 and 31 will reuse. -### The missing contract is dispatch-time capability selection +## Ownership Boundaries -What still looks under-modeled: +### Substrate resolution layer owns -- explicit dispatch-time scope selection and override precedence, -- richer capability choices such as selected MCP servers, selected skills, narrower policy views, and other custom capability bundles, -- one effective resolved launch envelope shared by human and orchestrator-controlled dispatch, -- and one validation story for unsupported or policy-blocked overrides. +1. dispatch request parsing, +2. inventory-plus-override-plus-policy merge logic, +3. the resolved launch contract, +4. generalization of the already-persisted host attach contract under the durable orchestration session, +5. caller parity between human and orchestrator-controlled dispatch. -## Required Shared Contract +### Inventory layer owns -This slice must create one shared internal dispatch contract with at least these conceptual layers: +1. baseline backend identity and profile defaults, +2. default scope and CLI mode, +3. baseline capability declarations, +4. restriction-only embedded policy overlays. -1. Inventory/profile defaults - - backend id - - default scope - - default mode - - declared capabilities - - restriction-only policy overlay -2. Dispatch request - - prompt or task payload - - explicit scope override - - explicit capability overrides - - explicit optional selections for allowed tooling or skills -3. Resolved effective launch contract - - the final merged backend, scope, capability set, and policy-restricted launch envelope that runtime code actually consumes +### Policy layer owns -The merge rules must be explicit and fail closed. +1. allow or deny decisions for backends, scopes, and capability categories, +2. narrowing of the resolved launch contract, +3. fail-closed rejection when a request exceeds allowed posture. + +### UAA and backend adapters own + +1. execution of the prompt-bearing launch contract after resolution is complete, +2. backend-native continuity selectors once Substrate chooses to use them. + +### UAA and backend adapters do not own + +1. dispatch-time override resolution semantics, +2. persisted host attach contract shape, +3. durable-session attachability semantics, +4. caller-specific launch dialects. ## In Scope -- define the shared dispatch envelope, -- define merge precedence between inventory defaults, dispatch-time overrides, and policy restrictions, -- model dispatch-time capability overrides for both human and orchestrator-driven launches, -- leave room for selected MCP/tool affordances, skills, narrower policy overlays, and other explicit capability bundles, -- and land the shared runtime resolution seam without forcing full public CLI contract expansion in the same slice. +1. define the shared dispatch request envelope, +2. define merge precedence between inventory defaults, dispatch overrides, and policy restrictions, +3. define the resolved launch contract consumed by runtime code, +4. define the generalized persisted host attach contract derived from that resolved launch contract, +5. validate caller parity across human and orchestrator dispatch, +6. make failures explicit and explainable. ## Out Of Scope -This slice does not include: +1. shipping public `substrate agent start --scope world`, +2. implementing lazy host attach, +3. moving the orchestrator in-world, +4. inventing a second ad hoc launch system separate from inventory, +5. broad public CLI design for every possible future capability family. -- broadening public `substrate agent start` on its own, -- choosing final user-facing flag names for every capability category if those belong to a later CLI-focused slice, -- moving the orchestrator in-world, -- bypassing inventory defaults with fully ad hoc one-off launch requests, -- or baking worker capability semantics directly into the gateway fulfillment rewrite. +## Contract Shape To Land -## Concrete Work Breakdown +The implementation may choose exact type names, but it must land the following conceptual layers. -### 1. Freeze inventory as the source of baseline truth +### 1. Dispatch request -Primary anchors: +Required fields: -- [ADR-0027](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md:204) -- [agent_inventory.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:13) +1. selected agent or backend target, +2. caller kind, +3. requested execution scope override when allowed, +4. requested capability overrides, +5. requested prompt or task payload when the launch is prompt-bearing, +6. workspace and policy-resolution context. -Required outcome: +### 2. Resolved launch contract + +Required contents: + +1. resolved backend identity, +2. resolved execution scope, +3. resolved protocol and launch descriptor, +4. resolved capability set, +5. effective policy restrictions applied, +6. explanation-ready provenance of which layer supplied or denied each field. + +### 3. Persisted host attach contract + +Required contents: -- the new dispatch model explicitly starts from inventory-defined defaults, -- inventory remains the durable profile layer, -- and dispatch overrides do not replace or bypass the inventory system. +1. the resolved host-orchestrator launch contract needed to attach a host execution client later, +2. attach-relevant capability selections and restrictions, +3. continuity selector state when a backend-native session id exists, +4. enough exact launch truth to support both: + - continuity attach, + - fresh attach. -### 2. Define one shared dispatch request envelope +## Concrete Work Breakdown + +### 1. Freeze inventory as baseline input Primary anchors: -- [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md) -- [ADR-0026](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md) +- [ADR-0027](../docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md) +- [crates/shell/src/execution/agent_inventory.rs](../crates/shell/src/execution/agent_inventory.rs) Required outcome: -- one internal request envelope exists for worker launches, -- that envelope can be consumed by a human CLI surface or an orchestrator-only tool surface, -- and the envelope has stable semantics independent of which caller produced it. +1. the resolution engine starts from inventory-defined defaults, +2. workspace inventory still overrides global inventory, +3. invalid inventory/data combinations fail closed before runtime launch. -### 3. Define explicit capability override categories +### 2. Define one dispatch request envelope Primary anchors: -- [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md) -- [ADR-0026](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md) +- [ADR-0025](../docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md) +- [ADR-0026](../docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md) + +Required outcome: + +1. one internal request shape exists for worker launch and host attach planning, +2. the envelope is stable regardless of whether the caller was human or orchestrator-controlled, +3. prompt-bearing and non-prompt-bearing callers share the same resolution substrate. + +### 3. Define first-class override categories Required outcome: -- the repo explicitly defines which capability families are first-class dispatch overrides, -- at minimum leaving room for: - - scope, - - backend selection where permitted, - - narrower policy overlays, - - selected MCP/tool affordances, - - selected skills, - - and other explicit custom capability bundles, -- and unsupported capability categories fail closed instead of being silently ignored. +1. the repo explicitly names which override families are supported now, +2. unsupported families fail closed, +3. the minimum supported families cover: + - execution scope, + - explicit capability narrowing or selection, + - attach-relevant launch knobs needed later by 30 and 31, + - narrower policy overlays where permitted. -### 4. Define merge precedence and fail-closed validation +### 4. Define merge precedence and failure behavior Primary anchors: -- [ADR-0027](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md) -- [PHASE_8_CROSS_CUTTING_DECISION_REGISTRY.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/packs/PHASE_8_CROSS_CUTTING_DECISION_REGISTRY.md) +- [ADR-0027](../docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md) +- [docs/project_management/packs/PHASE_8_CROSS_CUTTING_DECISION_REGISTRY.md](../docs/project_management/packs/PHASE_8_CROSS_CUTTING_DECISION_REGISTRY.md) Required outcome: -- one merge order is chosen and implemented, -- policy restrictions remain narrowing-only, -- invalid or disallowed overrides fail closed with actionable errors, -- and effective launch capability truth is explainable and deterministic. +1. one merge order is chosen and implemented, +2. policy stays narrowing-only, +3. invalid or disallowed overrides produce actionable errors, +4. the effective contract is deterministic and explainable. -### 5. Keep orchestrator-owned worker dispatch as the primary model +### 5. Persist the host attach contract under the orchestration session Primary anchors: -- [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md:141) -- [ADR-0026](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md:131) +- [crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [crates/shell/src/execution/agent_runtime/state_store.rs](../crates/shell/src/execution/agent_runtime/state_store.rs) +- [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md) Required outcome: -- the shared envelope must support human launches, -- but it must also clearly support the primary v1 model where world workers are usually dispatched by the host orchestrator through orchestrator-only tools, -- and the same effective launch semantics must apply in both cases. +1. the existing host-rooted session-birth persistence path now writes the generalized exact host attach contract, +2. the existing successor-allocation path now copies that generalized contract forward, +3. later attach does not infer launch truth from ambient inventory or a stale participant record. -## Required Test Additions Or Tightening +### 6. Enforce caller parity -### Inventory-resolution coverage +Required outcome: -Required scenarios: +1. human launches and orchestrator-controlled launches both resolve through the same code path, +2. the same effective launch contract is visible to tests and diagnostics in both paths, +3. no second hidden contract appears in REPL-only or toolbox-only code. -- inventory defaults resolve deterministically, -- workspace inventory overrides global inventory correctly, -- and unsupported inventory/data combinations still fail closed. +## Acceptance Criteria -### Dispatch-merge coverage +1. One shared dispatch envelope exists and is the only launch-resolution contract used by runtime code. +2. Inventory remains the baseline source of defaults. +3. Dispatch-time scope and capability overrides have explicit, fail-closed semantics. +4. The resolved launch contract is deterministic and explainable. +5. The already-landed persisted host attach contract seam is generalized so the contract written under the orchestration session and copied across successors comes from the shared dispatch contract. +6. Human and orchestrator-controlled callers consume the same resolution logic. +7. Retained world-member follow-up turns use the shared-contract-derived parity subset and do not reopen inventory or config selection semantics. -Required scenarios: +## Validation Plan -- dispatch overrides narrow or refine defaults correctly, -- policy overlays remain restriction-only, -- invalid capability requests fail closed, -- and the effective resolved launch contract is stable and explainable. +Run at minimum: -### Caller-parity coverage +1. targeted inventory/config tests, +2. targeted dispatch-resolution tests, +3. targeted state-store tests covering persisted host attach contract behavior, +4. parity tests proving equivalent resolution for human and orchestrator-controlled launch requests, +5. retained-member routing tests proving typed world dispatch preserves the persisted resolved runtime subset, +6. external public-control and successor regressions proving persisted attach capability denial and successor continuity truth, +7. full workspace tests: + - `cargo test --workspace -- --nocapture` -Required scenarios: +Manual validation must prove: -- the same envelope semantics hold when dispatch originates from a human CLI path, -- the same envelope semantics hold when dispatch originates from an orchestrator tool path, -- and no second ad hoc worker-launch contract appears. +1. inventory defaults resolve deterministically, +2. allowed overrides narrow the effective launch contract correctly, +3. denied overrides fail closed with actionable errors, +4. the persisted host attach contract matches the resolved host launch contract, +5. the same effective contract is produced for equivalent human and orchestrator-controlled inputs, +6. retained member follow-up turns preserve resolved backend/protocol/runtime-path truth across reuse and replacement. -## Acceptance Criteria +## Docs And Truth Sync -- one shared dispatch envelope exists and is consumed by both human-facing and orchestrator-driven dispatch paths. -- inventory/profile remains the baseline source of defaults. -- dispatch-time scope and capability overrides have explicit semantics. -- policy restrictions stay fail-closed and narrowing-only. -- richer worker capability selection is modeled as explicit launch input rather than implicit hardcoded runtime behavior. +When this slice closes: -## Validation Expectations +1. update [ADR-0027](../docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md) or successor truth docs if they still underspecify dispatch-time override resolution, +2. document the shared dispatch envelope and merge rules explicitly, +3. document the generalized persisted host attach contract as a Substrate durable-state concept without regressing the already-landed 28.5 floor, +4. stop implying that future host attach can be reconstructed by guessing from the last attached participant. -- run targeted inventory/config/policy tests and any new dispatch-resolution tests, -- run the full touched package coverage and then full workspace tests: - - `cargo test --workspace -- --nocapture` -- manual validation for this slice must explicitly demonstrate: - - inventory defaults, - - dispatch override application, - - policy-denied override failure, - - and equivalent behavior between human and orchestrator-driven dispatch inputs. +## Sequencing -## Docs And Truth Sync +Ready to implement after 28.5: -When this slice is closed: +1. this SOW. -- update docs so inventory/profile is clearly described as the baseline layer, -- document the shared dispatch envelope and merge rules explicitly, -- and stop implying that worker capability selection is only hardcoded profile behavior or only future hand-waving. +Downstream slices that depend on this landing and remain draft: +1. [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md) +2. [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md) diff --git a/llm-last-mile/29-unlanded-findings-audit.md b/llm-last-mile/29-unlanded-findings-audit.md new file mode 100644 index 000000000..2456e9f93 --- /dev/null +++ b/llm-last-mile/29-unlanded-findings-audit.md @@ -0,0 +1,282 @@ +# Slice 29 Unlanded Findings Audit + +Status: final audit of the work landed after [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md) and claimed under [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md). + +## Scope + +This document records what was reviewed, what appears correctly landed, what remains unlanded, and which gaps still belong to slice 29 versus later slices 30 and 31. + +The review baseline was: + +1. parent floor at commit `2bf50131` (`feat: finalize control-only session recovery`) +2. current branch head after the slice-29 landing commits +3. the current truth documents: + - [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](./29-shared-agent-dispatch-envelope-and-capability-override-contract.md) + - [30-public-world-scoped-agent-start-and-capability-flags.md](./30-public-world-scoped-agent-start-and-capability-flags.md) + - [31-lazy-host-attach-for-host-rooted-world-start.md](./31-lazy-host-attach-for-host-rooted-world-start.md) + +Primary runtime/code anchors reviewed: + +- `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` +- `crates/shell/src/execution/agent_runtime/orchestration_session.rs` +- `crates/shell/src/execution/agent_runtime/state_store.rs` +- `crates/shell/src/execution/agent_runtime/validator.rs` +- `crates/shell/src/execution/agents_cmd.rs` +- `crates/shell/src/repl/async_repl.rs` + +## High-Level Conclusion + +Slice 29 is not invalidated, but it is not fully landed either. + +The merged code appears to have landed the shared-dispatch-contract foundation successfully: + +1. a new shared resolver exists in `dispatch_contract.rs` +2. human `start`, `reattach`, detached host `turn`, and `fork` now route through that resolver +3. the minimum 28.5 attach seam is still preserved +4. fail-closed error messaging and several targeted public-surface regressions are covered by tests + +However, several parts of the slice-29 contract are still incomplete relative to the SOW: + +1. the generalized persisted host attach contract is not yet the sole source of all persisted attach truth +2. inventory restriction-only policy overlays are not actually merged into the resolved contract +3. dispatch-time capability override families are named but not yet supported +4. caller parity is strong for human host flows and member-start selection, but not obviously complete for every orchestrator-controlled dispatch path + +## What Appears Correctly Landed + +### 1. Shared dispatch-resolution foundation exists + +The repo now has a dedicated internal contract module: + +- `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` + +That module introduces: + +1. `DispatchRequestEnvelope` +2. `DispatchCallerKind` +3. `DispatchBaselineKind` +4. `DispatchCapabilityOverrideSet` +5. `AttachLaunchKnobs` +6. `ResolvedLaunchContract` +7. explicit rejecting-layer / field-scoped resolution errors + +This is the clearest evidence that 29 landed real shared-resolution infrastructure instead of only documentation. + +### 2. Human host caller surfaces are wired onto the shared resolver + +The following public host surfaces now consume the shared contract: + +1. public `start` + - `build_start_launch_plan(...)` in `crates/shell/src/execution/agents_cmd.rs` +2. public `reattach` + - `build_attach_launch_plan(...)` +3. detached host `turn` + - `build_resumed_turn_launch_plan(...)` +4. public `fork` + - `allocate_fork_successor(...)` + +These paths resolve from either: + +1. inventory-backed baseline truth for new launch, or +2. persisted-attach-backed baseline truth for host continuity and successor allocation + +That matches the core 29 direction. + +### 3. The 28.5 persisted attach seam is preserved and extended + +The attach seam remains durable and fail-closed through: + +1. `HostAttachContract` +2. `OrchestrationSessionRecord.host_attach_contract` +3. `sync_host_attach_contract(...)` +4. `fork_successor_attach_contract(...)` + +The public control/state-store logic still correctly refuses to proceed when required attach truth is missing or disallowed. + +### 4. High-signal regression tests passed + +The following targeted checks were run successfully during audit: + +1. `cargo test -p shell dispatch_contract -- --nocapture` +2. `cargo test -p shell public_reattach_uses_persisted_attach_continuity_selector_for_resume_args -- --nocapture` +3. `cargo test -p shell public_turn_uses_persisted_attach_continuity_selector_when_recovering_detached_host_turns -- --nocapture` +4. `cargo test -p shell public_root_start_denials_name_field_layer_and_reason -- --nocapture` + +These passing checks increase confidence that the slice-29 foundation is real and not merely aspirational. + +## What Has Not Yet Been Landed + +### 1. The generalized persisted host attach contract does not yet fully round-trip its own truth + +This is the most important remaining gap. + +Slice 29 says the persisted host attach contract should carry: + +1. the resolved host-orchestrator launch contract +2. attach-relevant capability selections and restrictions +3. continuity selector state +4. enough exact launch truth to support both continuity attach and fresh attach + +Current code only partially achieves that. + +Evidence: + +1. `HostAttachContract::from_manifest(...)` still writes defaulted capabilities/knobs rather than deriving them from a resolved shared contract + - `crates/shell/src/execution/agent_runtime/orchestration_session.rs` +2. `sync_host_attach_contract(...)` only refreshes `continuity_uaa_session_id` + - it does not refresh capabilities, launch knobs, or other generalized attach truth +3. `resolve_persisted_host_attach_contract(...)` reconstructs capabilities with a hardcoded `AgentCapabilitiesV1` literal + - it does not consume the persisted `HostAttachContract.capabilities` values +4. `build_attach_launch_plan(...)` and `allocate_fork_successor(...)` construct new `AttachLaunchKnobs` from caller intent and pass them in through the envelope + - they do not replay attach knobs from the persisted contract as the primary source of truth + +Practical meaning: + +1. continuity state is persisted and reused correctly +2. baseline launch descriptor identity is persisted and reused correctly +3. but the generalized attach contract is not yet authoritative for all of the extra contract surface 29 claims to have landed + +This is slice-29-owned work, not 30/31 work. + +### 2. Inventory policy overlays are still validated but not merged into the resolved contract + +Slice 29 says inventory owns restriction-only embedded policy overlays and that merge precedence must include inventory defaults, dispatch overrides, and policy restrictions. + +Current code carries overlay data into `ProjectedInventoryEntryV1` but does not appear to apply it during contract resolution. + +Evidence: + +1. `ProjectedInventoryEntryV1` includes `policy_overlay` + - `crates/shell/src/execution/agent_inventory.rs` +2. `resolve_inventory_projected_contract(...)` validates baseline candidate shape and checks `agents_allowed_backends` + - but it never merges `projected.policy_overlay` +3. `ResolvedLaunchContract.effective_policy` is currently just `base_policy.clone()` on inventory-backed resolution + - not a narrowed merged policy +4. `DispatchResolutionErrorKind::InvalidPolicyOverlay` exists but is not obviously exercised by the resolver path + +Practical meaning: + +1. the contract surface now has a place for merged policy truth +2. the merge itself is still incomplete + +This is slice-29-owned work. + +### 3. Dispatch-time capability override families are named but still entirely rejected + +Slice 29 says the minimum supported override families should cover: + +1. execution scope +2. explicit capability narrowing or selection +3. attach-relevant launch knobs needed later by 30 and 31 +4. narrower policy overlays where permitted + +The implementation currently names these categories, but does not yet support capability overrides on current caller surfaces. + +Evidence: + +1. `DispatchCapabilityOverrideSet` exists in `dispatch_contract.rs` +2. inventory-backed resolution rejects any non-empty `capability_overrides` +3. persisted-attach-backed resolution also rejects any non-empty `capability_overrides` +4. the rejection reason explicitly says these overrides are frozen but not supported by the current caller surfaces in slice-29 foundation + +Practical meaning: + +1. the shape is present +2. the fail-closed behavior is present +3. the actual minimum usable override contract is not yet fully landed + +This is slice-29-owned work. + +### 4. Caller parity is strong but not obviously complete for every orchestrator-controlled dispatch path + +Slice 29 says: + +1. human launches and orchestrator-controlled launches both resolve through the same code path +2. no second hidden contract should exist in REPL-only or toolbox-only code + +What looks landed: + +1. member-start selection now uses the shared resolver through `validate_member_selection(...)` +2. exact runtime selection helpers now route through the shared contract too + +What remains unclear or incomplete: + +1. `DispatchCallerKind::OrchestratorMemberTurn` exists but appears unused +2. retained world-member turn submission still appears to construct and send `MemberTurnSubmitRequestV1` directly rather than obviously resolving through `DispatchRequestEnvelope` + +This is the least clear ownership gap: + +1. it is not 30 work +2. it is not 31 work +3. it may be incomplete 29 work if the intended promise was full dispatch parity across all orchestrator-controlled dispatch paths +4. or it may be acceptable 29 foundation work if the intended promise was narrower and the remaining parity step was intentionally deferred without being written down clearly + +For closeout purposes, this should be treated as an explicit open item rather than silently assumed complete. + +## What Is Not Missing Because It Belongs To 30 + +The following are later-slice responsibilities and should not be counted as unlanded slice-29 work: + +1. public `substrate agent start --scope world` +2. user-facing public scope selection on `agent start` +3. CLI capability flags for that new public world-start surface +4. creating a host-rooted durable session plus world worker without eager host attach as a public entrypoint + +Those are explicitly owned by [30-public-world-scoped-agent-start-and-capability-flags.md](./30-public-world-scoped-agent-start-and-capability-flags.md). + +## What Is Not Missing Because It Belongs To 31 + +The following are later-slice responsibilities and should not be counted as unlanded slice-29 work: + +1. born-unattached host-rooted session behavior as an operator-visible steady state +2. fresh attach versus continuity attach mode selection at runtime +3. explicit lazy-attach triggering policy +4. attach-worker behavior for sessions born without a host execution client + +Those are explicitly owned by [31-lazy-host-attach-for-host-rooted-world-start.md](./31-lazy-host-attach-for-host-rooted-world-start.md). + +## Ownership Summary + +### Clearly still owned by 29 + +1. finishing the generalized persisted host attach contract so its durable fields are actually authoritative end to end +2. merging restriction-only inventory policy overlays into resolved `effective_policy` +3. implementing the promised minimum usable override families, or explicitly narrowing the SOW/landed-truth claim to “foundation only” + +### Probably owned by 29, but needs an explicit closeout decision + +1. whether orchestrator member-turn dispatch must also resolve through the shared dispatch envelope to count as caller parity complete + +### Owned by 30 + +1. public world-scoped `agent start` +2. public scope/capability CLI surface for that entrypoint + +### Owned by 31 + +1. born-unattached session semantics +2. fresh attach realization +3. lazy-attach trigger semantics + +## Recommended Closeout Position + +The most accurate final statement today is: + +1. slice 29 landed the shared dispatch-contract foundation correctly +2. slice 29 did not yet land every contract-completeness item its SOW claims +3. slice 30 and 31 should not be used to absorb the still-missing overlay/override/generalized-attach-truth work +4. a narrow slice-29 follow-on or explicit slice-29 closeout correction is still needed before the stack can honestly claim that 29 is fully complete + +## Minimal Follow-On Checklist + +If the goal is to finish slice 29 cleanly without broadening into 30/31: + +1. make `HostAttachContract` the authoritative source for generalized attach capabilities and launch knobs during persisted-attach resolution +2. ensure session birth and later sync populate those fields from resolved contract truth rather than defaults +3. merge validated restriction-only inventory `policy_overlay` into the resolved `effective_policy` +4. either: + - implement the minimum supported capability/overlay override families promised by the SOW, or + - revise the landed-truth claim to say slice 29 landed only the override-contract foundation +5. make an explicit decision on whether orchestrator member-turn parity is required for 29 completeness + +That closes the real slice-29 gaps without drifting into the public world-start or lazy-attach work reserved for 30 and 31. diff --git a/llm-last-mile/29.5-shared-dispatch-contract-closeout-and-parity-hardening.md b/llm-last-mile/29.5-shared-dispatch-contract-closeout-and-parity-hardening.md new file mode 100644 index 000000000..eddb78652 --- /dev/null +++ b/llm-last-mile/29.5-shared-dispatch-contract-closeout-and-parity-hardening.md @@ -0,0 +1,290 @@ +# SOW: Shared Dispatch Contract Closeout And Parity Hardening + +Status: implementation-ready closeout slice. This slice exists because the foundation landed under [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md) is real, but the audit in [29-unlanded-findings-audit.md](29-unlanded-findings-audit.md) confirmed several contract promises remain incomplete. This slice closes those gaps before the public expansion in [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md) and the lazy-attach realization in [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md). + +This is not a replacement for 29. It is the narrow completion slice that makes 29 truthful enough to serve as the dependency floor for 30 and 31. + +## Objective + +Land the remaining shared-dispatch-contract work that 29 claimed or implied, without broadening into the public world-start surface or lazy-host-attach behavior. + +This slice is done only when all of the following are true: + +1. the persisted `HostAttachContract` is authoritative for the attach-relevant resolved host launch truth 29 promised; +2. inventory restriction-only `policy_overlay` data is actually merged into the resolved contract as narrowing-only effective policy; +3. the minimum dispatch-time override families promised by 29 are either genuinely supported or explicitly fail closed for bounded reasons that match the truth docs; +4. orchestrator-controlled dispatch no longer leaves a second hidden contract in retained member-turn paths; +5. slices 30 and 31 can depend on one truthful shared dispatch contract and one truthful durable host-attach contract without reopening 29 semantics. + +## Why 29.5 Exists + +The current branch already landed valuable shared infrastructure: + +1. `dispatch_contract.rs` exists as a real shared resolver; +2. human `start`, `reattach`, detached host `turn`, and `fork` now use it; +3. member selection and world-member cold-start planning already route through it; +4. fail-closed diagnostics and continuity-path regressions are covered by tests. + +What remains incomplete is not architectural discovery. It is closeout. + +The audit validated four remaining gaps: + +1. generalized persisted attach truth is only partially authoritative end to end; +2. inventory `policy_overlay` is validated but not merged into `ResolvedLaunchContract.effective_policy`; +3. capability override families are scaffolded but not actually usable; +4. retained orchestrator member-turn submission still bypasses the shared contract path. + +If these remain unresolved: + +1. 30 will expose public world-start on top of incomplete override and attach truth; +2. 31 will try to realize continuity attach and fresh attach on top of a contract that still reconstructs too much at call time; +3. the repo will again have multiple nearly-correct dialects for launch and attach semantics. + +## Frozen Direction + +### 1. 29.5 is a closeout slice, not a new product slice + +This slice must not reopen host-rooted versus world-root architecture, public CLI design, or lazy-attach product behavior. + +### 2. `HostAttachContract` remains the only durable attach object + +No second persisted attach structure may be introduced. The fix is to make the existing contract authoritative enough for 30 and 31, not to create a replacement object. + +### 3. Policy remains narrowing-only and fail-closed + +Inventory overlays and dispatch-time overrides may narrow or deny. They may not broaden baseline truth. + +### 4. Caller parity means no REPL-only or control-only contract dialect + +Human and orchestrator-controlled paths may use different transport shapes, but they must not carry separate launch-resolution semantics. + +### 5. 29.5 stops at contract truth + +This slice does not ship public `agent start --scope world`, and it does not ship born-unattached host session behavior. It only makes those later slices safe. + +### 6. The follow-on planning pass must freeze the override closeout direction + +The planning agent for this slice must explicitly choose one of these two directions and treat that choice as binding for implementation artifacts: + +1. implement the bounded capability narrowing/selection support that 29 promised; or +2. narrow the 29 and 29.5 truth surface so the repo no longer claims capability-override support beyond what the code will actually land. + +The planning agent must not leave both paths open in `PLAN.md`, `ORCH_PLAN.md`, or equivalent execution artifacts. + +### 7. The follow-on planning pass must freeze the retained member-turn parity shape + +The planning agent for this slice must explicitly decide whether retained orchestrator member-turn submission: + +1. resolves through `DispatchRequestEnvelope` directly; or +2. is allowed to consume a persisted or already-resolved subset derived from that same shared contract. + +That decision must be made explicitly in planning artifacts, and the chosen shape must be justified against the no-hidden-dialect rule. The planning agent must not leave the parity integration shape underspecified. + +## What This Slice Assumes Is Already Landed + +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md) has already split prompt-bearing launch from control-only recovery and established host-rooted orchestration authority. +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md) has already landed the shared resolver module, the baseline-domain split, and the initial caller adoption. +3. The audit in [29-unlanded-findings-audit.md](29-unlanded-findings-audit.md) is the closeout baseline for this slice. + +## What This Slice Must Make True For 30 + +After 29.5, slice 30 must be able to assume: + +1. public scope/capability flags map onto one truthful shared override model rather than scaffolding-only fields; +2. world-scoped public start can persist a host attach contract that already contains the attach-relevant truth it will need later; +3. orchestrator-controlled dispatch and public start are not diverging on hidden resolution semantics. + +## What This Slice Must Make True For 31 + +After 29.5, slice 31 must be able to assume: + +1. the durable host attach contract already carries authoritative continuity-versus-fresh attach inputs; +2. attach-worker mode selection can trust persisted attach knobs and attach-relevant capability truth; +3. lazy attach does not need to re-derive launch truth from ambient participant state or one-off caller intent. + +## In Scope + +1. finish generalized `HostAttachContract` persistence so it is derived from the resolved host launch contract and remains authoritative end to end for attach-relevant fields; +2. merge validated inventory `policy_overlay` truth into the resolved effective policy as restriction-only narrowing; +3. implement the minimum usable dispatch-time override families that 29 promised, or narrow the truth surface so the repo no longer claims more than the code delivers; +4. route retained orchestrator member-turn dispatch through the same shared contract semantics or the persisted subset already derived from it; +5. tighten tests and truth docs so 29, 29.5, 30, and 31 all describe the same contract floor. + +## Out Of Scope + +1. shipping public `substrate agent start --scope world`; +2. broad public CLI UX design for all future override families; +3. deciding whether lazy attach is manual-only or automatically triggered; +4. born-unattached host session posture/status semantics beyond the durable contract fields 31 will later consume; +5. inventing a new crate, a new durable attach object, or a second orchestration state model. + +## Exact Gaps This Slice Owns + +### 1. Persisted attach truth closeout + +Current code persists durable identity and continuity, but still defaults or reconstructs parts of the attach contract at resolution time. + +This slice must make the persisted contract authoritative for: + +1. backend/protocol/scope/launch descriptor truth; +2. attach-relevant capability selections and restrictions; +3. attach launch knobs required by later continuity attach and fresh attach behavior; +4. successor-copy behavior that preserves generalized truth while clearing only continuity-specific state where appropriate. + +### 2. Inventory policy overlay merge closeout + +Current code validates embedded restriction-only overlays, but does not apply them to `ResolvedLaunchContract.effective_policy`. + +This slice must: + +1. merge valid inventory overlays into effective policy during inventory-backed resolution; +2. preserve narrowing-only semantics; +3. expose denial or invalid-overlay reasons truthfully; +4. ensure later runtime consumers can trust `effective_policy` as actual merged truth rather than a placeholder field. + +### 3. Override-family closeout + +Current code names override families, but capability overrides are rejected universally and scope override is only a guardrail. + +This slice must settle the minimum supported 29 contract explicitly: + +1. attach launch knobs remain a real supported override family; +2. capability narrowing/selection becomes genuinely supported for the bounded families 29 promised, or 29/29.5 truth is narrowed so the repo no longer claims those families are already supported; +3. unsupported families remain explicit and fail closed; +4. provenance and denial semantics match what the code actually does. + +### 4. Orchestrator parity closeout + +Current code routes orchestrator member selection and cold-start world-member launch through the shared contract, but retained member-turn submission still constructs typed requests directly from live manifest state. + +This slice must eliminate the hidden second dialect by ensuring: + +1. retained member-turn dispatch consumes shared contract semantics or the exact persisted/resolved subset already derived from them; +2. `DispatchCallerKind::OrchestratorMemberTurn` is either used truthfully or removed from the claimed contract surface; +3. REPL-only and control-only transport code do not silently own launch-resolution semantics. + +## Required Work Breakdown + +### 1. Finish generalized host attach contract derivation + +Primary anchors: + +- [crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](../crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +- [crates/shell/src/execution/agents_cmd.rs](../crates/shell/src/execution/agents_cmd.rs) + +Required outcome: + +1. session-birth persistence writes `HostAttachContract` from resolved-contract truth, not from manifest-era defaults; +2. persisted attach resolution consumes persisted capabilities and attach knobs as authoritative where 29 said they were durable; +3. `sync_host_attach_contract(...)` only updates fields that are legitimately runtime-refreshed, and does not leave other persisted truth permanently stale; +4. successor allocation copies the generalized contract forward while clearing only continuity state that must not be inherited; +5. nothing in attach planning re-derives durable attach truth from detached participant snapshots. + +### 2. Merge inventory overlays into resolved effective policy + +Primary anchors: + +- [crates/shell/src/execution/agent_inventory.rs](../crates/shell/src/execution/agent_inventory.rs) +- [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](../crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +- [crates/shell/src/execution/policy_model.rs](../crates/shell/src/execution/policy_model.rs) + +Required outcome: + +1. inventory-backed resolution applies valid `policy_overlay` truth into `effective_policy`; +2. merge behavior remains restriction-only and auditable; +3. invalid overlay data produces explicit resolver errors at the right layer; +4. tests prove the resolved contract is narrower when overlay data is present. + +### 3. Close the override contract honestly + +Primary anchors: + +- [crates/shell/src/execution/agent_runtime/dispatch_contract.rs](../crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +- [crates/shell/src/execution/agent_runtime/validator.rs](../crates/shell/src/execution/agent_runtime/validator.rs) +- [crates/shell/src/execution/agents_cmd.rs](../crates/shell/src/execution/agents_cmd.rs) + +Required outcome: + +1. the repo explicitly supports the bounded override families that 30 will later expose publicly; +2. capability narrowing/selection behavior is either implemented for the currently promised fields or the doc surface is narrowed to the real supported set; +3. accepted overrides are reflected in the resolved contract and provenance; +4. denied overrides remain field-scoped, fail-closed, and explanation-ready. + +### 4. Finish orchestrator parity for retained member turns + +Primary anchors: + +- [crates/shell/src/repl/async_repl.rs](../crates/shell/src/repl/async_repl.rs) +- [crates/shell/src/execution/agent_runtime/control.rs](../crates/shell/src/execution/agent_runtime/control.rs) +- [crates/shell/src/execution/routing/dispatch/world_ops.rs](../crates/shell/src/execution/routing/dispatch/world_ops.rs) + +Required outcome: + +1. retained member-turn submission no longer owns hidden launch semantics; +2. typed submit transport continues to exist, but its inputs come from the same shared contract vocabulary; +3. parity tests can prove equivalent resolved truth for equivalent human and orchestrator-controlled cases where the baseline domain is the same. + +### 5. Sync truth docs and downstream assumptions + +Primary anchors: + +- [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md) +- [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md) +- [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md) + +Required outcome: + +1. 29 truth is updated if any claimed override family or parity promise must be narrowed; +2. 30 explicitly depends on 29.5 for the public scope/capability surface; +3. 31 explicitly depends on 29.5 for authoritative persisted attach truth and attach knobs; +4. no later slice doc still assumes behavior that 29.5 intentionally leaves out. + +## Acceptance Criteria + +This slice is complete only when all of the following are true: + +1. one resolved host launch contract can be persisted into `HostAttachContract` without losing attach-relevant capability or attach-knob truth; +2. persisted attach resolution reuses that truth instead of reconstructing it from caller intent where the contract is supposed to be authoritative; +3. inventory restriction-only overlays narrow the resolved `effective_policy` in actual resolver output; +4. the minimum supported override families are truthful in both code and docs; +5. retained orchestrator member-turn dispatch no longer constitutes a second hidden launch-resolution dialect; +6. 30 and 31 can both cite 29.5 as the contract-completion floor they depend on. + +## Validation Plan + +Run at minimum: + +1. targeted resolver tests covering overlay merge, capability override acceptance and denial, and persisted attach resolution; +2. targeted orchestration-session/state-store tests covering generalized `HostAttachContract` persistence, sync, and successor-copy behavior; +3. targeted REPL/public-surface parity tests covering retained member-turn submission and world-member startup; +4. full contract-focused shell gates: + - `cargo test -p shell dispatch_contract -- --nocapture` + - `cargo test -p shell agent_public_control_surface_v1 -- --nocapture` + - `cargo test -p shell repl_world_first_routing_v1 -- --nocapture` + +Manual validation must prove: + +1. a persisted host attach contract matches the resolved host launch contract for attach-relevant fields; +2. reattach/fork planning uses durable contract truth rather than detached ambient state; +3. inventory overlays materially narrow resolved policy when present; +4. supported capability overrides narrow the effective contract correctly; +5. retained orchestrator member turns continue to use typed transport while no longer bypassing shared contract semantics. + +## Sequencing + +Updated stack status should be: + +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md): landed architectural floor. +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md): landed shared-contract foundation. +3. This SOW: implementation-ready closeout slice that makes 29 truthful and dependable. +4. [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md): public entrypoint slice after 29.5. +5. [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md): lazy-attach realization slice after 29.5 and 30. + +## Planning Requirements + +Any next-step planning artifact derived from this SOW must: + +1. lock one override closeout direction instead of carrying both implementation and doc-narrowing as parallel possibilities; +2. lock one retained member-turn parity shape instead of treating direct-envelope and derived-subset approaches as simultaneously acceptable; +3. reflect those two decisions in scope, ownership, acceptance criteria, and validation coverage. diff --git a/llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md b/llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md new file mode 100644 index 000000000..9a7bb5182 --- /dev/null +++ b/llm-last-mile/29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md @@ -0,0 +1,253 @@ +# SOW: Authoritative Host Attach Truth And REPL Cold-Start Parity + +Status: landed contract-authority closeout floor. This SOW followed [29.5-shared-dispatch-contract-closeout-and-parity-hardening.md](29.5-shared-dispatch-contract-closeout-and-parity-hardening.md) and the audit in [../SHARED_DISPATCH_CLOSEOUT_AUDIT_2026-05-25.md](../SHARED_DISPATCH_CLOSEOUT_AUDIT_2026-05-25.md). It existed because 29.5 landed meaningful contract work, but the audit confirmed the remaining gaps were still large enough to block [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md) and [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md) from promoting safely. + +This is not a replacement for 29 or 29.5. It is the narrow follow-up slice that finishes the remaining contract-authority seams those slices still leave open. + +## Objective + +Finish the last shared-dispatch authority gaps so every host-rooted session birth path persists the same attach-relevant truth and every later attach path consumes that truth without regaining manifest-era or permissive defaults. + +This slice is done only when all of the following are true: + +1. REPL host-orchestrator cold start resolves through the same shared dispatch contract family as the public start path. +2. birth-time host session persistence stores `HostAttachContract` from resolved-contract truth rather than `HostAttachContract::from_manifest(...)` defaults. +3. the repo explicitly freezes the authoritative durable attach baseline plus bounded attach-time overlay model and implements it consistently across code, docs, and tests. +4. steady-state attach resolution no longer falls back to `Policy::default()` or other permissive reconstruction when durable truth is missing. +5. 30 and 31 can depend on 29.75 as the final contract-authority floor instead of depending on a partially completed 29.5 closeout. + +## Why 29.75 Exists + +The audit confirmed that 29.5 was only partially a closeout slice. + +What is already landed enough to treat as prior floor: + +1. inventory `policy_overlay` is merged into `ResolvedLaunchContract.effective_policy` as restriction-only truth; +2. bounded dispatch-time capability narrowing is implemented for `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, and `event_stream`, and stays narrowing-only; +3. retained world-member follow-up turns already consume a shared-contract-derived parity subset instead of live descriptor drift. + +What this slice closed: + +1. REPL host cold start now persists durable attach truth from resolved-contract semantics instead of manifest-era defaults; +2. the repo now enforces one authoritative durable attach baseline plus bounded attach-time overlay model consistently across code, docs, and tests; +3. fallback paths no longer recreate weaker or default attach truth in places the contract is supposed to treat as authoritative; +4. downstream slices 30 and 31 can now point at 29.75 as the truthful dependency floor. + +## Frozen Direction + +### 1. This is a residual hardening slice, not a redo of 29.5 + +29.75 must not reopen overlay merge, retained member-turn parity, or bounded capability narrowing as if they were still unlanded. Those are prior floor unless concrete regressions are discovered. + +### 2. REPL host cold start must join the shared contract floor + +The REPL host-orchestrator cold-start path must no longer birth a durable session from manifest-only truth. It must carry resolved launch truth far enough to persist `HostAttachContract` from that truth. + +### 3. `HostAttachContract` remains the only durable attach object + +This slice must not invent a second persisted attach model. The fix is to make the existing durable contract authoritative enough for both public attach and later lazy attach. + +### 4. Durable attach baseline plus bounded overlay is the normative contract + +29.75 freezes the contract as follows: + +1. `HostAttachContract` remains the only durable attach object; +2. Layer 1 is the durable authoritative baseline carried by `HostAttachContract`; +3. that durable baseline includes backend identity, protocol, execution scope, resolved launch descriptor, attach-relevant capabilities, effective policy snapshot, continuity selector state, and attach policy defaults; +4. attach policy defaults explicitly include `requested_execution_scope`, `host_execution_client_start`, and `attach_mode_preference`; +5. Layer 2 is an attach-time request overlay, not a second baseline and not caller-owned replacement truth; +6. the overlay may only: + - honor baseline exactly, + - narrow baseline where the baseline semantics permit narrowing, + - select among baseline-permitted modes when the baseline explicitly allows multiple acceptable attach realizations; + - if the baseline does not explicitly encode multiple permitted realizations, the overlay may only honor or narrow, not select; +7. the overlay may not silently broaden baseline truth, replace baseline semantics, or let different callers reinterpret the same durable session ad hoc over time. + +31 still owns attach-worker behavior on top of this contract, but it must consume this same two-layer model rather than reopening baseline semantics. + +### 5. Missing durable truth must be fail-closed + +Steady-state code paths may not silently recover contract truth from `Policy::default()`, permissive capability defaults, or fresh `from_manifest(...)` reconstruction. 29.75 is greenfield for durable attach truth and does not preserve a legacy recovery or backfill surface in this slice. + +### 6. 30 and 31 remain product slices, not contract-repair slices + +30 continues to own public `agent start --scope world` exposure. 31 continues to own lazy attach trigger policy and continuity-versus-fresh attach behavior. Neither slice should have to repair baseline contract truth after 29.75 lands. + +## What This Slice Assumes Is Already Landed + +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md) established host-rooted orchestration authority and split control-only attach from prompt-bearing launch. +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md) established the shared dispatch vocabulary, the shared resolver, and the two baseline domains. +3. [29.5-shared-dispatch-contract-closeout-and-parity-hardening.md](29.5-shared-dispatch-contract-closeout-and-parity-hardening.md) plus the 2026-05-25 audit together define the real current floor: some closeout items are landed, but authoritative attach truth and REPL cold-start parity are not. + +## In Scope + +1. route REPL host-orchestrator cold start through shared-contract-backed resolution and persist birth-time attach truth from that resolved contract; +2. freeze the durable authoritative baseline plus bounded attach-time overlay model for persisted attach resolution and document it truthfully; +3. remove or quarantine steady-state fallback paths that recreate permissive/default attach truth; +4. remove steady-state manifest reconstruction and fail closed on missing or invalid durable attach truth; +5. rewire downstream SOW dependencies so 30 and 31 cite 29.75 as the final contract-authority gate. + +## Out Of Scope + +1. shipping public `substrate agent start --scope world`; +2. widening capability override families beyond the already-landed bounded narrowing set; +3. changing retained member-turn parity architecture that is already landed; +4. deciding whether lazy attach is manual-only or may auto-launch from pending work; +5. inventing a second durable attach structure or a third baseline domain. + +## Exact Gaps This Slice Owns + +### 1. REPL host cold-start parity + +Public start persistence already rewrites `session.host_attach_contract` from `HostAttachContract::from_resolved_contract(...)`. + +The landed 29.75 floor closes the last parity gap: + +1. REPL host cold start now resolves through the shared dispatch contract family before runtime materialization; +2. the REPL path carries enough resolved truth to persist `HostAttachContract::from_resolved_contract(...)` on the first durable parent-session write; +3. equivalent public and REPL cold starts now persist equivalent durable attach truth. + +### 2. Durable attach truth authority + +The durable contract is now strong enough to carry authoritative backend, protocol, scope, capability, and policy truth. The remaining problem is not missing structure. It is that too many paths still treat manifest-derived or default-derived truth as acceptable replacements. + +29.75 must make birth-time resolved truth authoritative and eliminate manifest-based reconstruction from steady-state authority paths. + +### 3. Durable attach policy fields and bounded overlay semantics + +The landed codebase now treats persisted attach policy fields consistently as durable baseline truth and defines one bounded overlay model for later attach requests. Later attach requests reuse persisted attach policy defaults from `HostAttachContract` and may only vary behavior through an explicit overlay constrained by that baseline. + +### 4. Fail-closed missing-truth behavior + +The landed runtime no longer defaults `effective_policy` to `Policy::default()`, and `sync_host_attach_contract(...)` no longer recreates a missing contract from manifest defaults. Missing required durable truth now fails closed. + +## Required Work Breakdown + +### 1. Carry resolved-contract truth into REPL host session birth + +Primary anchors: + +- [../crates/shell/src/repl/async_repl.rs](../crates/shell/src/repl/async_repl.rs) +- [../crates/shell/src/execution/agent_runtime/dispatch_contract.rs](../crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +- [../crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) + +Required outcome: + +1. REPL host bootstrap resolves through shared-contract-backed semantics before durable session birth; +2. the REPL path carries enough resolved truth to persist `HostAttachContract::from_resolved_contract(...)`; +3. `RuntimeSelectionDescriptor` may still be used for runtime startup materialization, but not as the sole durable attach truth source; +4. parity tests prove that equivalent public and REPL host cold starts persist equivalent attach-relevant truth. + +### 2. Remove `from_manifest(...)` from steady-state authority paths + +Primary anchors: + +- [../crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) +- [../crates/shell/src/execution/agent_runtime/state_store.rs](../crates/shell/src/execution/agent_runtime/state_store.rs) + +Implementation note: + +The repo currently tolerates some partial persisted `HostAttachContract` rows through serde defaults and corresponding state-store tests. 29.75 must tighten that posture: missing required attach fields must no longer deserialize into accepted steady-state truth, and tests that currently expect partial-contract backfill must be rewritten to expect fail-closed behavior. + +Required outcome: + +1. steady-state host session birth paths do not seed durable attach truth from manifest-era defaults; +2. `sync_host_attach_contract(...)` does not silently repopulate a missing modern contract from manifest defaults; +3. missing, null, malformed, or incomplete persisted durable attach truth fails closed rather than routing through repair logic; +4. tests cover fail-closed behavior for those unsupported or corrupt states. + +### 3. Freeze the durable baseline plus bounded attach-time overlay model + +Primary anchors: + +- [../crates/shell/src/execution/agent_runtime/dispatch_contract.rs](../crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +- [../crates/shell/src/execution/agents_cmd.rs](../crates/shell/src/execution/agents_cmd.rs) +- [../crates/shell/src/execution/agent_runtime/state_store.rs](../crates/shell/src/execution/agent_runtime/state_store.rs) + +Required outcome: + +1. durable persisted attach resolution reuses the full authoritative attach baseline from `HostAttachContract`, including backend identity, protocol, scope, resolved launch descriptor, attach-relevant capabilities, effective policy snapshot, continuity selector state, and attach policy defaults; +2. any later attach-time request inputs are modeled explicitly as a bounded overlay layer constrained by that baseline rather than as silent caller-side replacement of persisted attach policy; +3. the SOW-level contract is enforced exactly: the overlay may honor baseline, narrow baseline where supported, or select among baseline-permitted modes, but may not broaden or semantically replace the durable baseline; +4. field provenance, diagnostics, and tests reflect those override rules truthfully; +5. 31 can build attach-worker mode selection on top of that frozen contract without reopening baseline semantics. + +### 4. Remove permissive/default steady-state fallback + +Primary anchors: + +- [../crates/shell/src/execution/agent_runtime/dispatch_contract.rs](../crates/shell/src/execution/agent_runtime/dispatch_contract.rs) +- [../crates/shell/src/execution/agent_runtime/orchestration_session.rs](../crates/shell/src/execution/agent_runtime/orchestration_session.rs) + +Required outcome: + +1. missing or invalid persisted policy snapshot no longer degrades silently to `Policy::default()` in steady-state modern paths; +2. missing modern `HostAttachContract` no longer regains permissive capability defaults through silent reconstruction; +3. failure messages remain explanation-ready and point to durable-state corruption or unsupported persisted-state posture where appropriate. + +### 5. Rewire downstream planning truth + +Primary anchors: + +- [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md) +- [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md) +- [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md) + +Required outcome: + +1. 29 remains the foundation slice but no longer overclaims that 29.5 already finished every attach-authority gap; +2. 30 cites 29.75 as the final dependency floor for public world-scoped start; +3. 31 cites 29.75 as the final dependency floor for authoritative attach truth and 30 as the public entrypoint dependency; +4. no downstream doc still implies that 30 or 31 will repair contract-authority gaps left by 29.x. + +## Acceptance Criteria + +This slice is complete only when all of the following are true: + +1. REPL host cold start and public start persist equivalent `HostAttachContract` truth for equivalent host-backed launches; +2. no steady-state host session birth path relies on `HostAttachContract::from_manifest(...)` as its primary truth source; +3. persisted attach resolution reuses the full durable attach baseline from `HostAttachContract`, including attach policy defaults for `requested_execution_scope`, `host_execution_client_start`, and `attach_mode_preference`; +4. any attach-time request variation is modeled explicitly as a bounded overlay constrained by that baseline, with no silent caller-side replacement of persisted attach policy; +5. missing durable policy or attach truth fails closed in modern steady-state paths instead of silently broadening through defaults; +6. code, tests, and docs all describe the same overlay rules: honor baseline, narrow where permitted, or select among baseline-permitted modes, but never broaden or replace the durable baseline silently; +7. 30 and 31 can both move forward after 29.75 without reopening baseline attach semantics. + +## Validation Plan + +Run at minimum: + +1. targeted REPL bootstrap tests covering durable host attach persistence at cold start and parity with public start; +2. targeted orchestration-session tests covering modern fail-closed behavior for missing, null, malformed, or incomplete persisted durable truth; +3. targeted persisted-attach resolver tests covering full durable baseline reuse, bounded overlay behavior, forbidden broadening/replacement cases, and invalid/missing policy snapshot handling; +4. existing contract-focused shell gates: + - `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` + - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + +Manual validation must prove: + +1. a REPL-born host session persists the same attach-relevant truth shape as a public-start-born host session for the same backend; +2. later attach planning reuses the full durable baseline from `HostAttachContract`, including attach policy defaults, without regaining `Policy::default()` or manifest-default capabilities; +3. missing or invalid persisted durable truth fails closed with no compatibility branch in this slice; +4. bounded overlay behavior is explicit and auditable, with no caller-side semantic replacement of persisted attach policy; +5. 30 and 31 can be read straight through after 29.75 without contradicting the actual runtime contract floor. + +## Sequencing + +Updated stack status should be: + +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md): architectural floor. +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md): shared dispatch vocabulary and baseline-domain foundation. +3. [29.5-shared-dispatch-contract-closeout-and-parity-hardening.md](29.5-shared-dispatch-contract-closeout-and-parity-hardening.md): partial closeout that landed overlay merge, bounded narrowing, and retained member-turn parity subset. +4. This SOW: final contract-authority hardening required before public world start and lazy attach promotion. +5. [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md): public host-rooted world-start surface after 29.75. +6. [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md): lazy attach realization after 29.75 and 30. + +## Planning Requirements + +Any execution plan derived from this SOW must: + +1. treat overlay merge, bounded capability narrowing, and retained member-turn parity subset as already-landed floor unless a concrete regression is found; +2. freeze the durable authoritative baseline plus bounded attach-time overlay model exactly as described here instead of leaving both interpretations open; +3. explicitly freeze fail-closed steady-state behavior with no legacy compatibility helper or backfill surface in this slice; +4. keep 30 and 31 out of contract-repair work. diff --git a/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md b/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md index 16017c65b..26915cc25 100644 --- a/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md +++ b/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md @@ -1,197 +1,141 @@ # SOW: Public World-Scoped Agent Start And Capability Flags -Status: remaining-work draft. This SOW is the public contract follow-on after [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md). It is anchored to [ADR-0025 — Agent Hub Core](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md), [ADR-0027 — LLM and Agent Config/Policy Surface](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md), and [ADR-0047 — Host Orchestrator Durable Session and Parked-Resumable Ownership](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md). +Status: draft aligned to validated architecture. This slice is not implementation-ready yet. It depends on [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md), [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md), and the landed [29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md](29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md) floor. -This slice is a real public contract expansion. Today, public root start remains host-only. This slice is where that rule changes, if the repo chooses to change it. +This slice no longer carries host-rooted versus standalone world-root as an open product decision. The validated architecture has already closed that question. -## Current Frozen Contract +## Frozen Direction -The current public contract still says: +The only valid forward meaning of public `substrate agent start --scope world ...` is: -- `substrate agent start --backend --prompt ...` is host-only in v1 in [docs/USAGE.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md:115), -- world-only root start is intentionally rejected today in [agents_cmd.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs:915), -- and [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md:90) still fixes root `start` as host-only in v1. +1. create a host-rooted durable orchestration session, +2. persist the resolved host attach contract under that session, +3. launch a world worker/member under that session through the shared dispatch contract, +4. leave durable authority host-rooted, +5. avoid eager host execution-client startup. -This SOW is the slice that would intentionally broaden that contract. +This slice must not reopen standalone world-root continuity. ## Objective -Broaden the public `substrate agent start` surface so a human can explicitly launch host-scoped or world-scoped agents through one shared dispatch contract, including explicit capability flags. +Broaden the public `substrate agent start` surface so a human can explicitly launch a world-scoped worker under a host-rooted orchestration session using the shared dispatch contract from 29. This slice is done only when all of the following are true: -1. `substrate agent start` accepts an explicit scope selector such as `--scope host|world`. -2. Human launches and orchestrator-only tool launches consume the same underlying dispatch envelope. -3. Capability flags exposed on the human command map onto the same effective launch contract used by orchestrator tool calls. -4. The new public world-scoped start semantics are explicit, documented, and fully tested. +1. `substrate agent start` accepts explicit scope selection through the validated shared dispatch contract. +2. `--scope world` always means host-rooted orchestration plus world worker launch. +3. The command persists the host attach contract at session birth. +4. The command does not eagerly start a host execution client just to create ownership theater. +5. The CLI surface and docs explain the resulting durable session truth clearly. -## Required Product Decision +## What This Slice Assumes Is Already Landed -Before implementation is considered closed, this slice must choose one exact meaning for public `--scope world` root start. +1. 28.5 has removed blank-prompt control semantics from Substrate architecture and split control-only attach from prompt-bearing launch. +2. 29 has landed one shared `DispatchRequestEnvelope`, one resolved launch contract, and one persisted host attach contract. +3. 29 has frozen the two baseline domains: + - inventory-backed resolution for new dispatch, + - persisted-attach-backed resolution for later host attach and detached follow-up recovery. -### Option A: Host-rooted orchestration session plus world worker +## What This Slice Leaves To 31 -Public `substrate agent start --scope world ...`: +This slice does not finish lazy host attach behavior. 31 owns: -- creates or binds a host-rooted orchestration session, -- launches a world-scoped worker/member under that orchestration session, -- and keeps the durable orchestration authority host-rooted. +1. when lazy host attach is triggered, +2. fresh attach versus continuity attach behavior, +3. operator/status truth for born-unattached sessions with pending host-side work. -This option is more compatible with the current [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) model. +## Open Architectural Split: Born-Unattached Status Truth -### Option B: Standalone world-root session +The repo has already landed durable detached host-session posture truth for attached-then-detached sessions (`parked_resumable` and `awaiting_attention`), but it has not yet fully frozen or implemented a distinct born-unattached host-rooted status taxonomy. -Public `substrate agent start --scope world ...`: +That remaining architecture split is intentionally divided across 30 and 31: -- can create a standalone world-root session with no host-rooted orchestrator session above it. +1. 30 must freeze and implement the minimum operator-visible status floor required to ship truthful public `agent start --scope world` behavior: + - a born-unattached host-rooted world-start session is a valid non-terminal steady state; + - it must not be reported as attached/active ownership theater; + - it must not silently collapse into semantics that imply prior attach history if that distinction would be misleading; + - detached-world follow-up remains fail-closed until sanctioned host attach occurs. +2. 31 must freeze and implement the fuller born-unattached taxonomy and lifecycle meaning: + - exact posture/status naming, + - the distinction between never-attached versus previously-attached-and-parked, + - how pending host-side work changes status/posture, + - how attach-worker behavior and trigger policy interact with those states. -This option is a broader contract change and requires a more substantial rethink of the current host-rooted durability model. +This slice must therefore define enough pre-attach status truth to make public world-start honest, but it must not overreach into the full lazy-attach taxonomy that belongs to 31. -This SOW does not assume A or B. It requires the repo to choose one and align the public contract, runtime semantics, and docs to that choice. +## 29.75 Contract Floor This Slice Must Reuse -## Already Landed And Assumed +Before this public surface is promoted, it must inherit the 29.75 closeout floor exactly: -This SOW assumes the following are already true and are not being redesigned here: +1. inventory `policy_overlay` already merges into the resolved `effective_policy`; +2. the only dispatch-time capability narrowing family currently supported is `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, and `event_stream`, and only from `true` to `false`; +3. `session_start`, `llm`, and `mcp_client` remain dispatch-time unsupported and must stay fail closed until a later slice deliberately broadens scope; +4. retained world-member follow-up turns already consume a shared-contract-derived parity subset, so this slice must not invent a second public world-start follow-up dialect. +5. host session birth now persists authoritative attach-relevant truth from resolved-contract semantics across both public start and REPL host cold start, so this slice must not repair durable attach truth itself. +6. missing or invalid persisted durable attach truth now fails closed with no repair/backfill branch in 29.75, so this slice must not reintroduce one. -- the shared dispatch envelope from [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) exists or is the prerequisite contract, -- agent inventory already provides baseline defaults and policy overlays in [agent_inventory.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs:13), -- the orchestrator remains host-scoped in the current v1 model under [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md:141), -- and gateway-mediated LLM fulfillment is handled by the prior slice rather than being invented here. +## Draft Work Breakdown -## In Scope +### 1. Expose explicit scope selection on public `agent start` -- broaden public `substrate agent start` beyond host-only, -- add explicit scope selection for public root start, -- add explicit human-facing capability flags that map onto the shared dispatch envelope, -- align orchestrator tool launches and human launches behind the same launch contract, -- and document exactly what a public world-scoped root start means. +Required direction: -## Out Of Scope +1. `--scope host` preserves the current host-rooted start meaning. +2. `--scope world` routes through the same shared dispatch contract from 29; it does not invent a second CLI-only launch dialect. +3. invalid scope/backend combinations fail closed. -This slice does not include: +### 2. Define the public world-start contract in operator-visible terms -- moving the orchestrator itself in-world, -- bypassing the shared dispatch envelope, -- inventing a second public worker-launch system separate from orchestrator tools, -- or reopening the gateway-mediated fulfillment seam as a separate architecture project. +Required direction: -## Concrete Work Breakdown +1. a durable host-rooted orchestration session exists immediately, +2. a world worker/member is launched under that session, +3. the host attach contract is already persisted, +4. no host execution client must be attached yet. -### 1. Freeze one public meaning for `--scope world` +### 3. Reuse the same capability flag families exposed by 29 -Primary anchors: +Required direction: -- [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) -- [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md) +1. user-facing capability flags map onto the same resolved launch contract used by orchestrator-controlled dispatch, +2. policy-denied flags fail closed, +3. the command does not invent a CLI-only override model or alternate attach-truth vocabulary, +4. any public capability flags introduced in this slice must stay inside the already-supported narrowing family unless a later slice explicitly reopens the contract. -Required outcome: +### 4. Preserve current lifecycle boundaries -- the repo chooses whether world-scoped public root start is host-rooted or standalone world-root, -- that rule is explicit, -- and the rule is enforced consistently by runtime and docs. +Required direction: -### 2. Expose scope selection on public `agent start` +1. no hidden bootstrap prompt reappears, +2. detached-world follow-up remains fail-closed until host ownership is attached through the sanctioned path, +3. `start` does not imply standalone world continuity. -Primary anchors: +## Draft Acceptance Shape -- [agents_cmd.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) -- [cli.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/cli.rs) +This slice should only be promoted out of draft once: -Required outcome: +1. 28.5 has landed, +2. 29 has landed, +3. 29.75 has landed, +4. the command can create a host-rooted durable session plus world worker without eager host attach, +5. the public CLI and docs no longer imply standalone world-root as an option. -- the public root start command accepts an explicit scope selector, -- invalid scope/backend combinations fail closed, -- and current host-scoped starts continue to work under the same user-visible semantics. +## Draft Validation Targets -### 3. Expose capability flags on the human command +When this slice is eventually promoted, validation must include: -Primary anchors: +1. host-scoped root start still working, +2. world-scoped root start creating a host-rooted durable session, +3. resolved capability flags matching the 29 contract, +4. truthful status output for the newly created session before any host attach, +5. detached-world fail-closed behavior before host attach. -- [ADR-0027](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0027-llm-and-agent-config-policy-surface.md) -- [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) +## Sequencing -Required outcome: +Current stack status: -- human operators can express explicit launch-time capability choices as flags, -- those flags map onto the same internal dispatch envelope used by orchestrator tool calls, -- and the flag set is explicit rather than relying entirely on hidden inventory defaults. - -### 4. Keep lifecycle semantics stable while broadening root start - -Primary anchors: - -- [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) -- [25-host-durable-session-closeout-and-qa-hardening.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) - -Required outcome: - -- broadening public root start must not reintroduce hidden bootstrap prompts, -- must not regress `start`/`turn`/`reattach`/`stop` semantics for host-rooted orchestration, -- and must keep durable-session behavior truthful and explicit under the chosen contract. - -### 5. Align human launch and orchestrator launch under one model - -Primary anchors: - -- [ADR-0025](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0025-agent-hub-core-role-swappable.md) -- [ADR-0026](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0026-orchestration-toolbox-mcp.md) - -Required outcome: - -- the human command is a first-class supported launch path, -- orchestrator tool calls remain the primary worker-dispatch path in normal orchestration use, -- and both consume the same effective launch contract rather than diverging over time. - -## Required Test Additions Or Tightening - -### Public CLI coverage - -Required scenarios: - -- `substrate agent start --scope host ...` preserves current behavior, -- `substrate agent start --scope world ...` follows the chosen contract exactly, -- invalid capability flags or invalid scope/backend combinations fail closed, -- and user-facing JSON/text output stays explicit about what was launched. - -### Shared-envelope parity coverage - -Required scenarios: - -- human capability flags resolve to the same effective launch contract as orchestrator tool-call inputs, -- policy-denied flags fail closed in both paths, -- and inventory defaults remain visible in effective launch resolution. - -### Lifecycle regression coverage - -Required scenarios: - -- no hidden bootstrap prompts reappear, -- host durable-session behavior remains correct, -- and any new world-scoped root start behavior is fully explicit rather than heuristic. - -## Acceptance Criteria - -- public `substrate agent start` supports explicit scope selection. -- human operators can express launch-time capability choices as explicit flags. -- the human command and orchestrator tool paths share one dispatch contract. -- the exact meaning of public world-scoped root start is frozen and documented. -- current host-scoped lifecycle semantics do not regress. - -## Validation Expectations - -- run targeted CLI, inventory, dispatch, and durable-session tests, -- run full touched package coverage and then full workspace tests: - - `cargo test --workspace -- --nocapture` -- manual validation for this slice must explicitly exercise: - - host-scoped public root start, - - world-scoped public root start, - - capability flag resolution, - - and parity between human launches and orchestrator-driven launches. - -## Docs And Truth Sync - -When this slice is closed: - -- update [docs/USAGE.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md), -- update [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) or its successor truth surfaces where they still say root start is host-only, -- and document the public flag surface so world-scoped launch and capability overrides are explicit operator-facing contract rather than tribal knowledge. +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md): implementation-ready immediate slice. +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md): implementation-ready next slice. +3. [29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md](29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md): final contract-authority closeout floor. +4. This SOW: draft pending those earlier landings. +5. [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md): draft follow-on after this slice fixes the public entrypoint. diff --git a/llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md b/llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md index eb6394d66..500aeae80 100644 --- a/llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md +++ b/llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md @@ -1,258 +1,127 @@ # SOW: Lazy Host Attach For Host-Rooted World Start -Status: remaining-work draft. This SOW is the lifecycle refinement follow-on after [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md). It is anchored to the shared dispatch model from [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md), the public world-scoped start expansion from [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md), and the durable host-session truth in [ADR-0047 — Host Orchestrator Durable Session and Parked-Resumable Ownership](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) and [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md). +Status: draft aligned to validated architecture. This slice is not implementation-ready yet. It depends on [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md), [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md), the landed [29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md](29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md) floor, and the public host-rooted world-start entrypoint from [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md). -This slice does not broaden the architecture to standalone world-root continuity. It refines the host-rooted model so a public world-scoped root start can create the durable host orchestration session and launch a world worker without eagerly starting a host orchestration agent process just to establish ownership theater. +This slice no longer tries to discover the architecture. The validated architecture is already fixed: -## Objective - -Allow `substrate agent start --scope world ...` to create a real host-rooted orchestration session and launch a world worker/member immediately, while deferring host orchestration agent process startup until there is actual host-side work to perform. - -This slice is done only when all of the following are true: - -1. A host-rooted orchestration session can exist from birth with no attached host execution client. -2. Public world-scoped root start no longer requires an eager host orchestration agent prompt or warm-up binary launch just to create/claim the session. -3. If the world worker later needs approval, guidance, reply handling, or other host-side orchestration work, that need lands in durable session-owned state and can trigger sanctioned host attach. -4. Lazy host attach uses real pending context rather than a synthetic bootstrap prompt. -5. The host-rooted durability model, detached-world fail-closed rule, and `reattach` semantics remain coherent and explicit. - -## Sequencing And Landed Prerequisites - -This slice assumes all of the following are already landed or intentionally chosen and must be treated as fixed floor rather than reopened here: - -1. [28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/28-gateway-mediated-llm-fulfillment-without-lifecycle-regression.md) handles gateway-mediated fulfillment rather than lifecycle expansion. -2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) provides the shared dispatch envelope used by human and orchestrator-driven launch requests. -3. [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md) has already chosen the host-rooted meaning of public `--scope world` root start rather than standalone world-root continuity. -4. [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) remains the durability and recovery floor. -5. The current detached-world fail-closed rule remains live until a valid host owner is attached through the sanctioned path. - -This slice does not absorb: - -1. standalone world-root session design, -2. new gateway/backend adapter work, -3. dispatch-envelope redesign, -4. broader inbox workflow productization, -5. fuzzy routing or implicit world continuity rules. - -## Frozen Contract To Preserve - -This slice must preserve all of the following: - -1. The durable authority remains the host-rooted orchestration session, not the world worker. -2. The host orchestration agent process remains an attachable execution client rather than the identity of the durable session. -3. `substrate agent reattach --session ` remains attached-owner recovery only. It does not become a generic “wake any world session” verb. -4. Detached-world follow-up remains fail-closed until host ownership is actually attached or reattached through the sanctioned path. -5. No hidden bootstrap prompt, synthetic warm-up turn, or fake “this session is starting” host prompt may be introduced. -6. World-originated approvals, replies, and pending orchestration work must still land in durable Substrate-owned state rather than depending on a continuously live host agent process. - -Primary truth anchors: - -- [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) -- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) -- [docs/USAGE.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md) -- [25-host-durable-session-closeout-and-qa-hardening.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) - -## Current Tension In The Model - -The repo already treats the host execution client as attachable and the durable orchestration session as the real authority. That is explicit in [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md:46) and [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md:31). - -What is not yet modeled cleanly for the new public `--scope world` shape is: - -1. a host-rooted orchestration session that exists from birth with no attached host client yet, -2. world worker launch under that session without an eager host agent binary startup, -3. later host-side attach only when the world worker actually needs approvals, replies, or orchestration help, -4. posture/status truth that distinguishes “valid host-rooted session with no attached host client yet” from “previously attached session that later parked cleanly.” - -This SOW exists to close that lifecycle gap explicitly rather than faking it with an unnecessary eager host launch. - -## In Scope - -1. Define host-rooted world start semantics where the durable orchestration session is created before any host agent process is attached. -2. Allow immediate world worker/member launch under that session without an initial host orchestration agent prompt. -3. Persist enough launch-resolution and recovery metadata at session creation time to allow later sanctioned host attach. -4. Define when and how lazy host attach is triggered or requested. -5. Keep world-originated pending work durable and visible until host ownership is attached. -6. Make status/posture truth explicit for this lifecycle shape. - -## Out Of Scope +1. durable authority is the host-rooted orchestration session, +2. the host attach contract is persisted at session birth, +3. the attached host execution client is optional at birth, +4. lazy host attach is a Substrate attach-worker concern, not a prompt trick, +5. 29 owns the only shared dispatch contract and the only durable host-attach truth. -1. Standalone world-root sessions with no host-rooted orchestration session. -2. Automatic inbox-driven product workflows beyond narrow lifecycle correctness. -3. Replacing `reattach` with a different public recovery verb unless another SOW explicitly does so. -4. Reopening gateway-mediated fulfillment, backend selection, or dispatch-merge semantics. -5. Allowing detached world members to continue independently with no host-owned authority above them. +The 29.75 closeout floor this slice inherits is also fixed: -## Required Contract Decision +1. persisted host attach resolution already reuses durable backend, protocol, scope, capabilities, effective policy, continuity selector truth, and attach policy defaults from `HostAttachContract`, +2. later attach-time requests are bounded overlays on that baseline: they may honor it, narrow it where permitted, and may not silently broaden or replace it, +3. missing or invalid persisted durable attach truth fails closed with no repair/backfill branch in 29.75, +4. successor allocation already copies generalized attach truth forward while clearing only continuity-specific state, +5. retained world-member follow-up turns already avoid hidden baseline re-resolution and therefore must stay orthogonal to lazy host attach. -This slice assumes and hardens the following exact meaning: - -### Public `substrate agent start --scope world ...` - -The command: - -1. creates a host-rooted orchestration session, -2. persists the effective host and world launch contract for that session, -3. launches the world worker/member under that session, -4. does not require an eager host orchestration agent process launch, -5. leaves host attach lazy until real host-side work exists. - -This is not a standalone world-root session. It is a host-rooted durable session with deferred host execution-client attachment. - -## Concrete Work Breakdown - -### 1. Define session-from-birth semantics for unattached host-rooted world starts - -Primary anchors: - -- [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) -- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) - -Required outcome: - -1. The repo explicitly defines that a host-rooted orchestration session may exist before any host execution client is attached. -2. The repo explicitly defines whether this state is represented as: - - a refined existing posture such as `parked_resumable`, or - - a new explicit posture for “not yet attached but valid.” -3. That truth is persisted and observable rather than reconstructed heuristically. - -### 2. Persist the future host-attach contract at world-scoped root start - -Primary anchors: - -- [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/29-shared-agent-dispatch-envelope-and-capability-override-contract.md) -- [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md) - -Required outcome: - -1. The resolved launch contract for the host orchestrator is persisted when the host-rooted session is created. -2. Later host attach does not depend on guessing backend, scope, capability flags, or policy posture after the fact. -3. World start and later host attach consume the same effective launch truth. - -### 3. Launch the world worker without eager host-agent startup theater - -Primary anchors: - -- [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md) -- [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) - -Required outcome: - -1. Public world-scoped root start can launch the world worker under a valid host-rooted orchestration session without first spinning up a host orchestration process just to announce responsibility. -2. No hidden bootstrap prompt or synthetic orchestration claim prompt is emitted. -3. The world worker still has a durable orchestration session above it for later approvals, replies, and orchestration-directed work. +## Objective -### 4. Define the lazy host-attach triggers and sanctioned recovery path +Allow a host-rooted world session that was born without an attached host execution client to attach one later using real persisted launch truth and real pending context instead of synthetic bootstrap prompts. -Primary anchors: +## Frozen Direction -- [docs/USAGE.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md) -- [25-host-durable-session-closeout-and-qa-hardening.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/25-host-durable-session-closeout-and-qa-hardening.md) +The attach worker in this slice must support two explicit modes: -Required outcome: +1. continuity attach + - resume an existing backend-native session when a valid continuity selector exists; +2. fresh attach + - start a new attached host execution client from the persisted host attach contract when no backend-native session exists yet. -1. The repo explicitly defines the classes of host-side need that require host attach: - - approval requests, - - replies or follow-up orchestration messages, - - human-directed orchestration work, - - or other bounded control-plane needs. -2. Those needs land durably when no host client is attached. -3. The repo explicitly defines whether the product behavior is: - - require the operator to run `substrate agent reattach --session ...`, or - - allow a sanctioned lazy attach path that launches the host execution client automatically against real pending work. -4. In either case, the attach path must use real pending context rather than a fake bootstrap prompt. +This slice must not treat blank prompt as the meaning of either mode. -### 5. Keep detached-world fail-closed semantics coherent under the new model +## What Must Stay True -Primary anchors: +1. The durable orchestration session remains the authority whether or not a host client is attached. +2. Detached-world follow-up stays fail-closed until host ownership is actually attached through the sanctioned path. +3. No hidden bootstrap prompt, fake inbox-consumption prompt, or synthetic warm-up turn may be introduced. +4. Pending host-side work must remain durable under Substrate session state while no host client is attached. -- [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) -- [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md) +## Open Architectural Split: Born-Unattached Status Truth -Required outcome: +The repo already has landed posture truth for attached-then-detached durable host sessions, but it has not yet fully frozen or implemented the distinct born-unattached host-rooted taxonomy that this slice needs. -1. The existing rule does not soften into “world members are independently resumable.” -2. If host ownership is required and not attached, world follow-up still fails closed in a way that points back to the sanctioned host attach path. -3. The new lazy attach semantics must not accidentally create direct world-to-world public continuity. +Ownership is intentionally split between 30 and 31: -### 6. Make status and operator truth explicit +1. 30 owns the minimum pre-attach status floor needed for truthful public world-scoped root start: + - a born-unattached host-rooted session must be visible as a valid non-terminal state; + - public world follow-up remains fail-closed before host attach; + - the public entrypoint must not imply attached ownership when none exists. +2. 31 owns the full taxonomy and lifecycle semantics: + - exact posture/status naming for born-unattached sessions, + - the distinction between never-attached and previously-attached-and-parked states, + - how pending host-side work affects posture/status, + - how continuity attach versus fresh attach and manual versus automatic trigger policy interact with those states. -Primary anchors: +This slice should therefore consume the minimal truthful floor that 30 establishes and then finish the deeper taxonomy and attach-lifecycle semantics, rather than assuming those details were already fully frozen earlier in the stack. -- [docs/USAGE.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md) -- [crates/shell/tests/agent_public_control_surface_v1.rs](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) +## Draft Work Breakdown -Required outcome: +### 1. Define born-unattached host-rooted session behavior -1. `status --json` and human-readable status make it clear when: - - the host-rooted session exists, - - no host client is currently attached, - - the world worker is active, - - pending host-side work exists. -2. The operator can tell the difference between: - - “valid host-rooted session that has not attached a host client yet,” - - “previously attached session that is now parked,” - - and “attention-needed host-rooted session with pending work.” +Required direction: -## Required Test Additions Or Tightening +1. session birth without an attached host client is an intentional steady state, +2. the persisted host attach contract is authoritative from birth, +3. status/posture truth distinguishes this case from a previously attached-and-parked session. -### Public world-start coverage +### 2. Implement attach-worker mode selection -Required scenarios: +Required direction: -1. `substrate agent start --scope world ...` creates a valid host-rooted orchestration session. -2. The world worker launches successfully without eager host orchestration process startup. -3. No hidden bootstrap or fake responsibility-claim prompt is emitted. +1. if a valid continuity selector exists, the attach worker may choose continuity attach; +2. if no continuity selector exists, the attach worker must be able to perform fresh attach from the persisted host attach contract; +3. attach-mode choice must be explicit and auditable; +4. neither mode may invent a second durable attach object or re-derive launch truth from the last live participant snapshot, +5. both modes must trust the persisted effective policy, attach-relevant capability truth, and attach policy defaults that 29.75 freezes durably. -### Status and posture coverage +### 3. Define how lazy attach is triggered -Required scenarios: +Open draft question that still needs a later freeze: -1. A newly created host-rooted world session with no attached host client surfaces coherent status truth. -2. Pending world-originated host-side work transitions the session into the correct visible state. -3. Operators can distinguish unattached-from-birth, parked, and attention-needed cases. +1. require explicit operator `reattach`, or +2. allow sanctioned automatic launch when durable pending work exists. -### Host attach / recovery coverage +Either direction must use real pending context and the persisted host attach contract. -Required scenarios: +### 4. Keep fail-closed world follow-up semantics intact -1. A sanctioned host attach path can attach later using the persisted launch contract. -2. `reattach` remains non-prompt-taking and restores real attached host ownership if it reports success. -3. Later host attach uses real pending context rather than synthetic bootstrap text. +Required direction: -### Detached-world non-regression coverage +1. a detached or unattached host-rooted session does not authorize public world-to-world continuity, +2. error messaging still points operators back to the sanctioned attach path. -Required scenarios: +## Draft Acceptance Shape -1. Detached-world follow-up remains fail-closed when host ownership is required and not attached. -2. Error messaging points to the correct sanctioned host attach path. -3. No new path allows independent world continuity with no host-rooted authority above it. +This slice should only be promoted out of draft once: -## Acceptance Criteria +1. 28.5 and 29 have landed, +2. 29.75 has landed the final authoritative attach-truth floor, +3. 30 has landed the public host-rooted world-start entrypoint, +4. the repo has frozen whether lazy attach is manual-only or may be auto-launched from pending work, +5. attach-worker mode selection is explicit and testable, +6. born-unattached versus parked versus awaiting-attention status truth is operator-visible. -1. Public world-scoped root start can create a host-rooted orchestration session and launch a world worker without eager host agent process startup. -2. The durable host-rooted session remains the authority even before any host client is attached. -3. The effective host attach contract is persisted at session creation time and can be reused later. -4. Host-side approvals, replies, and orchestration needs remain durable when no host client is attached. -5. Later host attach uses real pending context rather than synthetic bootstrap prompts. -6. Detached-world fail-closed semantics do not regress into standalone world continuity. +## Draft Validation Targets -## Validation Expectations +When this slice is eventually promoted, validation must include: -- run targeted CLI, runtime-state, status, and lifecycle tests, -- run full touched package coverage and then full workspace tests: - - `cargo test --workspace -- --nocapture` -- manual validation for this slice must explicitly exercise: - - public `start --scope world ...` with no eager host process startup, - - a world worker producing host-side pending work, - - later sanctioned host attach, - - and detached-world fail-closed behavior before host attach. +1. world-scoped root start creating a host-rooted session with no eager host client, +2. pending host-side work persisting durably before attach, +3. continuity attach when a valid backend-native session exists, +4. fresh attach when no backend-native session exists, +5. no synthetic prompt submission during either attach mode, +6. detached-world fail-closed behavior before host attach. -## Docs And Truth Sync +## Sequencing -When this slice closes: +Current stack status: -1. update [30-public-world-scoped-agent-start-and-capability-flags.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/30-public-world-scoped-agent-start-and-capability-flags.md) so its chosen host-rooted public-start contract explicitly points to lazy host attach rather than implying eager host startup, -2. update [docs/USAGE.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md), -3. update [ADR-0047](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/project_management/adrs/draft/ADR-0047-host-orchestrator-durable-session-and-parked-resumable-ownership.md) or its successor truth surface so “attachable execution client” and world-root public start no longer leave this case ambiguous, -4. update [HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/HOST_ORCHESTRATOR_INTENDED_BEHAVIOR_TRUTH.md), -5. and align any status/lifecycle docs so operators can understand “host-rooted session exists, host client not yet attached” as an intentional steady-state rather than an error. +1. [28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md](28.5-explicit-control-only-session-recovery-and-host-rooted-world-start-alignment.md): implementation-ready immediate slice. +2. [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](29-shared-agent-dispatch-envelope-and-capability-override-contract.md): implementation-ready next slice. +3. [29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md](29.75-authoritative-host-attach-truth-and-repl-cold-start-parity.md): final contract-authority closeout floor. +4. [30-public-world-scoped-agent-start-and-capability-flags.md](30-public-world-scoped-agent-start-and-capability-flags.md): draft public entrypoint slice. +5. This SOW: draft lazy-attach realization slice after the earlier landings. diff --git a/llm-last-mile/README.md b/llm-last-mile/README.md index 81afc9e12..18a34f98f 100644 --- a/llm-last-mile/README.md +++ b/llm-last-mile/README.md @@ -82,6 +82,12 @@ The original packet stops before public control-plane productization. The follow - Hardening follow-on: host bootstrap and detach parking now preserve the exact durable-session fields that later `turn`, `reattach`, `stop`, and `status --json` rely on. - [25-host-durable-session-closeout-and-qa-hardening.md](./25-host-durable-session-closeout-and-qa-hardening.md) - Truth-convergence and QA-hardening follow-on: freeze `turn` as prompt-taking follow-up on the same durable session, `reattach` as attached-owner recovery only, `stop` as durable closeout for attached and parked host sessions, `status --json` as the authoritative parked-session read surface, detached-world follow-up as fail-closed until `reattach`, and inbox behavior as narrow retained state rather than a public inbox workflow. +- [29-shared-agent-dispatch-envelope-and-capability-override-contract.md](./29-shared-agent-dispatch-envelope-and-capability-override-contract.md) + - Landed shared dispatch-contract truth: one internal dispatch contract now resolves inventory-backed and persisted-attach-backed baselines, persists generalized `HostAttachContract` truth, and keeps human plus orchestrator-controlled dispatch on the same semantics. +- [30-public-world-scoped-agent-start-and-capability-flags.md](./30-public-world-scoped-agent-start-and-capability-flags.md) + - Draft follow-on: public world-scoped `agent start` depends on the slice 29 contract and keeps durable authority host-rooted. +- [31-lazy-host-attach-for-host-rooted-world-start.md](./31-lazy-host-attach-for-host-rooted-world-start.md) + - Draft follow-on: later attach must reuse the persisted slice 29 host-attach truth for explicit continuity or fresh attach modes. ## Non-Goals in This Packet diff --git a/scripts/linux/world-provision.sh b/scripts/linux/world-provision.sh index fb4e6abc6..8f2fbb415 100755 --- a/scripts/linux/world-provision.sh +++ b/scripts/linux/world-provision.sh @@ -617,10 +617,14 @@ echo "==> Reloading systemd and enabling socket activation" LEGACY_WORLD_UNIT_PREFIX="substrate-world" LEGACY_SERVICE="${LEGACY_WORLD_UNIT_PREFIX}-agent.service" LEGACY_SOCKET="${LEGACY_WORLD_UNIT_PREFIX}-agent.socket" -sudo_cmd systemctl stop "${LEGACY_SERVICE}" || true -sudo_cmd systemctl stop "${LEGACY_SOCKET}" || true -sudo_cmd systemctl disable "${LEGACY_SERVICE}" || true -sudo_cmd systemctl disable "${LEGACY_SOCKET}" || true +if sudo_cmd systemctl cat "${LEGACY_SERVICE}" >/dev/null 2>&1; then + sudo_cmd systemctl stop "${LEGACY_SERVICE}" || true + sudo_cmd systemctl disable "${LEGACY_SERVICE}" || true +fi +if sudo_cmd systemctl cat "${LEGACY_SOCKET}" >/dev/null 2>&1; then + sudo_cmd systemctl stop "${LEGACY_SOCKET}" || true + sudo_cmd systemctl disable "${LEGACY_SOCKET}" || true +fi sudo_cmd rm -f "/etc/systemd/system/${LEGACY_SERVICE}" "/etc/systemd/system/${LEGACY_SOCKET}" || true sudo_cmd systemctl daemon-reload sudo_cmd systemctl enable substrate-world-service.service diff --git a/scripts/substrate/dev-install-substrate.sh b/scripts/substrate/dev-install-substrate.sh index 43d29ff98..f2ecc188d 100755 --- a/scripts/substrate/dev-install-substrate.sh +++ b/scripts/substrate/dev-install-substrate.sh @@ -43,6 +43,14 @@ run_privileged() { return $? fi if command -v sudo >/dev/null 2>&1; then + if sudo -n true >/dev/null 2>&1; then + sudo -n "$@" + return $? + fi + if [[ ! -t 0 && ! -t 1 && ! -t 2 ]]; then + warn "Command requires elevated privileges but no interactive sudo prompt is possible: $*" + return 1 + fi sudo "$@" return $? fi @@ -756,7 +764,7 @@ import sys path = pathlib.Path(sys.argv[1]) events = [line.strip() for line in os.environ.get("STATE_EVENTS", "").splitlines() if line.strip()] schema_version = 1 -timestamp = datetime.datetime.utcnow().isoformat() + "Z" +timestamp = datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z") base = {} parsed_existing = False @@ -1620,16 +1628,30 @@ stage_dev_world_runtime_bundle "${PREFIX}" "${REPO_ROOT}" "${TARGET_DIR}" if [[ "${WORLD_ENABLED}" -eq 1 && "${IS_LINUX}" -eq 1 ]]; then ensure_linux_runtime_libraries libseccomp + use_noninteractive_world_provision=0 if [[ ${EUID} -ne 0 ]] && command -v sudo >/dev/null 2>&1; then - log "Caching sudo credentials for world provisioning (you may be prompted)..." - if ! sudo -v; then - WORLD_PROVISION_FAILED=1 - WORLD_ENABLED=0 - write_install_metadata "${WORLD_ENABLED}" - write_env_sh_script "${WORLD_ENABLED}" - write_manager_env_script "${WORLD_ENABLED}" - warn "Unable to cache sudo credentials; world-service service not provisioned." - warn "World has been disabled in ${INSTALL_CONFIG_PATH} to avoid confusing runtime failures. Re-run provisioning, then run 'substrate world enable --home \"${PREFIX}\"' to flip it back on." + if sudo -n true >/dev/null 2>&1; then + use_noninteractive_world_provision=1 + log "Detected non-interactive sudo for world provisioning." + else + log "Checking sudo access for world provisioning (you may be prompted)..." + if [[ ! -t 0 && ! -t 1 && ! -t 2 ]]; then + WORLD_PROVISION_FAILED=1 + WORLD_ENABLED=0 + write_install_metadata "${WORLD_ENABLED}" + write_env_sh_script "${WORLD_ENABLED}" + write_manager_env_script "${WORLD_ENABLED}" + warn "World provisioning requires sudo, but no interactive prompt is available in this session." + warn "World has been disabled in ${INSTALL_CONFIG_PATH} to avoid confusing runtime failures. Re-run provisioning from a TTY, pre-authenticate with 'sudo -v', or run with --no-world." + elif ! sudo -v; then + WORLD_PROVISION_FAILED=1 + WORLD_ENABLED=0 + write_install_metadata "${WORLD_ENABLED}" + write_env_sh_script "${WORLD_ENABLED}" + write_manager_env_script "${WORLD_ENABLED}" + warn "Unable to cache sudo credentials; world-service service not provisioned." + warn "World has been disabled in ${INSTALL_CONFIG_PATH} to avoid confusing runtime failures. Re-run provisioning, then run 'substrate world enable --home \"${PREFIX}\"' to flip it back on." + fi fi fi if [[ "${WORLD_ENABLED}" -eq 0 ]]; then @@ -1638,11 +1660,14 @@ if [[ "${WORLD_ENABLED}" -eq 1 && "${IS_LINUX}" -eq 1 ]]; then ensure_substrate_group_membership PROVISION_SCRIPT="${REPO_ROOT}/scripts/linux/world-provision.sh" if [[ -x "${PROVISION_SCRIPT}" ]]; then - log "Provisioning Linux world-service service via ${PROVISION_SCRIPT} (sudo may prompt)..." + log "Provisioning Linux world-service service via ${PROVISION_SCRIPT} (sudo may prompt if needed)..." provision_args=(--profile "${PROFILE}" --skip-build) if [[ "${ENABLE_WORLD_NETFILTER}" -eq 1 ]]; then provision_args+=(--world-netfilter) fi + if [[ "${use_noninteractive_world_provision}" -eq 1 ]]; then + provision_args+=(--sudo-noninteractive) + fi if ! SUBSTRATE_HOME="${PREFIX}" "${PROVISION_SCRIPT}" "${provision_args[@]}"; then WORLD_PROVISION_FAILED=1 WORLD_ENABLED=0 @@ -1728,6 +1753,17 @@ To add the dev binaries/shims to PATH for this shell, run: source ${ENV_FILE} MSG +if [[ "${IS_LINUX}" -eq 1 && "${WORLD_ENABLED}" -eq 1 ]] && ((${#HOST_STATE_ADDED_USERS[@]} > 0)); then + cat </dev/null 2>&1; then log "Creating '${target_group}' group (sudo may prompt)..." - if ! run_cmd sudo groupadd --system "${target_group}"; then + if ! run_with_sudo groupadd --system "${target_group}"; then warn "Unable to create ${target_group} group automatically. Run 'sudo groupadd --system ${target_group}' and re-run the installer." return else @@ -502,7 +503,7 @@ ensure_linux_group_membership() { fi log "Adding ${target_user} to ${target_group} (sudo may prompt)..." - if run_cmd sudo usermod -aG "${target_group}" "${target_user}"; then + if run_with_sudo usermod -aG "${target_group}" "${target_user}"; then warn "${target_user} added to ${target_group}. Log out/in or run 'newgrp ${target_group}' so group membership applies to new shells." record_user_added "${target_user}" else @@ -567,6 +568,26 @@ initialize_sudo() { fi } +run_with_sudo() { + initialize_sudo + + if [[ ${#SUDO_CMD[@]} -eq 0 ]]; then + run_cmd "$@" + return $? + fi + + if "${SUDO_CMD[@]}" -n true >/dev/null 2>&1; then + run_cmd "${SUDO_CMD[@]}" -n "$@" + return $? + fi + + if [[ ! -t 0 && ! -t 1 && ! -t 2 ]]; then + fatal "This installer requires interactive sudo for '$*', but no TTY is available. Re-run from a terminal or pre-authenticate with 'sudo -v'." + fi + + run_cmd "${SUDO_CMD[@]}" "$@" +} + reset_os_release_input_state() { OS_RELEASE_SELECTED_PATH="" OS_RELEASE_INPUT_STATE="unavailable" @@ -2081,10 +2102,10 @@ provision_linux_world() { return fi - run_cmd sudo install -Dm0755 "${world_service}" /usr/local/bin/substrate-world-service - run_cmd sudo install -Dm0755 "${gateway_binary}" /usr/local/bin/substrate-gateway - run_cmd sudo install -d -m0750 -o root -g substrate /run/substrate - run_cmd sudo install -d -m0750 /var/lib/substrate + run_with_sudo install -Dm0755 "${world_service}" /usr/local/bin/substrate-world-service + run_with_sudo install -Dm0755 "${gateway_binary}" /usr/local/bin/substrate-gateway + run_with_sudo install -d -m0750 -o root -g substrate /run/substrate + run_with_sudo install -d -m0750 /var/lib/substrate local home_path if [[ -n "${HOME}" ]]; then @@ -2155,24 +2176,24 @@ Service=substrate-world-service.service WantedBy=sockets.target UNIT - run_cmd sudo install -Dm0644 "${unit_file}" "${service_path}" - run_cmd sudo install -Dm0644 "${socket_unit}" /etc/systemd/system/substrate-world-service.socket + run_with_sudo install -Dm0644 "${unit_file}" "${service_path}" + run_with_sudo install -Dm0644 "${socket_unit}" /etc/systemd/system/substrate-world-service.socket local legacy_world_unit_prefix="substrate-world" local legacy_service="${legacy_world_unit_prefix}-agent.service" local legacy_socket="${legacy_world_unit_prefix}-agent.socket" - run_cmd sudo systemctl stop "${legacy_service}" "${legacy_socket}" || true - run_cmd sudo systemctl disable "${legacy_service}" "${legacy_socket}" || true - run_cmd sudo rm -f "/etc/systemd/system/${legacy_service}" "/etc/systemd/system/${legacy_socket}" - run_cmd sudo systemctl daemon-reload - run_cmd sudo systemctl enable substrate-world-service.service - run_cmd sudo systemctl enable --now substrate-world-service.socket - run_cmd sudo systemctl stop substrate-world-service.service substrate-world-service.socket || true - run_cmd sudo install -d -m0750 -o root -g substrate /run/substrate - run_cmd sudo rm -f /run/substrate.sock - run_cmd sudo systemctl start substrate-world-service.socket - run_cmd sudo systemctl start substrate-world-service.service - run_cmd sudo systemctl status substrate-world-service.socket --no-pager --lines=10 || true - run_cmd sudo systemctl status substrate-world-service.service --no-pager --lines=10 || true + run_with_sudo systemctl stop "${legacy_service}" "${legacy_socket}" || true + run_with_sudo systemctl disable "${legacy_service}" "${legacy_socket}" || true + run_with_sudo rm -f "/etc/systemd/system/${legacy_service}" "/etc/systemd/system/${legacy_socket}" + run_with_sudo systemctl daemon-reload + run_with_sudo systemctl enable substrate-world-service.service + run_with_sudo systemctl enable --now substrate-world-service.socket + run_with_sudo systemctl stop substrate-world-service.service substrate-world-service.socket || true + run_with_sudo install -d -m0750 -o root -g substrate /run/substrate + run_with_sudo rm -f /run/substrate.sock + run_with_sudo systemctl start substrate-world-service.socket + run_with_sudo systemctl start substrate-world-service.service + run_with_sudo systemctl status substrate-world-service.socket --no-pager --lines=10 || true + run_with_sudo systemctl status substrate-world-service.service --no-pager --lines=10 || true } run_world_checks() {