diff --git a/.gitignore b/.gitignore index 35b7f3083..674b5114d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ wt/ __pycache__/ *.pyc .runs +.codex-turn-capture # Triad/Codex artifacts (store under feature planning packs to survive `cargo clean`) docs/project_management/next/**/logs/ diff --git a/AGENTS.md b/AGENTS.md index 0159b3119..e209d00c8 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** (49749 symbols, 77069 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** (25015 symbols, 50288 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/CLAUDE.md b/CLAUDE.md index 5e698a141..f1828cd52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **substrate** (49749 symbols, 77069 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** (25015 symbols, 50288 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/crates/shell/src/execution/agent_inventory.rs b/crates/shell/src/execution/agent_inventory.rs index ddbeb726f..c235dbce5 100644 --- a/crates/shell/src/execution/agent_inventory.rs +++ b/crates/shell/src/execution/agent_inventory.rs @@ -61,6 +61,13 @@ pub(crate) struct AgentExecutionConfigV1 { pub scope: Option, } +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentCliRuntimeFamily { + Codex, + ClaudeCode, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub(crate) struct AgentCliConfigV1 { @@ -68,6 +75,8 @@ pub(crate) struct AgentCliConfigV1 { pub binary: String, #[serde(default)] pub mode: Option, + #[serde(default)] + pub runtime_family: Option, } #[derive(Debug, Clone, Deserialize)] @@ -127,6 +136,7 @@ pub(crate) struct ProjectedInventoryEntryV1 { pub cli_mode: crate::execution::config_model::AgentCliMode, pub cli_mode_origin: ProjectedInventoryValueOrigin, pub cli_binary: Option, + pub cli_runtime_family: Option, pub capabilities: AgentCapabilitiesV1, #[allow(dead_code)] pub policy_overlay: Option, @@ -167,6 +177,10 @@ impl AgentFileV1 { Some(trimmed) } } + + pub(crate) fn cli_runtime_family(&self) -> Option { + self.config.cli.as_ref().and_then(|cli| cli.runtime_family) + } } impl AgentInventoryEntryV1 { @@ -191,6 +205,10 @@ impl AgentInventoryEntryV1 { pub(crate) fn effective_cli_binary(&self) -> Option<&str> { self.file.effective_cli_binary() } + + pub(crate) fn cli_runtime_family(&self) -> Option { + self.file.cli_runtime_family() + } } pub(crate) fn inventory_entry_origin( @@ -274,6 +292,7 @@ pub(crate) fn project_inventory_entry( cli_mode: entry.effective_cli_mode(effective_config), cli_mode_origin, cli_binary: entry.effective_cli_binary().map(ToOwned::to_owned), + cli_runtime_family: entry.cli_runtime_family(), capabilities: entry.file.config.capabilities.clone(), policy_overlay: entry.file.policy_overlay.clone(), } @@ -532,6 +551,7 @@ fn validate_agent_config(path: &Path, config: &AgentConfigV1) -> Result<()> { let _ = &config.protocol; let _ = config.execution.scope; let _ = config.cli.as_ref().and_then(|cli| cli.mode); + let _ = config.cli.as_ref().and_then(|cli| cli.runtime_family); let _ = config.enabled; let _ = config.capabilities.session_start; let _ = config.capabilities.session_resume; @@ -832,10 +852,11 @@ fn validate_overlay_subset( #[cfg(test)] mod tests { use super::{ - inventory_entry_origin, AgentCapabilitiesV1, AgentCliConfigV1, AgentConfigKind, - AgentConfigV1, AgentExecutionConfigV1, AgentFileV1, AgentInventoryBaselineOrigin, - AgentInventoryEntryV1, + inventory_entry_origin, project_inventory_entry, AgentCapabilitiesV1, AgentCliConfigV1, + AgentCliRuntimeFamily, AgentConfigKind, AgentConfigV1, AgentExecutionConfigV1, AgentFileV1, + AgentInventoryBaselineOrigin, AgentInventoryEntryV1, }; + use crate::execution::config_model::{AgentCliMode, SubstrateConfig}; use crate::execution::workspace::{workspace_marker_path, SUBSTRATE_DIR_NAME}; use tempfile::tempdir; @@ -863,6 +884,7 @@ mod tests { cli: Some(AgentCliConfigV1 { binary: "codex".to_string(), mode: None, + runtime_family: None, }), api: None, capabilities: AgentCapabilitiesV1::default(), @@ -876,4 +898,40 @@ mod tests { AgentInventoryBaselineOrigin::WorkspaceInventory ); } + + #[test] + fn project_inventory_entry_carries_cli_runtime_family_truth() { + let cwd = tempdir().expect("tempdir"); + let mut effective_config = SubstrateConfig::default(); + effective_config.agents.defaults.cli.mode = AgentCliMode::Persistent; + + let entry = AgentInventoryEntryV1 { + path: cwd.path().join("codex_world.yaml"), + file: AgentFileV1 { + version: 1, + id: "codex_world".to_string(), + config: AgentConfigV1 { + enabled: true, + kind: AgentConfigKind::Cli, + protocol: Some("substrate.agent.session".to_string()), + execution: AgentExecutionConfigV1::default(), + cli: Some(AgentCliConfigV1 { + binary: "codex".to_string(), + mode: None, + runtime_family: Some(AgentCliRuntimeFamily::Codex), + }), + api: None, + capabilities: AgentCapabilitiesV1::default(), + }, + policy_overlay: None, + }, + }; + + let projected = project_inventory_entry(cwd.path(), &entry, &effective_config); + + assert_eq!( + projected.cli_runtime_family, + Some(AgentCliRuntimeFamily::Codex) + ); + } } diff --git a/crates/shell/src/execution/agent_runtime/control.rs b/crates/shell/src/execution/agent_runtime/control.rs index f8b2e4716..bedace84d 100644 --- a/crates/shell/src/execution/agent_runtime/control.rs +++ b/crates/shell/src/execution/agent_runtime/control.rs @@ -576,22 +576,40 @@ where } #[cfg(unix)] -pub(crate) fn run_hidden_owner_helper_startup_prompt_stream( +pub(crate) fn run_hidden_owner_helper_startup_prompt_stream_with_action( + listener: StartupPromptTransportListener, + json: bool, + action: PublicPromptAction, +) -> Result<()> { + run_hidden_owner_helper_startup_prompt_stream_with_projection( + listener, json, action, None, None, + ) +} + +#[cfg(unix)] +pub(crate) fn run_hidden_owner_helper_startup_prompt_stream_with_public_identity( listener: StartupPromptTransportListener, json: bool, + action: PublicPromptAction, + backend_id: &str, + scope: AgentExecutionScope, ) -> Result<()> { - run_hidden_owner_helper_startup_prompt_stream_with_action( + run_hidden_owner_helper_startup_prompt_stream_with_projection( listener, json, - PublicPromptAction::Start, + action, + Some(backend_id), + Some(scope), ) } #[cfg(unix)] -pub(crate) fn run_hidden_owner_helper_startup_prompt_stream_with_action( +fn run_hidden_owner_helper_startup_prompt_stream_with_projection( listener: StartupPromptTransportListener, json: bool, action: PublicPromptAction, + backend_id_override: Option<&str>, + scope_override: Option, ) -> Result<()> { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -609,7 +627,12 @@ pub(crate) fn run_hidden_owner_helper_startup_prompt_stream_with_action( ) { saw_terminal = true; } - let rewritten = rewrite_startup_prompt_envelope_action(envelope, action); + let rewritten = rewrite_startup_prompt_envelope_action( + envelope, + action, + backend_id_override, + scope_override, + ); renderer.render(&rewritten) }) .await @@ -640,6 +663,8 @@ pub(crate) fn run_hidden_owner_helper_startup_prompt_stream_with_action( fn rewrite_startup_prompt_envelope_action( envelope: &PublicPromptEnvelope, action: PublicPromptAction, + backend_id_override: Option<&str>, + scope_override: Option, ) -> PublicPromptEnvelope { match envelope { PublicPromptEnvelope::Accepted { @@ -653,9 +678,14 @@ fn rewrite_startup_prompt_envelope_action( version: *version, action, orchestration_session_id: orchestration_session_id.clone(), - backend_id: backend_id.clone(), + backend_id: backend_id_override + .unwrap_or(backend_id.as_str()) + .to_string(), participant_id: participant_id.clone(), - scope: scope.clone(), + scope: scope_override + .map(scope_label) + .unwrap_or(scope.as_str()) + .to_string(), }, PublicPromptEnvelope::Completed { version, @@ -671,7 +701,9 @@ fn rewrite_startup_prompt_envelope_action( version: *version, action, orchestration_session_id: orchestration_session_id.clone(), - backend_id: backend_id.clone(), + backend_id: backend_id_override + .unwrap_or(backend_id.as_str()) + .to_string(), participant_id: participant_id.clone(), turn_outcome: turn_outcome.clone(), session_posture: *session_posture, diff --git a/crates/shell/src/execution/agent_runtime/dispatch_contract.rs b/crates/shell/src/execution/agent_runtime/dispatch_contract.rs index 35a93dddd..6edf77ca4 100644 --- a/crates/shell/src/execution/agent_runtime/dispatch_contract.rs +++ b/crates/shell/src/execution/agent_runtime/dispatch_contract.rs @@ -14,7 +14,7 @@ use crate::execution::config_model::{AgentCliMode, AgentExecutionScope, Substrat use crate::execution::policy_model::{apply_policy_patch, PolicyPatch}; use super::mapping::{ - orchestrator_backend_kind, protocol_validation_error, AgentRuntimeBackendKind, + protocol_validation_error, resolve_shell_owned_runtime_family, AgentRuntimeBackendKind, PURE_AGENT_PROTOCOL, }; @@ -667,15 +667,15 @@ fn resolve_inventory_projected_contract( ); } - 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"), - } + let backend_kind = resolve_shell_owned_runtime_family( + projected.agent_id.as_str(), + projected.cli_runtime_family, + ) + .map_err(|err| DispatchResolutionError { + kind: DispatchResolutionErrorKind::RuntimeUnrealizableAfterResolution, + field: "config.cli.runtime_family", + rejecting_layer: DispatchRejectingLayer::RuntimeMaterialization, + reason: err.to_string(), })?; Ok(ResolvedLaunchContract { @@ -962,13 +962,14 @@ mod tests { use super::{ resolve_inventory_contract_for_exact_backend, resolve_persisted_host_attach_contract, - AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, DispatchCallerKind, - DispatchCapabilityOverrideSet, DispatchRequestEnvelope, DispatchResolutionErrorKind, - FieldBaselineOrigin, FieldValueOrigin, HostExecutionClientStart, + AgentRuntimeBackendKind, AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, + DispatchCallerKind, DispatchCapabilityOverrideSet, DispatchRejectingLayer, + DispatchRequestEnvelope, DispatchResolutionErrorKind, FieldBaselineOrigin, + FieldValueOrigin, HostExecutionClientStart, }; use crate::execution::agent_inventory::{ - AgentCapabilitiesV1, AgentCliConfigV1, AgentConfigKind, AgentConfigV1, - AgentExecutionConfigV1, AgentFileV1, AgentInventoryEntryV1, + AgentCapabilitiesV1, AgentCliConfigV1, AgentCliRuntimeFamily, AgentConfigKind, + AgentConfigV1, AgentExecutionConfigV1, AgentFileV1, AgentInventoryEntryV1, }; use crate::execution::agent_runtime::control::{ ResolvedRuntimeBackendKind, ResolvedRuntimeDescriptor, @@ -1044,6 +1045,7 @@ mod tests { cli: Some(AgentCliConfigV1 { binary: "cargo".to_string(), mode: cli_mode, + runtime_family: Some(AgentCliRuntimeFamily::Codex), }), api: None, capabilities, @@ -1128,6 +1130,62 @@ mod tests { assert_eq!(resolved.execution_scope, AgentExecutionScope::Host); } + #[test] + fn exact_backend_alias_resolves_canonical_runtime_family_from_inventory_truth() { + let cwd = PathBuf::from("."); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex_world".to_string(), + AgentInventoryEntryV1 { + path: PathBuf::from("codex_world.yaml"), + file: AgentFileV1 { + version: 1, + id: "codex_world".to_string(), + config: AgentConfigV1 { + enabled: true, + kind: AgentConfigKind::Cli, + protocol: Some(super::PURE_AGENT_PROTOCOL.to_string()), + execution: AgentExecutionConfigV1 { + scope: Some(AgentExecutionScope::World), + }, + cli: Some(AgentCliConfigV1 { + binary: "cargo".to_string(), + mode: Some(AgentCliMode::Persistent), + runtime_family: Some(AgentCliRuntimeFamily::Codex), + }), + api: None, + capabilities: required_capabilities(), + }, + policy_overlay: None, + }, + }, + ); + let policy = Policy { + agents_allowed_backends: vec!["cli:codex_world".to_string()], + ..Policy::default() + }; + let config = SubstrateConfig::default(); + + let resolved = resolve_inventory_contract_for_exact_backend( + &cwd, + &config, + &inventory, + &policy, + &exact_backend_envelope( + DispatchCallerKind::OrchestratorMemberStart, + DispatchBaselineKind::InventoryLaunch, + "cli:codex_world", + ), + AgentExecutionScope::World, + ) + .expect("resolution should succeed") + .expect("contract"); + + assert_eq!(resolved.agent_id, "codex_world"); + assert_eq!(resolved.backend_id, "cli:codex_world"); + assert_eq!(resolved.backend_kind, AgentRuntimeBackendKind::Codex); + } + #[test] fn inventory_contract_merges_policy_overlay_into_effective_policy() { let cwd = PathBuf::from("."); @@ -1623,10 +1681,18 @@ mod tests { .expect_err("override must fail closed"); assert_eq!(error.field, field); + assert_eq!( + error.rejecting_layer, + DispatchRejectingLayer::CallerContract + ); assert_eq!( error.kind, DispatchResolutionErrorKind::OverrideNotSupportedForCaller ); + assert_eq!( + error.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" + ); } } diff --git a/crates/shell/src/execution/agent_runtime/mapping.rs b/crates/shell/src/execution/agent_runtime/mapping.rs index caf672660..f6680115f 100644 --- a/crates/shell/src/execution/agent_runtime/mapping.rs +++ b/crates/shell/src/execution/agent_runtime/mapping.rs @@ -1,5 +1,7 @@ use anyhow::Result; +use crate::execution::agent_inventory::AgentCliRuntimeFamily; + pub(crate) const PURE_AGENT_PROTOCOL: &str = "substrate.agent.session"; pub(crate) const LEGACY_PURE_AGENT_PROTOCOL: &str = concat!("uaa.agent", ".session"); pub(crate) const PURE_AGENT_ROUTER: &str = "agent_hub"; @@ -23,16 +25,29 @@ impl AgentRuntimeBackendKind { } } -pub(crate) fn orchestrator_backend_kind(agent_id: &str) -> Result { - match agent_id { - "codex" => Ok(AgentRuntimeBackendKind::Codex), - "claude_code" => Ok(AgentRuntimeBackendKind::ClaudeCode), - other => Err(anyhow::anyhow!( - "selected orchestrator backend '{other}' is not supported by the shell-owned UAA runtime; supported backends are cli:codex and cli:claude_code" - )), +fn backend_kind_from_runtime_family( + runtime_family: AgentCliRuntimeFamily, +) -> AgentRuntimeBackendKind { + match runtime_family { + AgentCliRuntimeFamily::Codex => AgentRuntimeBackendKind::Codex, + AgentCliRuntimeFamily::ClaudeCode => AgentRuntimeBackendKind::ClaudeCode, } } +pub(crate) fn resolve_shell_owned_runtime_family( + agent_id: &str, + runtime_family: Option, +) -> Result { + runtime_family + .map(backend_kind_from_runtime_family) + .ok_or_else(|| { + anyhow::anyhow!( + "selected runtime '{}' is not runtime-realizable because config.cli.runtime_family is required for shell-owned UAA runtimes", + agent_id + ) + }) +} + pub(crate) fn protocol_validation_error(subject: &str, actual: Option<&str>) -> String { match actual { Some(LEGACY_PURE_AGENT_PROTOCOL) => format!( diff --git a/crates/shell/src/execution/agent_runtime/orchestration_session.rs b/crates/shell/src/execution/agent_runtime/orchestration_session.rs index 892367fc1..afda691ff 100644 --- a/crates/shell/src/execution/agent_runtime/orchestration_session.rs +++ b/crates/shell/src/execution/agent_runtime/orchestration_session.rs @@ -68,6 +68,7 @@ pub(crate) struct StartupPromptRecord { #[serde(rename_all = "snake_case")] pub(crate) enum OrchestrationSessionPosture { ActiveAttached, + BornUnattached, #[default] ParkedResumable, AwaitingAttention, @@ -344,6 +345,43 @@ impl OrchestrationSessionRecord { } } + #[allow(dead_code)] + pub(crate) fn new_deferred_host_attach( + orchestration_session_id: String, + shell_trace_session_id: String, + workspace_root: String, + host_attach_contract: HostAttachContract, + ) -> Self { + let now = Utc::now(); + Self { + orchestration_session_id, + shell_trace_session_id, + workspace_root, + shell_owner_pid: 0, + state: OrchestrationSessionState::Active, + opened_at: now, + last_active_at: now, + orchestrator_agent_id: host_attach_contract.launch_descriptor.agent_id.clone(), + orchestrator_backend_id: host_attach_contract.backend_id.clone(), + orchestrator_protocol: host_attach_contract.protocol.clone(), + active_session_handle_id: None, + latest_run_id: None, + world_id: None, + world_generation: None, + invalidation_reason: None, + closed_at: None, + posture: OrchestrationSessionPosture::BornUnattached, + posture_changed_at: now, + attached_participant_id: None, + pending_inbox_count: 0, + last_parked_at: Some(now), + last_attention_at: None, + parked_reason: Some("host attach deferred".to_string()), + startup_prompt: None, + host_attach_contract: Some(host_attach_contract), + } + } + pub(crate) fn transition_state(&mut self, next: OrchestrationSessionState) { self.state = next; self.last_active_at = Utc::now(); @@ -503,6 +541,17 @@ impl OrchestrationSessionRecord { anyhow::bail!("active_attached posture requires attached_participant_id"); } } + OrchestrationSessionPosture::BornUnattached => { + if self.active_participant_id().is_some() { + anyhow::bail!("born_unattached posture must clear active_session_handle_id"); + } + if self.attached_participant_id.is_some() { + anyhow::bail!("born_unattached posture must clear attached_participant_id"); + } + if self.pending_inbox_count > 0 { + anyhow::bail!("born_unattached posture cannot retain pending inbox items"); + } + } OrchestrationSessionPosture::ParkedResumable => { if self.attached_participant_id.is_some() { anyhow::bail!("parked_resumable posture must clear attached_participant_id"); @@ -623,7 +672,12 @@ impl OrchestrationSessionRecord { if self.pending_inbox_count > 0 { OrchestrationSessionPosture::AwaitingAttention } else { - OrchestrationSessionPosture::ParkedResumable + match self.posture { + OrchestrationSessionPosture::BornUnattached => { + OrchestrationSessionPosture::BornUnattached + } + _ => OrchestrationSessionPosture::ParkedResumable, + } } } } @@ -713,6 +767,38 @@ mod tests { .expect("new session invariants"); } + #[test] + fn deferred_host_attach_session_starts_born_unattached_without_attached_owner() { + let manifest = manifest(); + let contract = HostAttachContract::from_manifest_for_test(&manifest) + .expect("host attach contract for deferred session"); + let session = OrchestrationSessionRecord::new_deferred_host_attach( + "sess_001".to_string(), + "trace_001".to_string(), + "/workspace".to_string(), + contract.clone(), + ); + + assert_eq!(session.state, OrchestrationSessionState::Active); + assert_eq!(session.posture, OrchestrationSessionPosture::BornUnattached); + assert_eq!(session.active_participant_id(), None); + assert_eq!(session.attached_participant_id(), None); + assert_eq!(session.shell_owner_pid, 0); + assert_eq!( + session.orchestrator_agent_id, + contract.launch_descriptor.agent_id + ); + assert_eq!(session.orchestrator_backend_id, contract.backend_id); + assert_eq!(session.orchestrator_protocol, contract.protocol); + assert_eq!( + session.parked_reason.as_deref(), + Some("host attach deferred") + ); + session + .validate_persisted_invariants() + .expect("deferred host attach invariants"); + } + #[test] fn detached_postures_enforce_pending_inbox_truth() { let manifest = manifest(); diff --git a/crates/shell/src/execution/agent_runtime/session.rs b/crates/shell/src/execution/agent_runtime/session.rs index c981dc792..0bc285940 100644 --- a/crates/shell/src/execution/agent_runtime/session.rs +++ b/crates/shell/src/execution/agent_runtime/session.rs @@ -523,6 +523,24 @@ impl AgentRuntimeParticipantRecord { && self.is_host_orchestrator() } + pub(crate) fn matches_authoritative_parent_world_binding( + &self, + session: &OrchestrationSessionRecord, + ) -> bool { + let Some(world_id) = session.world_id.as_deref() else { + return false; + }; + let Some(world_generation) = session.world_generation else { + return false; + }; + + self.handle.orchestration_session_id == session.orchestration_session_id + && self.handle.role == MEMBER_ROLE + && self.handle.execution.scope == AgentExecutionScope::World + && self.handle.world_id.as_deref() == Some(world_id) + && self.handle.world_generation == Some(world_generation) + } + pub(crate) fn set_uaa_session_id(&mut self, backend_session_id: impl Into) { self.internal.uaa_session_id = Some(backend_session_id.into()); if self.is_host_orchestrator() && self.handle.state.is_live() { diff --git a/crates/shell/src/execution/agent_runtime/state_store.rs b/crates/shell/src/execution/agent_runtime/state_store.rs index 8215554d0..45c4c2ce5 100644 --- a/crates/shell/src/execution/agent_runtime/state_store.rs +++ b/crates/shell/src/execution/agent_runtime/state_store.rs @@ -942,6 +942,13 @@ impl AgentRuntimeStateStore { orchestration_session_id ); } + if record.session.posture == OrchestrationSessionPosture::BornUnattached { + anyhow::bail!( + "unsupported_platform_or_posture: orchestration session {} backend {} is born_unattached and cannot accept follow-up turns before sanctioned host attach", + orchestration_session_id, + backend_id + ); + } let authoritative = resolve_authoritative_session_control(&record, orchestration_session_id)?; @@ -1807,11 +1814,24 @@ impl AgentRuntimeStateStore { } }, None => { - warnings.push(format!( - "active orchestration session {} is missing authoritative orchestrator participant linkage", - session.orchestration_session_id - )); - false + if session.posture == OrchestrationSessionPosture::BornUnattached + && born_unattached_status_anchor(&AgentRuntimeSessionRecord { + session: session.clone(), + participants: participants.clone(), + warnings: Vec::new(), + has_authoritative_parent, + complete: false, + }) + .is_some() + { + true + } else { + warnings.push(format!( + "active orchestration session {} is missing authoritative orchestrator participant linkage", + session.orchestration_session_id + )); + false + } } } }; @@ -2248,12 +2268,9 @@ fn public_turn_authoritative_candidates( } } - let Some(world_id) = record.session.world_id.as_deref() else { + if record.session.world_id.is_none() || record.session.world_generation.is_none() { return candidates; - }; - let Some(world_generation) = record.session.world_generation else { - return candidates; - }; + } candidates.extend( record @@ -2261,14 +2278,9 @@ fn public_turn_authoritative_candidates( .iter() .filter(|participant| { participant.handle.backend_id == backend_id - && participant.handle.orchestration_session_id - == record.session.orchestration_session_id - && participant.handle.role == MEMBER_ROLE - && participant.handle.execution.scope == AgentExecutionScope::World && participant.handle.orchestrator_participant_id.as_deref() == active_participant_id - && participant.handle.world_id.as_deref() == Some(world_id) - && participant.handle.world_generation == Some(world_generation) + && participant.matches_authoritative_parent_world_binding(&record.session) }) .cloned() .map(|participant| PublicTurnTargetCandidate { @@ -2482,6 +2494,24 @@ fn validate_runtime_contract( session.validate_persisted_invariants()?; let Some(authoritative_participant_id) = session_authoritative_participant_id(session) else { + if session.state == OrchestrationSessionState::Active + && session.posture == OrchestrationSessionPosture::BornUnattached + { + if born_unattached_status_anchor(&AgentRuntimeSessionRecord { + session: session.clone(), + participants: participants.to_vec(), + warnings: Vec::new(), + has_authoritative_parent: true, + complete: false, + }) + .is_some() + { + return Ok(()); + } + anyhow::bail!( + "born_unattached session requires authoritative world member launch proof" + ); + } if session.state == OrchestrationSessionState::Active && session.posture == OrchestrationSessionPosture::ActiveAttached { @@ -2512,6 +2542,9 @@ fn validate_runtime_contract( anyhow::bail!("active_attached session requires attached host participant truth"); } } + OrchestrationSessionPosture::BornUnattached => { + anyhow::bail!("born_unattached sessions must not retain an authoritative participant"); + } OrchestrationSessionPosture::ParkedResumable => { if !participant.is_resume_eligible() { anyhow::bail!("parked_resumable session requires resume-eligible host participant"); @@ -2530,6 +2563,24 @@ fn validate_runtime_contract( Ok(()) } +pub(crate) fn born_unattached_status_anchor( + record: &AgentRuntimeSessionRecord, +) -> Option { + let session = &record.session; + if session.posture != OrchestrationSessionPosture::BornUnattached + || session.state != OrchestrationSessionState::Active + { + return None; + } + + record + .participants + .iter() + .filter(|participant| participant.matches_authoritative_parent_world_binding(session)) + .max_by(|left, right| left.last_status_at().cmp(&right.last_status_at())) + .cloned() +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/crates/shell/src/execution/agent_runtime/validator.rs b/crates/shell/src/execution/agent_runtime/validator.rs index 70ae367cd..9f6c47d33 100644 --- a/crates/shell/src/execution/agent_runtime/validator.rs +++ b/crates/shell/src/execution/agent_runtime/validator.rs @@ -4,7 +4,9 @@ use std::path::PathBuf; use anyhow::Result; use substrate_broker::Policy; -use crate::execution::agent_inventory::{AgentCapabilitiesV1, AgentInventoryEntryV1}; +use crate::execution::agent_inventory::{ + AgentCapabilitiesV1, AgentConfigKind, AgentInventoryEntryV1, +}; use crate::execution::agent_runtime::dispatch_contract::{ resolve_inventory_contract_for_exact_backend, resolve_inventory_contract_for_unique_scope, AttachLaunchKnobs, AttachModePreference, DispatchBaselineKind, DispatchCallerKind, @@ -14,7 +16,7 @@ use crate::execution::agent_runtime::dispatch_contract::{ use crate::execution::config_model::{AgentCliMode, AgentExecutionScope, SubstrateConfig}; use super::mapping::{ - orchestrator_backend_kind, protocol_validation_error, AgentRuntimeBackendKind, + protocol_validation_error, resolve_shell_owned_runtime_family, AgentRuntimeBackendKind, PURE_AGENT_PROTOCOL, }; @@ -125,49 +127,70 @@ pub(crate) fn validate_runtime_realizability( entry: &AgentInventoryEntryV1, effective_config: &SubstrateConfig, ) -> std::result::Result { - let contract = ResolvedLaunchContract { - caller_kind: DispatchCallerKind::HumanStart, - baseline_kind: DispatchBaselineKind::InventoryLaunch, + if entry.file.config.kind != 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() + ), + }); + } + + let cli_mode = entry.effective_cli_mode(effective_config); + if 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 { + AgentCliMode::Persistent => "persistent", + AgentCliMode::PerRequest => "per_request", + } + ), + }); + } + + let protocol = entry + .file + .config + .protocol + .clone() + .unwrap_or_else(|| PURE_AGENT_PROTOCOL.to_string()); + let backend_kind = + resolve_shell_owned_runtime_family(&entry.file.id, entry.cli_runtime_family()).map_err( + |err| RuntimeRealizabilityError { + exit_code: 2, + reason: err.to_string(), + }, + )?; + let binary = entry + .effective_cli_binary() + .ok_or_else(|| RuntimeRealizabilityError { + exit_code: 4, + reason: format!( + "selected runtime '{}' is not runtime-realizable because config.cli.binary is missing", + entry.file.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 + ), + })?; + + Ok(RuntimeSelectionDescriptor { 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()), + backend_kind, + protocol, 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) + binary_path, + }) } pub(crate) fn materialize_runtime_descriptor( @@ -364,8 +387,8 @@ mod tests { PURE_AGENT_PROTOCOL, }; use crate::execution::agent_inventory::{ - AgentCapabilitiesV1, AgentCliConfigV1, AgentConfigKind, AgentConfigV1, - AgentExecutionConfigV1, AgentFileV1, AgentInventoryEntryV1, + AgentCapabilitiesV1, AgentCliConfigV1, AgentCliRuntimeFamily, AgentConfigKind, + AgentConfigV1, AgentExecutionConfigV1, AgentFileV1, AgentInventoryEntryV1, }; use crate::execution::agent_runtime::mapping::LEGACY_PURE_AGENT_PROTOCOL; use crate::execution::config_model::{AgentCliMode, AgentExecutionScope, SubstrateConfig}; @@ -378,6 +401,28 @@ mod tests { protocol: Option<&str>, cli_mode: AgentCliMode, capabilities: AgentCapabilitiesV1, + ) -> AgentInventoryEntryV1 { + let runtime_family = match agent_id { + "claude_code" => Some(AgentCliRuntimeFamily::ClaudeCode), + _ => Some(AgentCliRuntimeFamily::Codex), + }; + make_entry_with_runtime_family( + agent_id, + scope, + protocol, + cli_mode, + runtime_family, + capabilities, + ) + } + + fn make_entry_with_runtime_family( + agent_id: &str, + scope: AgentExecutionScope, + protocol: Option<&str>, + cli_mode: AgentCliMode, + runtime_family: Option, + capabilities: AgentCapabilitiesV1, ) -> AgentInventoryEntryV1 { let test_binary = std::env::current_exe() .expect("current test binary should resolve") @@ -396,6 +441,7 @@ mod tests { cli: Some(AgentCliConfigV1 { binary: test_binary, mode: Some(cli_mode), + runtime_family, }), api: None, capabilities, @@ -476,6 +522,61 @@ mod tests { assert_eq!(descriptor.execution_scope, AgentExecutionScope::World); } + #[test] + fn validate_member_selection_resolves_alias_backend_to_canonical_runtime_family() { + let config = SubstrateConfig::default(); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex_world".to_string(), + make_entry_with_runtime_family( + "codex_world", + AgentExecutionScope::World, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::Persistent, + Some(AgentCliRuntimeFamily::Codex), + required_capabilities(), + ), + ); + + let descriptor = assert_selected_descriptor(validate_member_selection(&config, &inventory)); + assert_eq!(descriptor.agent_id, "codex_world"); + assert_eq!(descriptor.backend_id, "cli:codex_world"); + assert_eq!(descriptor.backend_kind, AgentRuntimeBackendKind::Codex); + } + + #[test] + fn validate_member_selection_prefers_world_alias_while_host_codex_remains_distinct() { + let config = SubstrateConfig::default(); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + "codex", + AgentExecutionScope::Host, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::Persistent, + required_capabilities(), + ), + ); + inventory.insert( + "codex_world".to_string(), + make_entry_with_runtime_family( + "codex_world", + AgentExecutionScope::World, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::Persistent, + Some(AgentCliRuntimeFamily::Codex), + required_capabilities(), + ), + ); + + let descriptor = assert_selected_descriptor(validate_member_selection(&config, &inventory)); + assert_eq!(descriptor.agent_id, "codex_world"); + assert_eq!(descriptor.backend_id, "cli:codex_world"); + assert_eq!(descriptor.backend_kind, AgentRuntimeBackendKind::Codex); + assert_eq!(descriptor.execution_scope, AgentExecutionScope::World); + } + #[test] fn validate_member_selection_fails_closed_on_ambiguity() { let config = SubstrateConfig::default(); @@ -626,6 +727,43 @@ mod tests { assert_eq!(descriptor.backend_id, "cli:codex"); } + #[test] + fn validate_exact_backend_selection_preserves_codex_world_alias_identity() { + let config = SubstrateConfig::default(); + let mut inventory = BTreeMap::new(); + inventory.insert( + "codex".to_string(), + make_entry( + "codex", + AgentExecutionScope::Host, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::Persistent, + required_capabilities(), + ), + ); + inventory.insert( + "codex_world".to_string(), + make_entry_with_runtime_family( + "codex_world", + AgentExecutionScope::World, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::Persistent, + Some(AgentCliRuntimeFamily::Codex), + required_capabilities(), + ), + ); + + let descriptor = assert_exact_selected_descriptor(validate_exact_backend_selection( + &config, + &inventory, + AgentExecutionScope::World, + "cli:codex_world", + )); + assert_eq!(descriptor.agent_id, "codex_world"); + assert_eq!(descriptor.backend_id, "cli:codex_world"); + assert_eq!(descriptor.backend_kind, AgentRuntimeBackendKind::Codex); + } + #[test] fn validate_exact_backend_selection_reports_scope_specific_protocol_error() { let config = SubstrateConfig::default(); @@ -676,4 +814,51 @@ mod tests { error.reason ); } + + #[test] + fn validate_runtime_realizability_requires_runtime_family_for_shell_owned_path() { + let config = SubstrateConfig::default(); + let entry = make_entry_with_runtime_family( + "codex_world", + AgentExecutionScope::Host, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::Persistent, + None, + required_capabilities(), + ); + + let error = validate_runtime_realizability(&entry, &config).expect_err("must fail"); + assert!( + error + .reason + .contains("config.cli.runtime_family is required"), + "unexpected reason: {}", + error.reason + ); + } + + #[test] + fn validate_runtime_realizability_keeps_cli_mode_failure_ahead_of_runtime_family_checks() { + let config = SubstrateConfig::default(); + let entry = make_entry_with_runtime_family( + "codex_world", + AgentExecutionScope::Host, + Some(PURE_AGENT_PROTOCOL), + AgentCliMode::PerRequest, + None, + required_capabilities(), + ); + + let error = validate_runtime_realizability(&entry, &config).expect_err("must fail"); + assert!( + error.reason.contains("cli.mode=per_request"), + "unexpected reason: {}", + error.reason + ); + assert!( + !error.reason.contains("runtime_family"), + "unexpected reason: {}", + error.reason + ); + } } diff --git a/crates/shell/src/execution/agents_cmd.rs b/crates/shell/src/execution/agents_cmd.rs index b8ab48306..996338dff 100644 --- a/crates/shell/src/execution/agents_cmd.rs +++ b/crates/shell/src/execution/agents_cmd.rs @@ -4,6 +4,8 @@ use crate::execution::agent_inventory::{ }; #[cfg(unix)] use crate::execution::agent_runtime::control::request_private_stop; +#[cfg(target_os = "linux")] +use crate::execution::agent_runtime::control::PersistedWorldBinding; use crate::execution::agent_runtime::control::{ hidden_owner_helper_readiness_timed_out, load_hidden_owner_helper_launch_plan, load_public_prompt_source, persist_hidden_owner_helper_launch_plan, @@ -20,8 +22,9 @@ use crate::execution::agent_runtime::control::{ 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, + run_hidden_owner_helper_startup_prompt_stream_with_action, + run_hidden_owner_helper_startup_prompt_stream_with_public_identity, + HiddenOwnerHelperStartupPromptPlan, }; use crate::execution::agent_runtime::orchestration_session::HostAttachContract; use crate::execution::agent_runtime::orchestration_session::{ @@ -46,13 +49,18 @@ use crate::execution::agent_runtime::{ MEMBER_ROLE, NESTED_ROUTER, ORCHESTRATOR_ROLE, PURE_AGENT_PROTOCOL, PURE_AGENT_ROUTER, }; use crate::execution::cli::{ - AgentAction, AgentCmd, AgentDoctorArgs, AgentOwnerHelperArgs, AgentScopeArg, - AgentSessionControlArgs, AgentStartArgs, AgentToolboxAction, AgentToolboxCmd, - AgentToolboxViewArgs, AgentTurnArgs, AgentViewArgs, AgentsAction, AgentsCmd, Cli, + AgentAction, AgentCmd, AgentDisableCapabilityArg, AgentDoctorArgs, AgentOwnerHelperArgs, + AgentScopeArg, AgentSessionControlArgs, AgentStartArgs, AgentStartScopeArg, AgentToolboxAction, + AgentToolboxCmd, AgentToolboxViewArgs, AgentTurnArgs, AgentViewArgs, AgentsAction, AgentsCmd, + Cli, }; use crate::execution::config_model::{ self, AgentExecutionScope, AgentToolboxBindTransport, CliConfigOverrides, SubstrateConfig, }; +#[cfg(target_os = "linux")] +use crate::execution::policy_snapshot; +#[cfg(target_os = "linux")] +use crate::execution::{ReplPersistentSessionClient, ReplSessionStartParams}; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; @@ -62,6 +70,8 @@ use std::fs::File; use std::io::{self, BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +#[cfg(target_os = "linux")] +use std::sync::Arc; use std::thread; use std::time::Duration; use substrate_broker::Policy; @@ -69,6 +79,8 @@ use substrate_common::paths as substrate_paths; use substrate_common::{AgentEvent, PlacementExecution}; #[cfg(unix)] use tokio::runtime::Builder as TokioRuntimeBuilder; +#[cfg(target_os = "linux")] +use transport_api_types::{SharedWorldOwnerAction, SharedWorldOwnerSpec}; use uuid::Uuid; const TOOLBOX_VERSION: u32 = 1; #[cfg(unix)] @@ -369,9 +381,18 @@ struct AgentControlResultJson<'a> { source_orchestration_session_id: Option<&'a str>, } -struct StartLaunchPlan { +#[derive(Clone, Debug)] +struct StartPromptPublicIdentity { + #[cfg_attr(not(unix), allow(dead_code))] + backend_id: String, + scope: AgentExecutionScope, +} + +#[derive(Debug)] +struct HostStartLaunchPlan { helper_plan: HiddenOwnerHelperLaunchPlan, resolved_contract: ResolvedLaunchContract, + public_identity: StartPromptPublicIdentity, } fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { @@ -382,16 +403,43 @@ 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 StartLaunchPlan { + #[cfg(target_os = "linux")] + let mut start_plan = build_start_launch_plan(args, &context)?; + #[cfg(not(target_os = "linux"))] + let start_plan = build_start_launch_plan(args, &context)?; + + if start_plan.public_identity.scope == AgentExecutionScope::World { + #[cfg(not(target_os = "linux"))] + { + let _ = prompt; + anyhow::bail!(config_model::user_error( + "unsupported_platform_or_posture: public world-scoped root start is supported on Linux only in this slice" + )); + } + #[cfg(target_os = "linux")] + { + let world_binding = establish_public_world_start_binding( + start_plan.helper_plan.orchestration_session_id(), + &start_plan.helper_plan.session.workspace_root, + ) + .map_err(runtime_start_error)?; + start_plan.helper_plan.session.world_id = Some(world_binding.world_id); + start_plan.helper_plan.session.world_generation = Some(world_binding.world_generation); + } + } + + let HostStartLaunchPlan { helper_plan, resolved_contract, - } = build_start_launch_plan(args, &context)?; + public_identity, + } = start_plan; #[cfg(not(unix))] { let _ = store; let _ = helper_plan; let _ = resolved_contract; + let _ = public_identity; let _ = prompt; anyhow::bail!(config_model::user_error( "unsupported_platform_or_posture: public start prompt streaming requires a Unix launch-time backchannel" @@ -400,6 +448,17 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { #[cfg(unix)] { + let public_backend_id = public_identity.backend_id.clone(); + let public_scope = public_identity.scope; + let stream_start_result = |listener| { + run_hidden_owner_helper_startup_prompt_stream_with_public_identity( + listener, + args.json, + PublicPromptAction::Start, + &public_backend_id, + public_scope, + ) + }; let mut plan = helper_plan; let startup_listener = register_hidden_owner_helper_startup_prompt_listener( &store, @@ -413,11 +472,12 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { }); let receipt = launch_hidden_owner_helper(&plan, cli).map_err(runtime_start_error)?; - match run_hidden_owner_helper_startup_prompt_stream(startup_listener, args.json) { - Ok(()) => wait_for_start_prompt_completion_normalization( + match stream_start_result(startup_listener) { + Ok(()) => wait_for_start_completion_for_scope( &store, &receipt.orchestration_session_id, &receipt.participant_id, + public_scope, ) .and_then(|()| { persist_resolved_start_attach_contract( @@ -447,12 +507,12 @@ fn run_start(args: &AgentStartArgs, cli: &Cli) -> Result<()> { }); let retry_receipt = launch_hidden_owner_helper(&plan, cli).map_err(runtime_start_error)?; - run_hidden_owner_helper_startup_prompt_stream(retry_listener, args.json) - .map_err(normalize_public_prompt_error)?; - wait_for_start_prompt_completion_normalization( + stream_start_result(retry_listener).map_err(normalize_public_prompt_error)?; + wait_for_start_completion_for_scope( &store, &retry_receipt.orchestration_session_id, &retry_receipt.participant_id, + public_scope, ) .and_then(|()| { persist_resolved_start_attach_contract( @@ -554,6 +614,27 @@ fn stabilize_hidden_owner_helper_start_return( } } +#[cfg(unix)] +fn wait_for_start_completion_for_scope( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + participant_id: &str, + public_scope: AgentExecutionScope, +) -> Result<()> { + match public_scope { + AgentExecutionScope::Host => wait_for_start_prompt_completion_normalization( + store, + orchestration_session_id, + participant_id, + ), + AgentExecutionScope::World => wait_for_start_prompt_attached_readiness( + store, + orchestration_session_id, + participant_id, + ), + } +} + #[cfg(unix)] fn wait_for_start_prompt_completion_normalization( store: &AgentRuntimeStateStore, @@ -609,6 +690,53 @@ fn wait_for_start_prompt_completion_normalization( } } +#[cfg(unix)] +fn wait_for_start_prompt_attached_readiness( + store: &AgentRuntimeStateStore, + orchestration_session_id: &str, + participant_id: &str, +) -> Result<()> { + let readiness_started_at = std::time::Instant::now(); + loop { + match store.classify_hidden_owner_helper_launch_readiness( + orchestration_session_id, + participant_id, + true, + )? { + HiddenOwnerHelperLaunchReadiness::ReadyAttached => return Ok(()), + HiddenOwnerHelperLaunchReadiness::ReadyDetached(posture) => { + anyhow::bail!( + "runtime_start_failed: world-backed public start detached before returning (orchestration session {} normalized to {:?} instead of remaining attached through start return)", + orchestration_session_id, + posture + ); + } + HiddenOwnerHelperLaunchReadiness::Pending => {} + } + + if readiness_started_at.elapsed() >= START_DETACH_NORMALIZATION_TIMEOUT { + let snapshot_summary = store + .load_orchestration_session(orchestration_session_id)? + .map(|session| { + format!( + "state={:?}, posture={:?}, attached_participant_id={:?}, shell_owner_pid={}", + session.state, + session.posture, + session.attached_participant_id, + session.shell_owner_pid, + ) + }) + .unwrap_or_else(|| "session_missing".to_string()); + anyhow::bail!( + "timed out waiting for attached start readiness after startup prompt completion for orchestration session {} ({snapshot_summary})", + orchestration_session_id, + ); + } + + thread::sleep(START_DETACH_NORMALIZATION_POLL_INTERVAL); + } +} + #[cfg(unix)] fn recoverable_detached_start_retry( orchestration_session_id: &str, @@ -892,17 +1020,72 @@ fn start_dispatch_classifier( } } -fn build_start_dispatch_envelope(backend_id: &str) -> DispatchRequestEnvelope { +fn build_start_capability_overrides( + disabled_capabilities: &[AgentDisableCapabilityArg], +) -> DispatchCapabilityOverrideSet { + let mut overrides = DispatchCapabilityOverrideSet::default(); + for capability in disabled_capabilities { + match capability { + AgentDisableCapabilityArg::SessionResume => overrides.session_resume = Some(false), + AgentDisableCapabilityArg::SessionFork => overrides.session_fork = Some(false), + AgentDisableCapabilityArg::SessionStop => overrides.session_stop = Some(false), + AgentDisableCapabilityArg::StatusSnapshot => overrides.status_snapshot = Some(false), + AgentDisableCapabilityArg::EventStream => overrides.event_stream = Some(false), + } + } + overrides +} + +fn start_scope_from_arg(scope: AgentStartScopeArg) -> AgentExecutionScope { + match scope { + AgentStartScopeArg::Host => AgentExecutionScope::Host, + AgentStartScopeArg::World => AgentExecutionScope::World, + } +} + +fn alternate_execution_scope(scope: AgentExecutionScope) -> AgentExecutionScope { + match scope { + AgentExecutionScope::Host => AgentExecutionScope::World, + AgentExecutionScope::World => AgentExecutionScope::Host, + } +} + +fn host_execution_client_start_for_scope( + requested_execution_scope: AgentExecutionScope, +) -> HostExecutionClientStart { + match requested_execution_scope { + AgentExecutionScope::Host => HostExecutionClientStart::StartNow, + AgentExecutionScope::World => HostExecutionClientStart::Defer, + } +} + +fn require_start_backend_id(args: &AgentStartArgs) -> Result<&str> { + let backend_id = args.backend.trim(); + if backend_id.is_empty() { + anyhow::bail!(config_model::user_error( + "unknown_backend: public start requires --backend " + )); + } + Ok(backend_id) +} + +fn build_start_dispatch_envelope( + args: &AgentStartArgs, + backend_id: &str, + requested_execution_scope: AgentExecutionScope, +) -> 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(), + requested_execution_scope_override: Some(requested_execution_scope), + capability_overrides: build_start_capability_overrides(&args.disable_capability), attach_launch_knobs: AttachLaunchKnobs { - requested_execution_scope: AgentExecutionScope::Host, - host_execution_client_start: HostExecutionClientStart::StartNow, + requested_execution_scope, + host_execution_client_start: host_execution_client_start_for_scope( + requested_execution_scope, + ), attach_mode_preference: AttachModePreference::ContinuityRequired, }, has_prompt_payload: true, @@ -933,6 +1116,53 @@ fn build_persisted_attach_dispatch_envelope( } } +fn probe_start_scope_candidate( + args: &AgentStartArgs, + context: &AgentCommandContext, + cwd: &Path, + backend_id: &str, + requested_execution_scope: AgentExecutionScope, +) -> Result> { + let envelope = build_start_dispatch_envelope(args, backend_id, requested_execution_scope); + resolve_inventory_contract_for_exact_backend( + cwd, + &context.effective_config, + &context.inventory, + &context.base_policy, + &envelope, + requested_execution_scope, + ) + .map_err(|err| dispatch_resolution_user_error(start_dispatch_classifier(&err), err)) +} + +fn resolve_requested_start_scope( + args: &AgentStartArgs, + context: &AgentCommandContext, +) -> Result { + if let Some(scope) = args.scope { + return Ok(start_scope_from_arg(scope)); + } + + let backend_id = require_start_backend_id(args)?; + let cwd = current_dir(); + let preferred_scope = context.effective_config.agents.defaults.execution.scope; + if probe_start_scope_candidate(args, context, &cwd, backend_id, preferred_scope)?.is_some() { + return Ok(preferred_scope); + } + + let fallback_scope = alternate_execution_scope(preferred_scope); + if probe_start_scope_candidate(args, context, &cwd, backend_id, fallback_scope)?.is_some() { + return Ok(fallback_scope); + } + + anyhow::bail!(config_model::user_error(format!( + "unknown_backend: baseline truth rejected field 'backend_id': no exact backend match found for '{}' after resolving omitted --scope through effective {} then {} defaults", + backend_id, + execution_scope_label(preferred_scope), + execution_scope_label(fallback_scope), + ))); +} + fn permissive_inventory_selection_policy( inventory: &BTreeMap, ) -> Policy { @@ -1065,16 +1295,21 @@ fn run_stop(args: &AgentSessionControlArgs, _cli: &Cli) -> Result<()> { fn build_start_launch_plan( args: &AgentStartArgs, context: &AgentCommandContext, -) -> Result { - let backend_id = args.backend.trim(); - if backend_id.is_empty() { - anyhow::bail!(config_model::user_error( - "unknown_backend: public start requires --backend " - )); +) -> Result { + match resolve_requested_start_scope(args, context)? { + AgentExecutionScope::Host => build_host_start_launch_plan(args, context), + AgentExecutionScope::World => build_world_start_session_birth_plan(args, context), } +} + +fn build_host_start_launch_plan( + args: &AgentStartArgs, + context: &AgentCommandContext, +) -> Result { + let backend_id = require_start_backend_id(args)?; let cwd = current_dir(); - let envelope = build_start_dispatch_envelope(backend_id); + let envelope = build_start_dispatch_envelope(args, backend_id, AgentExecutionScope::Host); let resolved = match resolve_inventory_contract_for_exact_backend( &cwd, &context.effective_config, @@ -1121,7 +1356,7 @@ fn build_start_launch_plan( .map_err(|err| runtime_materialization_user_error("runtime_start_failed", err.reason))?; let orchestration_session_id = Uuid::now_v7().to_string(); - Ok(StartLaunchPlan { + Ok(HostStartLaunchPlan { helper_plan: HiddenOwnerHelperLaunchPlan { mode: OwnerHelperMode::Start, descriptor: (&descriptor).into(), @@ -1144,9 +1379,180 @@ fn build_start_launch_plan( source_orchestration_session_id: None, }, resolved_contract: resolved, + public_identity: StartPromptPublicIdentity { + backend_id: backend_id.to_string(), + scope: AgentExecutionScope::Host, + }, }) } +#[allow(dead_code)] +fn build_world_start_host_attach_dispatch_envelope( + args: &AgentStartArgs, + 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: Some(AgentExecutionScope::Host), + capability_overrides: build_start_capability_overrides(&args.disable_capability), + attach_launch_knobs: AttachLaunchKnobs { + requested_execution_scope: AgentExecutionScope::Host, + host_execution_client_start: HostExecutionClientStart::StartNow, + attach_mode_preference: AttachModePreference::ContinuityRequired, + }, + has_prompt_payload: false, + } +} + +#[allow(dead_code)] +fn build_world_start_session_birth_plan( + args: &AgentStartArgs, + context: &AgentCommandContext, +) -> Result { + let backend_id = require_start_backend_id(args)?; + + let cwd = current_dir(); + let world_envelope = + build_start_dispatch_envelope(args, backend_id, AgentExecutionScope::World); + let requested_world_contract = resolve_inventory_contract_for_exact_backend( + &cwd, + &context.effective_config, + &context.inventory, + &context.base_policy, + &world_envelope, + AgentExecutionScope::World, + ) + .map_err(|err| dispatch_resolution_user_error(start_dispatch_classifier(&err), err))? + .ok_or_else(|| { + config_model::user_error(format!( + "unknown_backend: baseline truth rejected field 'backend_id': no exact world-scoped backend match found for '{}'", + backend_id + )) + })?; + + let orchestrator = + validate_orchestrator_selection(&context.effective_config, &context.inventory) + .map_err(config_model::user_error)?; + let host_backend_id = orchestrator.derived_backend_id(); + let host_attach_envelope = + build_world_start_host_attach_dispatch_envelope(args, &host_backend_id); + let host_attach_resolved = resolve_inventory_contract_for_exact_backend( + &cwd, + &context.effective_config, + &context.inventory, + &context.base_policy, + &host_attach_envelope, + AgentExecutionScope::Host, + ) + .map_err(|err| dispatch_resolution_user_error(start_dispatch_classifier(&err), err))? + .ok_or_else(|| { + config_model::user_error(format!( + "runtime_start_failed: baseline truth rejected field 'backend_id': no exact host-scoped orchestrator backend match found for '{}'", + host_backend_id + )) + })?; + let host_attach_contract = HostAttachContract::from_resolved_contract(&host_attach_resolved, None) + .ok_or_else(|| { + config_model::user_error(format!( + "runtime_start_failed: resolved host attach contract for backend '{}' did not remain host scoped", + host_backend_id + )) + })?; + let descriptor = materialize_runtime_descriptor(&host_attach_resolved) + .map_err(|err| runtime_materialization_user_error("runtime_start_failed", err.reason))?; + + let orchestration_session_id = Uuid::now_v7().to_string(); + Ok(HostStartLaunchPlan { + 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: cwd.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: Some(host_attach_contract), + startup_prompt: None, + source_orchestration_session_id: None, + }, + resolved_contract: host_attach_resolved, + public_identity: StartPromptPublicIdentity { + backend_id: requested_world_contract.backend_id, + scope: AgentExecutionScope::World, + }, + }) +} + +#[cfg(target_os = "linux")] +fn establish_public_world_start_binding( + orchestration_session_id: &str, + workspace_root: &str, +) -> Result { + let rt = TokioRuntimeBuilder::new_current_thread() + .enable_all() + .build() + .context("failed to initialize world-start launch runtime")?; + rt.block_on(async { + establish_public_world_start_binding_async(orchestration_session_id, workspace_root).await + }) +} + +#[cfg(target_os = "linux")] +async fn establish_public_world_start_binding_async( + orchestration_session_id: &str, + workspace_root: &str, +) -> Result { + let requested_cwd = workspace_root.to_string(); + let requested_path = PathBuf::from(&requested_cwd); + let resolved = policy_snapshot::resolve_policy_snapshot_for_cwd(requested_path.as_path()) + .context("policy snapshot (public world start)")?; + let world_network_policy = policy_snapshot::resolve_world_network_policy_for_snapshot( + resolved.snapshot, + requested_path.as_path(), + ) + .context("world network policy (public world start)")?; + let world_network = policy_snapshot::request_world_network_routing(&world_network_policy); + let (mut start_params, _inherit_from_host) = ReplSessionStartParams::for_cwd_and_snapshot( + requested_cwd, + requested_path.as_path(), + world_network_policy.snapshot, + world_network, + ) + .context("failed to build shared-world session start parameters")?; + start_params.shared_world = Some(SharedWorldOwnerSpec { + orchestration_session_id: orchestration_session_id.to_string(), + action: SharedWorldOwnerAction::AttachOrCreate, + }); + + let world_client = ReplPersistentSessionClient::start_with(start_params, Arc::new(|_| {})) + .await + .context("failed to open authoritative shared world for public world start")?; + let ready = world_client.ready().clone(); + let authoritative_binding = ready.shared_world.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "runtime_start_failed: shared world ready frame omitted authoritative binding proof" + ) + })?; + let world_binding = PersistedWorldBinding { + world_id: authoritative_binding.world_id.clone(), + world_generation: authoritative_binding.world_generation, + }; + let _ = world_client.close().await; + Ok(world_binding) +} + #[cfg(unix)] fn persist_resolved_start_attach_contract( store: &AgentRuntimeStateStore, @@ -2735,17 +3141,68 @@ fn live_participant_status_projection( } } +fn born_unattached_status_projection( + session: &AgentRuntimeSessionRecord, +) -> Option { + let anchor = + crate::execution::agent_runtime::state_store::born_unattached_status_anchor(session)?; + let world_id = session.session.world_id.clone()?; + let world_generation = session.session.world_generation?; + let last_event_ts = session.session.last_active_at.max(anchor.last_status_at()); + Some(SessionProjection { + last_event_ts, + session: StatusSessionJson { + orchestration_session_id: session.session.orchestration_session_id.clone(), + participant_id: None, + agent_id: anchor.handle.agent_id.clone(), + source_kind: "live_runtime", + backend_id: anchor.handle.backend_id.clone(), + client: anchor.handle.agent_id.clone(), + router: PURE_AGENT_ROUTER.to_string(), + protocol: anchor.handle.protocol.clone(), + execution: ExecutionScopeJson { scope: "world" }, + role: Some(anchor.handle.role.clone()), + posture: Some(orchestration_session_posture_label(session.session.posture).to_string()), + attached_participant_id: session.session.attached_participant_id.clone(), + pending_inbox_count: Some(session.session.pending_inbox_count), + last_event_at: last_event_ts.to_rfc3339(), + world_id: Some(world_id), + world_generation: Some(world_generation), + }, + source: SessionProjectionSource { + identity: StatusIdentityKey { + orchestration_session_id: session.session.orchestration_session_id.clone(), + agent_id: anchor.handle.agent_id.clone(), + execution_scope: "world", + participant_id: None, + }, + run_id: anchor.internal.latest_run_id.clone(), + ts: last_event_ts, + is_world_scoped: true, + has_top_level_world_id: true, + has_top_level_world_generation: true, + }, + }) +} + fn live_session_status_projections(session: &AgentRuntimeSessionRecord) -> Vec { - session + let mut projections = session .status_visible_participants() .into_iter() .map(|participant| live_participant_status_projection(&session.session, &participant)) - .collect() + .collect::>(); + if projections.is_empty() { + if let Some(projection) = born_unattached_status_projection(session) { + projections.push(projection); + } + } + projections } fn orchestration_session_posture_label(posture: OrchestrationSessionPosture) -> &'static str { match posture { OrchestrationSessionPosture::ActiveAttached => "active_attached", + OrchestrationSessionPosture::BornUnattached => "born_unattached", OrchestrationSessionPosture::ParkedResumable => "parked_resumable", OrchestrationSessionPosture::AwaitingAttention => "awaiting_attention", OrchestrationSessionPosture::Terminal => "terminal", @@ -3606,3 +4063,353 @@ impl AgentExecutionScope { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use std::path::PathBuf; + use tempfile::TempDir; + + struct CurrentDirGuard { + original: PathBuf, + } + + impl CurrentDirGuard { + fn change_to(path: &Path) -> Self { + let original = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(path).expect("set current dir"); + Self { original } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } + + fn write_agent_file( + agent_id: &str, + runtime_family: &str, + scope: Option<&str>, + binary: &Path, + ) -> String { + let execution_scope = scope + .map(|scope| format!(" execution:\n scope: {scope}\n")) + .unwrap_or_default(); + format!( + "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: {PURE_AGENT_PROTOCOL}\n{execution_scope} cli:\n runtime_family: {runtime_family}\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", + binary.display() + ) + } + + fn write_test_runtime_inventory( + substrate_home: &Path, + workspace_root: &Path, + include_world_backend: bool, + allow_host_backend: bool, + ) { + let allowed_backends = match (allow_host_backend, include_world_backend) { + (true, true) => " - cli:codex\n - cli:claude_code\n", + (true, false) => " - cli:codex\n", + (false, true) => " - cli:claude_code\n", + (false, false) => "", + }; + fs::create_dir_all(substrate_home.join("agents")).expect("agents dir"); + 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"), + format!( + "id: test-global-policy\nname: Test Global Policy\nworld_fs:\n host_visible: true\n fail_closed:\n routing: true\n write:\n enabled: true\nnet_allowed: []\ncmd_allowed: []\ncmd_denied: []\ncmd_isolated: []\nrequire_approval: false\nallow_shell_operators: true\nlimits:\n max_memory_mb: null\n max_cpu_percent: null\n max_runtime_ms: null\n max_egress_bytes: null\nmetadata: {{}}\nagents:\n allowed_backends:\n{allowed_backends}", + ), + ) + .expect("write policy"); + fs::write( + workspace_root.join(".substrate-profile"), + "id: test-policy\nname: Test Policy\nworld_fs:\n host_visible: true\n fail_closed:\n routing: true\n write:\n enabled: true\nnet_allowed: []\ncmd_allowed: []\ncmd_denied: []\ncmd_isolated: []\nrequire_approval: false\nallow_shell_operators: true\nlimits:\n max_memory_mb: null\n max_cpu_percent: null\n max_runtime_ms: null\n max_egress_bytes: null\nmetadata: {}\n", + ) + .expect("write workspace profile"); + + let binary = std::env::current_exe().expect("current test binary"); + fs::write( + substrate_home.join("agents/codex.yaml"), + write_agent_file("codex", "codex", Some("host"), &binary), + ) + .expect("write host orchestrator"); + if include_world_backend { + fs::write( + substrate_home.join("agents/claude_code.yaml"), + write_agent_file("claude_code", "claude_code", Some("world"), &binary), + ) + .expect("write world backend"); + } + } + + fn write_test_runtime_inventory_with_unscoped_member( + substrate_home: &Path, + workspace_root: &Path, + global_scope: &str, + workspace_scope: Option<&str>, + ) { + write_test_runtime_inventory(substrate_home, workspace_root, true, true); + fs::write( + substrate_home.join("config.yaml"), + format!( + "agents:\n enabled: true\n defaults:\n execution:\n scope: {global_scope}\n hub:\n orchestrator_agent_id: codex\n", + ), + ) + .expect("write global scope config"); + if let Some(workspace_scope) = workspace_scope { + let workspace_config_dir = workspace_root.join(".substrate"); + fs::create_dir_all(&workspace_config_dir).expect("workspace config dir"); + fs::write( + workspace_config_dir.join("workspace.yaml"), + format!("agents:\n defaults:\n execution:\n scope: {workspace_scope}\n",), + ) + .expect("write workspace scope config"); + } + + let binary = std::env::current_exe().expect("current test binary"); + fs::write( + substrate_home.join("agents/claude_code.yaml"), + write_agent_file("claude_code", "claude_code", None, &binary), + ) + .expect("write unscoped member backend"); + } + + fn command_context_for_test(cwd: &Path) -> AgentCommandContext { + let effective_config = + config_model::resolve_effective_config(cwd, &CliConfigOverrides::default()) + .expect("resolve effective config"); + let (base_policy, _) = substrate_broker::resolve_effective_policy_with_explain(cwd, false) + .expect("resolve effective policy"); + let inventory = + load_effective_agent_inventory(cwd, &base_policy).expect("load agent inventory"); + AgentCommandContext { + effective_config, + base_policy, + inventory, + } + } + + fn host_start_args() -> AgentStartArgs { + AgentStartArgs { + backend: "cli:codex".to_string(), + scope: Some(AgentStartScopeArg::Host), + disable_capability: vec![AgentDisableCapabilityArg::SessionFork], + prompt_source: crate::execution::cli::PublicPromptArgs::default(), + json: false, + } + } + + fn world_start_args() -> AgentStartArgs { + AgentStartArgs { + backend: "cli:claude_code".to_string(), + scope: Some(AgentStartScopeArg::World), + disable_capability: vec![AgentDisableCapabilityArg::SessionStop], + prompt_source: crate::execution::cli::PublicPromptArgs::default(), + json: false, + } + } + + fn omitted_scope_start_args(backend_id: &str) -> AgentStartArgs { + AgentStartArgs { + backend: backend_id.to_string(), + scope: None, + disable_capability: Vec::new(), + prompt_source: crate::execution::cli::PublicPromptArgs::default(), + json: false, + } + } + + #[test] + #[serial] + fn host_start_launch_plan_keeps_eager_host_attach_behavior() { + 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"); + write_test_runtime_inventory(&substrate_home, &workspace_root, false, true); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let _substrate_home_guard = EnvVarGuard::set("SUBSTRATE_HOME", &substrate_home); + + let context = command_context_for_test(&workspace_root); + let plan = + build_host_start_launch_plan(&host_start_args(), &context).expect("host launch plan"); + + assert_eq!(plan.helper_plan.descriptor.backend_id, "cli:codex"); + assert_eq!( + plan.helper_plan + .host_attach_contract + .as_ref() + .expect("host attach contract") + .attach_launch_knobs + .host_execution_client_start, + crate::execution::agent_runtime::orchestration_session::HostAttachExecutionClientStart::StartNow + ); + } + + #[test] + #[serial] + fn world_start_launch_plan_builds_host_helper_with_world_visible_identity() { + 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"); + write_test_runtime_inventory(&substrate_home, &workspace_root, true, true); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let _substrate_home_guard = EnvVarGuard::set("SUBSTRATE_HOME", &substrate_home); + + let context = command_context_for_test(&workspace_root); + let plan = build_world_start_session_birth_plan(&world_start_args(), &context) + .expect("world session birth plan"); + + assert_eq!(plan.public_identity.backend_id, "cli:claude_code"); + assert_eq!(plan.public_identity.scope, AgentExecutionScope::World); + assert_eq!(plan.helper_plan.descriptor.backend_id, "cli:codex"); + let attach_contract = plan + .helper_plan + .host_attach_contract + .as_ref() + .expect("persisted attach contract"); + assert_eq!(attach_contract.backend_id, "cli:codex"); + assert_eq!( + attach_contract + .attach_launch_knobs + .requested_execution_scope, + AgentExecutionScope::Host + ); + assert_eq!( + attach_contract.attach_launch_knobs.host_execution_client_start, + crate::execution::agent_runtime::orchestration_session::HostAttachExecutionClientStart::StartNow + ); + assert!(!attach_contract.capabilities.session_stop); + assert_eq!(plan.helper_plan.session.world_id, None); + assert_eq!(plan.helper_plan.session.world_generation, None); + } + + #[test] + #[serial] + fn build_start_launch_plan_routes_world_scope_to_host_helper_path() { + 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"); + write_test_runtime_inventory(&substrate_home, &workspace_root, true, true); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let _substrate_home_guard = EnvVarGuard::set("SUBSTRATE_HOME", &substrate_home); + + let context = command_context_for_test(&workspace_root); + let plan = build_start_launch_plan(&world_start_args(), &context) + .expect("world start launch plan"); + + assert_eq!(plan.public_identity.backend_id, "cli:claude_code"); + assert_eq!(plan.public_identity.scope, AgentExecutionScope::World); + assert_eq!(plan.helper_plan.descriptor.backend_id, "cli:codex"); + } + + #[test] + #[serial] + fn build_start_launch_plan_resolves_omitted_scope_from_workspace_defaults_before_global() { + 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"); + write_test_runtime_inventory_with_unscoped_member( + &substrate_home, + &workspace_root, + "host", + Some("world"), + ); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let _substrate_home_guard = EnvVarGuard::set("SUBSTRATE_HOME", &substrate_home); + + let context = command_context_for_test(&workspace_root); + let plan = build_start_launch_plan(&omitted_scope_start_args("cli:claude_code"), &context) + .expect("omitted scope launch plan"); + + assert_eq!(plan.public_identity.backend_id, "cli:claude_code"); + assert_eq!(plan.public_identity.scope, AgentExecutionScope::World); + assert_eq!(plan.helper_plan.descriptor.backend_id, "cli:codex"); + } + + #[test] + #[serial] + fn build_start_launch_plan_resolves_omitted_scope_from_global_defaults_when_workspace_unset() { + 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"); + write_test_runtime_inventory_with_unscoped_member( + &substrate_home, + &workspace_root, + "host", + None, + ); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let _substrate_home_guard = EnvVarGuard::set("SUBSTRATE_HOME", &substrate_home); + + let context = command_context_for_test(&workspace_root); + let plan = build_start_launch_plan(&omitted_scope_start_args("cli:claude_code"), &context) + .expect("omitted scope launch plan"); + + assert_eq!(plan.helper_plan.descriptor.backend_id, "cli:claude_code"); + assert_eq!( + plan.resolved_contract.execution_scope, + AgentExecutionScope::Host + ); + } + + #[test] + #[serial] + fn world_start_session_birth_plan_fails_closed_without_host_attach_backend() { + 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"); + write_test_runtime_inventory(&substrate_home, &workspace_root, true, false); + let _cwd_guard = CurrentDirGuard::change_to(&workspace_root); + let _substrate_home_guard = EnvVarGuard::set("SUBSTRATE_HOME", &substrate_home); + + let context = command_context_for_test(&workspace_root); + let err = build_world_start_session_birth_plan(&world_start_args(), &context) + .expect_err("missing host attach backend must fail closed"); + + assert!(err.to_string().contains("policy_disallow")); + } +} diff --git a/crates/shell/src/execution/cli.rs b/crates/shell/src/execution/cli.rs index 1c6fa52f0..156475434 100644 --- a/crates/shell/src/execution/cli.rs +++ b/crates/shell/src/execution/cli.rs @@ -434,6 +434,33 @@ impl AgentScopeArg { } } +#[derive(Copy, Clone, Debug, Default, ValueEnum, PartialEq, Eq)] +#[value(rename_all = "snake_case")] +pub enum AgentStartScopeArg { + #[default] + Host, + World, +} + +impl AgentStartScopeArg { + pub fn as_str(self) -> &'static str { + match self { + Self::Host => "host", + Self::World => "world", + } + } +} + +#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)] +#[value(rename_all = "snake_case")] +pub enum AgentDisableCapabilityArg { + SessionResume, + SessionFork, + SessionStop, + StatusSnapshot, + EventStream, +} + #[derive(Args, Debug, Default)] pub struct AgentViewArgs { /// Emit JSON instead of human-readable output @@ -464,6 +491,14 @@ pub struct AgentOwnerHelperArgs { pub struct AgentStartArgs { #[arg(long = "backend", value_name = "BACKEND_ID")] pub backend: String, + #[arg(long, value_name = "host|world")] + pub scope: Option, + #[arg( + long = "disable-capability", + visible_alias = "disable-cap", + value_name = "CAPABILITY" + )] + pub disable_capability: Vec, #[command(flatten)] pub prompt_source: PublicPromptArgs, #[arg(long)] @@ -519,7 +554,7 @@ pub enum AgentAction { Status(AgentViewArgs), /// Validate deterministic startability of the agent control plane Doctor(AgentDoctorArgs), - /// Start a new host-scoped orchestration session from an exact backend id + /// Start a new orchestration session from an exact backend id Start(AgentStartArgs), /// Submit a follow-up prompt to the exact backend in an orchestration session Turn(AgentTurnArgs), @@ -570,6 +605,90 @@ pub enum WorkspaceAction { Rollback(WorkspaceRollbackArgs), } +#[cfg(test)] +mod tests { + use super::{ + AgentAction, AgentCmd, AgentDisableCapabilityArg, AgentStartScopeArg, Cli, SubCommands, + }; + use clap::Parser; + + fn parse_start_args(args: &[&str]) -> super::AgentStartArgs { + let cli = Cli::try_parse_from(args).expect("agent start should parse"); + let Some(SubCommands::Agent(AgentCmd { + action: AgentAction::Start(start), + })) = cli.sub + else { + panic!("expected agent start command"); + }; + start + } + + #[test] + fn agent_start_leaves_scope_unset_when_omitted() { + let start = parse_start_args(&[ + "substrate", + "agent", + "start", + "--backend", + "cli:codex", + "--prompt", + "hello", + ]); + + assert_eq!(start.scope, None); + assert!(start.disable_capability.is_empty()); + } + + #[test] + fn agent_start_accepts_scope_and_repeatable_disable_capability_flags() { + let start = parse_start_args(&[ + "substrate", + "agent", + "start", + "--backend", + "cli:codex", + "--scope", + "world", + "--disable-capability", + "event_stream", + "--disable-cap", + "session_resume", + "--prompt", + "hello", + ]); + + assert_eq!(start.scope, Some(AgentStartScopeArg::World)); + assert_eq!( + start.disable_capability, + vec![ + AgentDisableCapabilityArg::EventStream, + AgentDisableCapabilityArg::SessionResume, + ] + ); + } + + #[test] + fn agent_start_rejects_unsupported_disable_capability_names_at_parse_time() { + let err = Cli::try_parse_from([ + "substrate", + "agent", + "start", + "--backend", + "cli:codex", + "--disable-capability", + "llm", + "--prompt", + "hello", + ]) + .expect_err("unsupported disable capability must fail at parse time"); + + let rendered = err.to_string(); + assert!(rendered.contains("llm")); + assert!(rendered.contains("session_resume")); + assert!(rendered.contains("event_stream")); + } +} + #[derive(Args, Debug)] pub struct WorkspaceInitArgs { /// Workspace root path (defaults to .) diff --git a/crates/shell/src/execution/manager.rs b/crates/shell/src/execution/manager.rs index dea018060..a859c371f 100644 --- a/crates/shell/src/execution/manager.rs +++ b/crates/shell/src/execution/manager.rs @@ -238,6 +238,10 @@ pub(crate) fn configure_child_shell_env( cmd.set_env_var("PATH", path); } + // A fresh child shell must always re-source manager_env.sh even if the + // parent process was itself launched from a Substrate-managed shell. + cmd.remove_env_var("SUBSTRATE_MANAGER_ENV_ACTIVE"); + if let Some(original) = &config.host_bash_env { cmd.set_env_var("SUBSTRATE_ORIGINAL_BASH_ENV", original); } else { @@ -275,7 +279,7 @@ mod tests { use crate::execution::settings::WorldRootSettings; use crate::execution::ShellMode; use serial_test::serial; - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; use std::fs; use substrate_common::WorldRootMode; use substrate_trace::init_trace; @@ -345,6 +349,22 @@ mod tests { } } + #[derive(Default)] + struct RecordingEnvAdapter { + set: HashMap, + removed: HashSet, + } + + impl CommandEnvAdapter for RecordingEnvAdapter { + fn set_env_var(&mut self, key: &str, value: &str) { + self.set.insert(key.to_string(), value.to_string()); + } + + fn remove_env_var(&mut self, key: &str) { + self.removed.insert(key.to_string()); + } + } + #[test] #[serial] fn configure_manager_init_generates_snippet_and_exports_env() { @@ -428,6 +448,20 @@ managers: assert!(script.contains(".substrate_bashenv")); } + #[test] + fn configure_child_shell_env_clears_inherited_manager_env_guard() { + let temp = tempdir().unwrap(); + let config = test_shell_config(&temp); + let mut adapter = RecordingEnvAdapter::default(); + + configure_child_shell_env(&mut adapter, &config, true, false); + + assert!( + adapter.removed.contains("SUBSTRATE_MANAGER_ENV_ACTIVE"), + "fresh child shells must not inherit the manager env recursion guard" + ); + } + #[test] #[serial] fn manager_manifest_base_path_prefers_env_override() { diff --git a/crates/shell/src/execution/routing/dispatch/exec.rs b/crates/shell/src/execution/routing/dispatch/exec.rs index 4269226bb..8719ff50f 100644 --- a/crates/shell/src/execution/routing/dispatch/exec.rs +++ b/crates/shell/src/execution/routing/dispatch/exec.rs @@ -153,6 +153,31 @@ fn required_world_backend_unavailable_error(reason: &str) -> anyhow::Error { ) } +fn shell_escape_for_command(path: &Path) -> String { + let raw = path.to_string_lossy(); + if raw.contains('\'') { + format!("'{}'", raw.replace('\'', "'\"'\"'")) + } else { + format!("'{raw}'") + } +} + +fn bash_startup_source_prefix(config: &ShellConfig) -> Option { + if config.no_world { + return None; + } + + let startup_path = if config.preexec_available { + config.bash_preexec_path.as_path() + } else { + config.manager_env_path.as_path() + }; + let startup_path = shell_escape_for_command(startup_path); + Some(format!( + "if [[ -f {startup_path} ]]; then source {startup_path}; fi; " + )) +} + fn exit_status_from_code(code: i32) -> ExitStatus { #[cfg(unix)] { @@ -1519,6 +1544,11 @@ fn execute_external( cmd.arg("/C").arg(cmd_command); } else { // Unix shells (bash, sh, zsh, etc.) + if is_bash { + if let Some(prefix) = bash_startup_source_prefix(config) { + command_for_shell = format!("{prefix}{command_for_shell}"); + } + } if config.ci_mode && !config.no_exit_on_error && is_bash { command_for_shell = format!("set -euo pipefail; {command_for_shell}"); } diff --git a/crates/shell/src/repl/async_repl.rs b/crates/shell/src/repl/async_repl.rs index 6dc2bb115..eb4785804 100644 --- a/crates/shell/src/repl/async_repl.rs +++ b/crates/shell/src/repl/async_repl.rs @@ -3031,6 +3031,16 @@ pub(crate) fn run_hidden_owner_helper(plan: HiddenOwnerHelperLaunchPlan) -> Resu let mut telemetry = ReplSessionTelemetry::new(config.clone(), "agent_owner_helper"); let prepared = prepare_hidden_owner_helper_runtime(&config, &plan) .map_err(|failure| anyhow!(failure.message))?; + let initial_world_binding = match ( + plan.session.world_id.as_ref(), + plan.session.world_generation, + ) { + (Some(world_id), Some(world_generation)) => Some(PersistedWorldBinding { + world_id: world_id.clone(), + world_generation, + }), + _ => None, + }; let initial_prompt = plan.startup_prompt.as_ref().map(|startup_prompt| { InitialExecPromptPlan::StartupPrompt { prompt: startup_prompt.prompt_text.clone(), @@ -3039,7 +3049,7 @@ pub(crate) fn run_hidden_owner_helper(plan: HiddenOwnerHelperLaunchPlan) -> Resu }); let runtime = start_host_orchestrator_runtime_with_prepared_prompt( Some(prepared), - None, + initial_world_binding.as_ref(), initial_prompt, matches!(plan.mode, OwnerHelperMode::Attach), true, @@ -6240,7 +6250,9 @@ fn build_parked_host_runtime_snapshots( OrchestrationSessionPosture::AwaitingAttention => { parked_orchestration.mark_awaiting_attention(); } - OrchestrationSessionPosture::ActiveAttached | OrchestrationSessionPosture::Terminal => { + OrchestrationSessionPosture::ActiveAttached + | OrchestrationSessionPosture::BornUnattached + | OrchestrationSessionPosture::Terminal => { unreachable!("detached host parking only normalizes to parked or attention posture") } } @@ -8580,6 +8592,19 @@ mod tests { } } + #[cfg(unix)] + fn runtime_agent_file( + agent_id: &str, + scope: &str, + runtime_family: &str, + binary: &Path, + ) -> String { + format!( + "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: {PURE_AGENT_PROTOCOL}\n execution:\n scope: {scope}\n cli:\n runtime_family: {runtime_family}\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", + binary.display() + ) + } + #[cfg(unix)] fn write_runtime_inventory_with_world_member( substrate_home: &Path, @@ -8600,18 +8625,12 @@ mod tests { fs::create_dir_all(&agents_dir).expect("agents dir"); fs::write( agents_dir.join("claude_code.yaml"), - format!( - "version: 1\nid: claude_code\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", - orchestrator_binary.display() - ), + runtime_agent_file("claude_code", "host", "claude_code", orchestrator_binary), ) .expect("write claude_code agent file"); 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: world\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", - member_binary.display() - ), + runtime_agent_file("codex", "world", "codex", member_binary), ) .expect("write codex agent file"); } @@ -8679,10 +8698,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -8853,10 +8869,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -8970,10 +8983,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -9071,10 +9081,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -9152,10 +9159,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -9301,10 +9305,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -9432,10 +9433,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); @@ -9537,10 +9535,7 @@ mod tests { 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() - ), + runtime_agent_file("codex", "host", "codex", &fake_codex), ) .expect("write codex agent file"); diff --git a/crates/shell/tests/agent_hub_trace_persistence.rs b/crates/shell/tests/agent_hub_trace_persistence.rs index 2fed772a2..62a447695 100644 --- a/crates/shell/tests/agent_hub_trace_persistence.rs +++ b/crates/shell/tests/agent_hub_trace_persistence.rs @@ -159,7 +159,7 @@ fn write_orchestrator_runtime_config(home_substrate: &Path, fake_codex: &Path) { fs::write( home_substrate.join("agents/codex.yaml"), format!( - "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\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", + "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: host\n cli:\n runtime_family: codex\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() ), ) diff --git a/crates/shell/tests/agent_public_control_surface_v1.rs b/crates/shell/tests/agent_public_control_surface_v1.rs index e2ba55143..f16eb1214 100644 --- a/crates/shell/tests/agent_public_control_surface_v1.rs +++ b/crates/shell/tests/agent_public_control_surface_v1.rs @@ -104,13 +104,13 @@ impl AgentControlFixture { ); } - fn write_runtime_inventory(&self, include_world_backend: bool) { + fn write_runtime_inventory_with_member_backend(&self, member_agent_id: Option<&str>) { fs::create_dir_all(self.substrate_home.join("agents")).expect("create agents dir"); - let allowed_backends = if include_world_backend { - " - cli:codex\n - cli:claude_code\n" - } else { - " - cli:codex\n" - }; + let mut allowed_backends = vec![" - cli:codex".to_string()]; + if let Some(member_agent_id) = member_agent_id { + allowed_backends.push(format!(" - cli:{member_agent_id}")); + } + let allowed_backends = allowed_backends.join("\n"); fs::write( self.substrate_home.join("config.yaml"), "agents:\n enabled: true\n hub:\n orchestrator_agent_id: codex\n toolbox:\n enabled: true\n bind:\n transport: uds\n", @@ -119,7 +119,7 @@ impl AgentControlFixture { fs::write( self.substrate_home.join("policy.yaml"), format!( - "id: test-global-policy\nname: Test Global Policy\nworld_fs:\n host_visible: true\n fail_closed:\n routing: true\n write:\n enabled: true\nnet_allowed: []\ncmd_allowed: []\ncmd_denied: []\ncmd_isolated: []\nrequire_approval: false\nallow_shell_operators: true\nlimits:\n max_memory_mb: null\n max_cpu_percent: null\n max_runtime_ms: null\n max_egress_bytes: null\nmetadata: {{}}\nagents:\n allowed_backends:\n{allowed_backends}", + "id: test-global-policy\nname: Test Global Policy\nworld_fs:\n host_visible: true\n fail_closed:\n routing: true\n write:\n enabled: true\nnet_allowed: []\ncmd_allowed: []\ncmd_denied: []\ncmd_isolated: []\nrequire_approval: false\nallow_shell_operators: true\nlimits:\n max_memory_mb: null\n max_cpu_percent: null\n max_runtime_ms: null\n max_egress_bytes: null\nmetadata: {{}}\nagents:\n allowed_backends:\n{allowed_backends}\n", ), ) .expect("write policy.yaml"); @@ -130,16 +130,81 @@ impl AgentControlFixture { .expect("write .substrate-profile"); fs::write( self.substrate_home.join("agents/codex.yaml"), - cli_agent_file("codex", "host", &self.fake_codex), + cli_agent_file("codex", Some("host"), &self.fake_codex), ) .expect("write codex agent file"); - if include_world_backend { + if let Some(member_agent_id) = member_agent_id { + fs::write( + self.substrate_home + .join(format!("agents/{member_agent_id}.yaml")), + cli_agent_file(member_agent_id, Some("world"), &self.fake_codex), + ) + .unwrap_or_else(|_| panic!("write {member_agent_id} agent file")); + } + } + + fn write_runtime_inventory(&self, include_world_backend: bool) { + self.write_runtime_inventory_with_member_backend( + include_world_backend.then_some("claude_code"), + ); + } + + fn write_runtime_inventory_with_unscoped_member( + &self, + global_scope: &str, + workspace_scope: Option<&str>, + ) { + self.write_runtime_inventory(true); + fs::write( + self.substrate_home.join("config.yaml"), + format!( + "agents:\n enabled: true\n defaults:\n execution:\n scope: {global_scope}\n hub:\n orchestrator_agent_id: codex\n toolbox:\n enabled: true\n bind:\n transport: uds\n", + ), + ) + .expect("write global config.yaml"); + if let Some(workspace_scope) = workspace_scope { + let workspace_config_dir = self.workspace_root.join(".substrate"); + fs::create_dir_all(&workspace_config_dir).expect("create workspace config dir"); + fs::write( + workspace_config_dir.join("workspace.yaml"), + format!("agents:\n defaults:\n execution:\n scope: {workspace_scope}\n",), + ) + .expect("write workspace config patch"); + } + fs::write( + self.substrate_home.join("agents/claude_code.yaml"), + cli_agent_file("claude_code", None, &self.fake_codex), + ) + .expect("write unscoped claude_code agent file"); + } + + fn write_runtime_inventory_with_unscoped_orchestrator( + &self, + global_scope: &str, + workspace_scope: Option<&str>, + ) { + self.write_runtime_inventory(false); + fs::write( + self.substrate_home.join("config.yaml"), + format!( + "agents:\n enabled: true\n defaults:\n execution:\n scope: {global_scope}\n hub:\n orchestrator_agent_id: codex\n toolbox:\n enabled: true\n bind:\n transport: uds\n", + ), + ) + .expect("write global config.yaml"); + if let Some(workspace_scope) = workspace_scope { + let workspace_config_dir = self.workspace_root.join(".substrate"); + fs::create_dir_all(&workspace_config_dir).expect("create workspace config dir"); fs::write( - self.substrate_home.join("agents/claude_code.yaml"), - cli_agent_file("claude_code", "world", &self.fake_codex), + workspace_config_dir.join("workspace.yaml"), + format!("agents:\n defaults:\n execution:\n scope: {workspace_scope}\n",), ) - .expect("write claude_code agent file"); + .expect("write workspace config patch"); } + fs::write( + self.substrate_home.join("agents/codex.yaml"), + cli_agent_file("codex", None, &self.fake_codex), + ) + .expect("write unscoped codex agent file"); } fn run(&self, args: &[&str]) -> Output { @@ -223,13 +288,25 @@ impl AgentControlFixture { } } -fn cli_agent_file(agent_id: &str, scope: &str, binary: &Path) -> String { +fn cli_agent_file(agent_id: &str, scope: Option<&str>, binary: &Path) -> String { + let execution_scope = scope + .map(|scope| format!(" execution:\n scope: {scope}\n")) + .unwrap_or_default(); + let runtime_family = runtime_family_for_fixture_agent(agent_id); format!( - "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: {PURE_AGENT_PROTOCOL}\n execution:\n scope: {scope}\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", + "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: {PURE_AGENT_PROTOCOL}\n{execution_scope} cli:\n runtime_family: {runtime_family}\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", binary.display() ) } +fn runtime_family_for_fixture_agent(agent_id: &str) -> &'static str { + match agent_id { + "claude_code" => "claude_code", + "codex" | "codex_world" => "codex", + other => panic!("fixture runtime_family is not specified for agent `{other}`"), + } +} + fn write_fake_codex_script(dir: &Path) -> PathBuf { let path = dir.join("fake-codex.sh"); let count_path = dir.join("fake-codex.count"); @@ -766,11 +843,7 @@ fn host_attach_contract_manifest( agent_id: &str, orchestration_session_id: &str, ) -> Value { - let backend_kind = if agent_id == "claude_code" { - "claude_code" - } else { - "codex" - }; + let backend_kind = runtime_family_for_fixture_agent(agent_id); json!({ "backend_id": format!("cli:{agent_id}"), "execution_scope": "host", @@ -1053,7 +1126,7 @@ fn write_runtime_participant( "last_transition_at": ts, "resumed_from_participant_id": resumed_from_participant_id, "internal": { - "resolved_agent_kind": agent_id, + "resolved_agent_kind": runtime_family_for_fixture_agent(agent_id), "resolved_binary_path": fixture.fake_codex.display().to_string(), "shell_owner_pid": std::process::id(), "lease_token": format!("lease-{participant_id}"), @@ -1120,7 +1193,7 @@ fn write_world_member_participant( "world_generation": world_generation, "orchestrator_participant_id": orchestrator_participant_id, "internal": { - "resolved_agent_kind": agent_id, + "resolved_agent_kind": runtime_family_for_fixture_agent(agent_id), "resolved_binary_path": fixture.fake_codex.display().to_string(), "shell_owner_pid": std::process::id(), "lease_token": format!("lease-{participant_id}"), @@ -1614,6 +1687,227 @@ fn public_start_turn_and_stop_emit_streaming_ndjson_and_authoritative_state() { ); } +#[test] +#[serial] +fn public_start_scope_host_and_disable_capability_flags_persist_narrowed_capabilities() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory(false); + + let output = fixture.run(&[ + "agent", + "start", + "--backend", + "cli:codex", + "--scope", + "host", + "--disable-capability", + "session_fork", + "--disable-cap", + "status_snapshot", + "--prompt", + "hello from narrowed start", + "--json", + ]); + assert!( + output.status.success(), + "host-scoped public start with narrowed capabilities should succeed: {output:?}" + ); + + let records = parse_ndjson_output(&output); + let completed = find_ndjson_record(&records, "completed"); + let orchestration_session_id = completed["orchestration_session_id"] + .as_str() + .expect("start session id"); + let persisted_session = fixture.load_orchestration_session(orchestration_session_id); + + assert_eq!( + persisted_session + .pointer("/host_attach_contract/capabilities/session_fork") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/capabilities/status_snapshot") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/capabilities/session_resume") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/attach_launch_knobs/requested_execution_scope") + .and_then(Value::as_str), + Some("host") + ); +} + +#[test] +#[serial] +fn public_start_rejects_unsupported_disable_capability_names_at_parse_time() { + let fixture = AgentControlFixture::new(); + + let output = fixture.run(&[ + "agent", + "start", + "--backend", + "cli:codex", + "--disable-capability", + "llm", + "--prompt", + "hello", + "--json", + ]); + assert_eq!( + output.status.code(), + Some(2), + "unsupported disable capability names must fail at parse time: {output:?}" + ); + + let stderr = stderr_text(&output); + assert!( + stderr.contains("invalid value 'llm'"), + "parse-time rejection must name the unsupported capability: {stderr}" + ); + assert!( + stderr.contains("session_resume") && stderr.contains("event_stream"), + "parse-time rejection must advertise the supported narrowing family: {stderr}" + ); + assert!( + !fixture.fake_codex_args_path(1).exists(), + "parse-time capability rejection must fail before launching a backend runtime" + ); +} + +#[test] +#[serial] +fn public_start_omitted_scope_uses_global_defaults_when_workspace_scope_is_unset() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory_with_unscoped_orchestrator("host", None); + + let output = fixture.run(&[ + "agent", + "start", + "--backend", + "cli:codex", + "--prompt", + "hello from global host default", + "--json", + ]); + assert!( + output.status.success(), + "omitted scope should honor the global host default for an unscoped backend: {output:?}" + ); + + let records = parse_ndjson_output(&output); + let accepted = find_ndjson_record(&records, "accepted"); + let completed = find_ndjson_record(&records, "completed"); + assert_eq!(accepted.get("scope").and_then(Value::as_str), Some("host")); + assert_eq!( + completed.get("backend_id").and_then(Value::as_str), + Some("cli:codex") + ); + + let orchestration_session_id = completed["orchestration_session_id"] + .as_str() + .expect("start session id"); + let persisted_session = fixture.load_orchestration_session(orchestration_session_id); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/attach_launch_knobs/requested_execution_scope") + .and_then(Value::as_str), + Some("host") + ); +} + +#[test] +#[serial] +fn public_start_omitted_scope_prefers_workspace_defaults_before_global_defaults() { + let fixture = AgentControlFixture::new(); + fixture.init_workspace(); + fixture.write_runtime_inventory_with_unscoped_member("host", Some("world")); + + #[cfg(target_os = "linux")] + let output = { + let socket_home = tempfile::Builder::new() + .prefix("sac-omitted-world-") + .tempdir_in("/tmp") + .expect("socket tempdir"); + let socket_path = socket_home.path().join("world.sock"); + let _server = ReplWorldAgentStub::start_with_member_dispatch_scripts( + &socket_path, + StreamBehavior::Normal, + vec![MemberDispatchStreamScript::ReadyAndHoldUntilCancel { + session_handle_id: "session-omitted-world-start".to_string(), + exit_code_on_cancel: 130, + }], + ); + fixture + .command() + .current_dir(&fixture.workspace_root) + .env("SUBSTRATE_WORLD_SOCKET", &socket_path) + .args([ + "agent", + "start", + "--backend", + "cli:claude_code", + "--prompt", + "hello from workspace world default", + "--json", + ]) + .output() + .expect("run public world start with omitted scope") + }; + + #[cfg(not(target_os = "linux"))] + let output = fixture.run(&[ + "agent", + "start", + "--backend", + "cli:claude_code", + "--prompt", + "hello from workspace world default", + "--json", + ]); + + #[cfg(not(target_os = "linux"))] + { + assert_eq!( + output.status.code(), + Some(2), + "workspace world default should resolve omitted scope to the Linux-first world path: {output:?}" + ); + let stderr = stderr_text(&output); + assert!( + stderr.contains("unsupported_platform_or_posture"), + "world-default omitted scope must keep the frozen classifier: {stderr}" + ); + return; + } + + #[cfg(target_os = "linux")] + { + assert!( + output.status.success(), + "workspace world default should route omitted scope through the world-backed path: {output:?}" + ); + let records = parse_ndjson_output(&output); + let accepted = find_ndjson_record(&records, "accepted"); + let start_json = find_ndjson_record(&records, "completed"); + assert_eq!(accepted.get("scope").and_then(Value::as_str), Some("world")); + assert_eq!( + start_json.get("backend_id").and_then(Value::as_str), + Some("cli:claude_code") + ); + } +} + #[test] #[serial] fn public_reattach_and_fork_preserve_exact_session_and_lineage_contracts() { @@ -1929,18 +2223,6 @@ fn public_fork_and_stop_fail_closed_when_persisted_attach_contract_disables_capa 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, @@ -2013,11 +2295,6 @@ fn public_fork_and_stop_fail_closed_when_persisted_attach_contract_disables_capa 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] @@ -3610,7 +3887,7 @@ fn public_turn_fail_closed_taxonomy_is_explicit_for_world_linkage_ambiguity_and_ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { let fixture = AgentControlFixture::new(); fixture.init_workspace(); - fixture.write_runtime_inventory(true); + fixture.write_runtime_inventory_with_member_backend(Some("codex_world")); let socket_home = tempfile::Builder::new() .prefix("sac-world-submit-") @@ -3639,7 +3916,7 @@ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { ) .expect("host runtime ready"); - repl.send_line("::cli:claude_code member targeted first turn"); + repl.send_line("::cli:codex_world member targeted first turn"); repl.wait_for_output("substrate>", Duration::from_secs(5)) .expect("prompt after initial world turn"); @@ -3689,7 +3966,7 @@ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { "--session", &orchestration_session_id, "--backend", - "cli:claude_code", + "cli:codex_world", "--prompt", "continue in world", "--json", @@ -3714,7 +3991,7 @@ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { ); assert_eq!( turn_json.get("backend_id").and_then(Value::as_str), - Some("cli:claude_code") + Some("cli:codex_world") ); assert_eq!( turn_json.get("turn_outcome").and_then(Value::as_str), @@ -3725,6 +4002,28 @@ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { Some("active") ); + let live_members = wait_for_live_world_member_count( + &fixture, + &orchestration_session_id, + 1, + Duration::from_secs(5), + ); + assert_eq!( + live_members[0].get("agent_id").and_then(Value::as_str), + Some("codex_world") + ); + assert_eq!( + live_members[0].get("backend_id").and_then(Value::as_str), + Some("cli:codex_world") + ); + assert_eq!( + live_members[0] + .pointer("/internal/resolved_agent_kind") + .and_then(Value::as_str), + Some("codex"), + "world member persistence must keep canonical runtime-family spelling separate from alias identity" + ); + let guard = records.lock().expect("lock world-service records"); assert_eq!( guard.member_turn_submit_requests.len(), @@ -3735,7 +4034,7 @@ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { assert_eq!(submit.orchestration_session_id, orchestration_session_id); assert_eq!(submit.participant_id, member_participant_id); assert_eq!(submit.orchestrator_participant_id, owner_participant_id); - assert_eq!(submit.backend_id, "cli:claude_code"); + assert_eq!(submit.backend_id, "cli:codex_world"); assert_eq!(submit.world_id, world_id); assert_eq!(submit.world_generation, world_generation); assert_eq!(submit.prompt, "continue in world"); @@ -3754,70 +4053,403 @@ fn public_turn_routes_linux_world_member_follow_up_through_typed_submit_path() { #[test] #[serial] -fn public_root_start_rejects_world_scoped_backends_in_v1() { +fn public_root_start_world_scope_starts_attached_host_session_with_world_binding_truth() { let fixture = AgentControlFixture::new(); fixture.init_workspace(); - fixture.write_runtime_inventory(true); + fixture.write_runtime_inventory_with_member_backend(Some("codex_world")); + + #[cfg(target_os = "linux")] + let output = { + let socket_home = tempfile::Builder::new() + .prefix("sac-world-start-") + .tempdir_in("/tmp") + .expect("socket tempdir"); + let socket_path = socket_home.path().join("world.sock"); + let server = ReplWorldAgentStub::start_with_member_dispatch_scripts( + &socket_path, + StreamBehavior::Normal, + vec![MemberDispatchStreamScript::ReadyAndHoldUntilCancel { + session_handle_id: "session-public-world-start".to_string(), + exit_code_on_cancel: 130, + }], + ); + let records = server.records(); + let output = fixture + .command() + .current_dir(&fixture.workspace_root) + .env("SUBSTRATE_WORLD_SOCKET", &socket_path) + .args([ + "agent", + "start", + "--backend", + "cli:codex_world", + "--scope", + "world", + "--prompt", + "hello", + "--json", + ]) + .output() + .expect("run public world start"); + let guard = records.lock().expect("lock world-service records"); + assert_eq!( + guard.persistent_start_sessions.len(), + 1, + "world-scoped root start must open exactly one shared-world session: {guard:#?}" + ); + assert_eq!( + guard.member_dispatch_requests.len(), + 0, + "world-scoped root start must not create a first world-member conversation: {guard:#?}" + ); + assert_eq!( + guard.execute_cancel_requests.len(), + 0, + "world-scoped root start must not bootstrap and cancel a temporary world member: {guard:#?}" + ); + drop(guard); + output + }; + #[cfg(not(target_os = "linux"))] let output = fixture.run(&[ "agent", "start", "--backend", - "cli:claude_code", + "cli:codex_world", + "--scope", + "world", "--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"), - "world-scoped root start must classify the posture failure exactly: {stderr}" - ); - assert!( - stderr.contains("public root start is host-only in v1"), - "world-scoped root start failure must explain the Linux-first host-only contract: {stderr}" - ); + #[cfg(not(target_os = "linux"))] + { + assert_eq!( + output.status.code(), + Some(2), + "non-Linux world-scoped root start must fail closed: {output:?}" + ); + let stderr = stderr_text(&output); + assert!( + stderr.contains("unsupported_platform_or_posture"), + "non-Linux world-scoped root start must keep the frozen classifier: {stderr}" + ); + assert!( + stderr.contains("supported on Linux only"), + "non-Linux world-scoped root start must explain the Linux-first rollout: {stderr}" + ); + return; + } + + #[cfg(target_os = "linux")] + { + assert!( + output.status.success(), + "world-scoped root start must succeed through the host-first startup path: {output:?}" + ); + let start_records = parse_ndjson_output(&output); + let start_accepted = find_ndjson_record(&start_records, "accepted"); + let start_json = find_ndjson_record(&start_records, "completed"); + assert_eq!( + start_records + .first() + .and_then(|record| record.get("kind")) + .and_then(Value::as_str), + Some("accepted"), + "world-scoped root start must stream acceptance before completion: {start_records:?}" + ); + assert_eq!( + start_accepted.get("backend_id").and_then(Value::as_str), + Some("cli:codex_world") + ); + assert_eq!( + start_accepted.get("scope").and_then(Value::as_str), + Some("world") + ); + assert_eq!( + start_json.get("action").and_then(Value::as_str), + Some("start") + ); + assert_eq!( + start_json.get("backend_id").and_then(Value::as_str), + Some("cli:codex_world") + ); + assert_eq!( + start_json.get("turn_outcome").and_then(Value::as_str), + Some("success") + ); + assert_eq!( + start_json.get("session_posture").and_then(Value::as_str), + Some("active") + ); + assert_eq!( + start_json.get("state").and_then(Value::as_str), + Some("active") + ); + assert_empty_warnings(start_json); + + let orchestration_session_id = start_json["orchestration_session_id"] + .as_str() + .expect("start session id"); + let participant_id = start_json["participant_id"] + .as_str() + .expect("start participant id"); + let persisted_session = fixture.load_orchestration_session(orchestration_session_id); + assert_eq!( + persisted_session.get("state").and_then(Value::as_str), + Some("active") + ); + assert_eq!( + persisted_session.get("posture").and_then(Value::as_str), + Some("active_attached") + ); + assert_eq!( + persisted_session + .get("active_session_handle_id") + .and_then(Value::as_str), + Some(participant_id) + ); + assert_eq!( + persisted_session + .get("attached_participant_id") + .and_then(Value::as_str), + Some(participant_id) + ); + assert!( + persisted_session + .get("shell_owner_pid") + .and_then(Value::as_u64) + .is_some_and(|pid| pid > 0), + "world-scoped root start must persist a live host owner pid: {persisted_session}" + ); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/execution_scope") + .and_then(Value::as_str), + Some("host") + ); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/attach_launch_knobs/host_execution_client_start") + .and_then(Value::as_str), + Some("start_now") + ); + assert_eq!( + persisted_session + .pointer("/host_attach_contract/continuity_uaa_session_id") + .and_then(Value::as_str), + Some("thread-test") + ); + assert_eq!( + persisted_session + .pointer("/startup_prompt/state") + .and_then(Value::as_str), + Some("completed") + ); + assert_eq!( + persisted_session.get("world_id").and_then(Value::as_str), + Some("wld_stub_0001"), + "linux world-scoped root start must persist the authoritative world_id from the shared-world launch seam" + ); + assert_eq!( + persisted_session.get("world_generation").and_then(Value::as_u64), + Some(0), + "linux world-scoped root start must persist the authoritative world_generation from the shared-world launch seam" + ); + let participant_files = { + let participants_dir = fixture + .substrate_home + .join("run/agent-hub/sessions") + .join(orchestration_session_id) + .join("participants"); + fs::read_dir(&participants_dir) + .ok() + .map(|entries| { + entries + .filter_map(Result::ok) + .filter(|entry| { + entry.path().extension().and_then(|value| value.to_str()) + == Some("json") + }) + .count() + }) + .unwrap_or(0) + }; + assert_eq!( + participant_files, 1, + "linux world-scoped root start must persist exactly one host orchestrator participant" + ); + let participants = + session_participant_manifests(&fixture.substrate_home, orchestration_session_id); + assert_eq!( + participants.len(), + 1, + "expected exactly one persisted host orchestrator manifest" + ); + let participant = &participants[0]; + assert_eq!( + participant.get("role").and_then(Value::as_str), + Some("orchestrator") + ); + assert_eq!( + participant + .pointer("/execution/scope") + .and_then(Value::as_str), + Some("host") + ); + assert_eq!( + participant.get("backend_id").and_then(Value::as_str), + Some("cli:codex") + ); + assert_ne!( + participant.get("state").and_then(Value::as_str), + Some("stopped"), + "host-first root start must leave the retained orchestrator live" + ); + assert_eq!( + participant + .pointer("/internal/uaa_session_id") + .and_then(Value::as_str), + Some("thread-test") + ); + let start_args = fixture.read_fake_codex_args(1); + assert!( + start_args.iter().any(|arg| arg == "exec"), + "world-scoped root start must still launch the host orchestrator through exec: {start_args:?}" + ); + assert!( + !start_args.iter().any(|arg| arg == "resume"), + "world-scoped root start must not resume a pre-existing host session: {start_args:?}" + ); + let start_stdin = fixture.read_fake_codex_stdin(1); + assert!( + start_stdin.contains("hello"), + "the inaugural prompt must ride the host orchestrator startup exec stdin payload: {start_stdin:?}" + ); + let turn_output = fixture + .command() + .current_dir(&fixture.workspace_root) + .args([ + "agent", + "turn", + "--session", + orchestration_session_id, + "--backend", + "cli:codex_world", + "--prompt", + "next", + "--json", + ]) + .output() + .expect("run pre-attach public world turn"); + assert_eq!( + turn_output.status.code(), + Some(2), + "world follow-up must fail closed until the host allocates a world backend slot: {turn_output:?}" + ); + let turn_stderr = stderr_text(&turn_output); + assert!( + turn_stderr.contains("backend_not_in_session"), + "world follow-up must fail because no world member slot exists yet: {turn_stderr}" + ); + assert!( + !turn_stderr.contains("born_unattached"), + "host-first world start must not report the old born_unattached posture: {turn_stderr}" + ); + } } #[test] #[serial] -fn public_root_start_denials_name_field_layer_and_reason() { +fn public_root_start_world_scope_reports_requested_backend_and_scope() { let fixture = AgentControlFixture::new(); fixture.init_workspace(); - fixture.write_runtime_inventory(true); + fixture.write_runtime_inventory_with_member_backend(Some("codex_world")); + + #[cfg(target_os = "linux")] + let output = { + let socket_home = tempfile::Builder::new() + .prefix("sac-world-start-shape-") + .tempdir_in("/tmp") + .expect("socket tempdir"); + let socket_path = socket_home.path().join("world.sock"); + let _server = ReplWorldAgentStub::start_with_member_dispatch_scripts( + &socket_path, + StreamBehavior::Normal, + vec![MemberDispatchStreamScript::ReadyAndHoldUntilCancel { + session_handle_id: "session-public-world-start-shape".to_string(), + exit_code_on_cancel: 130, + }], + ); + fixture + .command() + .current_dir(&fixture.workspace_root) + .env("SUBSTRATE_WORLD_SOCKET", &socket_path) + .args([ + "agent", + "start", + "--backend", + "cli:codex_world", + "--scope", + "world", + "--prompt", + "hello", + "--json", + ]) + .output() + .expect("run public world start") + }; + #[cfg(not(target_os = "linux"))] let output = fixture.run(&[ "agent", "start", "--backend", - "cli:claude_code", + "cli:codex_world", + "--scope", + "world", "--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}" - ); + #[cfg(not(target_os = "linux"))] + { + assert_eq!( + output.status.code(), + Some(2), + "non-Linux world-scoped root start must fail closed: {output:?}" + ); + return; + } + + #[cfg(target_os = "linux")] + { + assert!( + output.status.success(), + "world-scoped root start must succeed once the public seam is wired: {output:?}" + ); + let records = parse_ndjson_output(&output); + let accepted = find_ndjson_record(&records, "accepted"); + let start_json = find_ndjson_record(&records, "completed"); + assert!( + start_json.get("source_orchestration_session_id").is_none(), + "new world-root births must not advertise a source session: {start_json}" + ); + assert_eq!( + start_json.get("action").and_then(Value::as_str), + Some("start") + ); + assert_eq!( + start_json.get("backend_id").and_then(Value::as_str), + Some("cli:codex_world") + ); + assert_eq!(accepted.get("scope").and_then(Value::as_str), Some("world")); + assert_eq!( + start_json.get("state").and_then(Value::as_str), + Some("active") + ); + } } #[test] diff --git a/crates/shell/tests/agent_successor_contract_ahcsitc0.rs b/crates/shell/tests/agent_successor_contract_ahcsitc0.rs index f13db3b16..c6402ad96 100644 --- a/crates/shell/tests/agent_successor_contract_ahcsitc0.rs +++ b/crates/shell/tests/agent_successor_contract_ahcsitc0.rs @@ -24,6 +24,7 @@ struct SessionContractOptions<'a> { capability_override: Option>, cli_mode: &'a str, binary: &'a str, + runtime_family: Option<&'a str>, } impl SessionContractOptions<'_> { @@ -33,10 +34,19 @@ impl SessionContractOptions<'_> { capability_override: None, cli_mode: "persistent", binary: "sh", + runtime_family: None, } } } +fn runtime_family_for_fixture_agent(agent_id: &str) -> &'static str { + match agent_id { + "claude_code" | "claude_code_world" => "claude_code", + "codex" | "codex_world" | "helper" => "codex", + other => panic!("fixture runtime_family is not specified for agent `{other}`"), + } +} + struct AgentSuccessorFixture { _temp: TempDir, home: PathBuf, @@ -218,6 +228,26 @@ fn cli_agent_file( llm, mcp_client, enabled, + runtime_family_for_fixture_agent(agent_id), + SessionContractOptions::default(), + ) +} + +fn cli_agent_file_with_runtime_family( + agent_id: &str, + scope: &str, + llm: bool, + mcp_client: bool, + enabled: bool, + runtime_family: &str, +) -> String { + cli_agent_file_with_session_contract( + agent_id, + scope, + llm, + mcp_client, + enabled, + runtime_family, SessionContractOptions::default(), ) } @@ -228,15 +258,17 @@ fn cli_agent_file_with_session_contract<'a>( llm: bool, mcp_client: bool, enabled: bool, + runtime_family: &'a str, options: SessionContractOptions<'a>, ) -> String { + let runtime_family = options.runtime_family.unwrap_or(runtime_family); let mut body = format!("version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: {enabled}\n"); if let Some(protocol) = options.protocol { body.push_str(&format!(" protocol: {protocol}\n")); } body.push_str(&format!( - " execution:\n scope: {scope}\n cli:\n binary: {}\n mode: {}\n capabilities:\n", + " execution:\n scope: {scope}\n cli:\n runtime_family: {runtime_family}\n binary: {}\n mode: {}\n capabilities:\n", options.binary, options.cli_mode )); for capability in [ @@ -505,7 +537,7 @@ fn runtime_participant_manifest( manifest.insert( "internal".to_string(), json!({ - "resolved_agent_kind": agent_id, + "resolved_agent_kind": runtime_family_for_fixture_agent(agent_id), "resolved_binary_path": "sh", "shell_owner_pid": std::process::id(), "lease_token": format!("lease-{participant_id}"), @@ -889,11 +921,7 @@ fn orchestration_session_manifest_with_options( } 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" - }; + let backend_kind = runtime_family_for_fixture_agent(agent_id); json!({ "backend_id": format!("cli:{agent_id}"), "execution_scope": "host", @@ -2337,6 +2365,130 @@ fn agent_toolbox_surfaces_stay_orchestrator_anchored_when_live_member_exists() { ); } +#[test] +fn agent_turn_world_member_fails_closed_when_authoritative_parent_binding_is_missing() { + let fixture = AgentSuccessorFixture::new(); + fixture.init_workspace(); + fixture.seed_inventory_for_list_and_status_contracts(); + write_live_runtime_manifest( + &fixture, + "claude_code", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fca", + "ash_turn_parent_missing", + "2026-04-07T00:00:01Z", + ); + write_active_orchestration_session( + &fixture, + "claude_code", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fca", + Some("ash_turn_parent_missing"), + "2026-04-07T00:00:01Z", + ); + write_runtime_participant( + &fixture, + "ash_turn_member_missing", + "codex", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fca", + RuntimeParticipantOptions::world_member( + "running", + true, + "2026-04-07T00:00:02Z", + "wld_member_missing_0001", + 7, + "ash_turn_parent_missing", + ), + ); + + let output = fixture.run(&[ + "agent", + "turn", + "--session", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fca", + "--backend", + "cli:codex", + "--prompt", + "next", + "--json", + ]); + assert_eq!( + output.status.code(), + Some(2), + "turn must fail closed before dispatch when the authoritative parent world binding is absent: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("stale_linkage"), + "missing authoritative parent world binding must fail with stale_linkage rather than silently reviving the member runtime: {stderr}" + ); + assert!( + stderr.contains("no longer has an authoritative retained turn target"), + "missing authoritative parent world binding must explain why the retained world member is unusable: {stderr}" + ); +} + +#[test] +fn agent_turn_world_member_fails_closed_when_authoritative_parent_binding_mismatches_member() { + let fixture = AgentSuccessorFixture::new(); + fixture.init_workspace(); + fixture.seed_inventory_for_list_and_status_contracts(); + write_live_runtime_manifest( + &fixture, + "claude_code", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fcb", + "ash_turn_parent_mismatch", + "2026-04-07T00:00:01Z", + ); + write_active_orchestration_session_with_world_binding( + &fixture, + "claude_code", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fcb", + Some("ash_turn_parent_mismatch"), + "2026-04-07T00:00:01Z", + "wld_parent_binding_0001", + 7, + ); + write_runtime_participant( + &fixture, + "ash_turn_member_mismatch", + "codex", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fcb", + RuntimeParticipantOptions::world_member( + "running", + true, + "2026-04-07T00:00:02Z", + "wld_member_binding_9999", + 7, + "ash_turn_parent_mismatch", + ), + ); + + let output = fixture.run(&[ + "agent", + "turn", + "--session", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fcb", + "--backend", + "cli:codex", + "--prompt", + "next", + "--json", + ]); + assert_eq!( + output.status.code(), + Some(2), + "turn must fail closed before dispatch when the member world binding no longer matches the authoritative parent session: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("stale_linkage"), + "mismatched authoritative world binding must fail with stale_linkage: {stderr}" + ); + assert!( + stderr.contains("no longer has an authoritative retained turn target"), + "mismatched authoritative world binding must explain why the retained world member cannot be reused: {stderr}" + ); +} + #[test] fn agent_status_selected_host_row_stays_unchanged_when_parent_session_has_world_binding() { let fixture = AgentSuccessorFixture::new(); @@ -2741,6 +2893,103 @@ fn agent_status_json_surfaces_parked_resumable_fields_from_parent_session_truth( ); } +#[test] +fn agent_status_json_surfaces_born_unattached_fields_for_legacy_world_started_session_truth() { + let fixture = AgentSuccessorFixture::new(); + fixture.init_workspace(); + fixture.seed_inventory_for_list_and_status_contracts(); + write_runtime_participant( + &fixture, + "ash_world_birth_member", + "codex", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fbc", + RuntimeParticipantOptions::world_member( + "stopped", + false, + "2026-04-05T00:00:04Z", + "world-born-1", + 7, + "ash_deferred_world_birth", + ), + ); + write_orchestration_session_with_manifest_options( + &fixture, + OrchestrationSessionManifestSpec { + agent_id: "claude_code", + orchestration_session_id: "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fbc", + active_session_handle_id: None, + state: "active", + ts: "2026-04-05T00:00:04Z", + world_binding: Some(("world-born-1", 7)), + }, + OrchestrationSessionManifestOptions { + posture: Some("born_unattached"), + attached_participant_id: Some(None), + pending_inbox_count: Some(0), + last_parked_at: Some(Some("2026-04-05T00:00:04Z")), + last_attention_at: Some(None), + parked_reason: Some(Some("legacy deferred host attach")), + host_attach_contract: None, + }, + ); + + let output = fixture.run(&["agent", "status", "--json"]); + assert!( + output.status.success(), + "agent status should surface legacy born-unattached sessions without treating them as the default public happy path: {output:?}" + ); + + let json = parse_json_output(&output); + let sessions = json["sessions"] + .as_array() + .expect("sessions should be an array"); + let born_unattached = find_session_by_agent_and_orchestration_session( + sessions, + "codex", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fbc", + ); + assert_eq!( + born_unattached + .pointer("/source_kind") + .and_then(Value::as_str), + Some("live_runtime") + ); + assert_eq!( + born_unattached + .pointer("/execution/scope") + .and_then(Value::as_str), + Some("world") + ); + assert_eq!( + born_unattached.pointer("/role").and_then(Value::as_str), + Some("member") + ); + assert_eq!( + born_unattached.pointer("/posture").and_then(Value::as_str), + Some("born_unattached") + ); + assert!( + born_unattached.get("participant_id").is_none(), + "legacy born-unattached status rows must not imply a live authoritative participant: {born_unattached}" + ); + assert!( + born_unattached + .pointer("/attached_participant_id") + .is_some_and(Value::is_null), + "born-unattached rows must preserve null attached_participant_id from session truth: {born_unattached}" + ); + assert_eq!( + born_unattached.pointer("/world_id").and_then(Value::as_str), + Some("world-born-1") + ); + assert_eq!( + born_unattached + .pointer("/world_generation") + .and_then(Value::as_u64), + Some(7) + ); +} + #[test] fn agent_status_json_surfaces_awaiting_attention_fields_from_parent_session_truth() { let fixture = AgentSuccessorFixture::new(); @@ -3624,6 +3873,104 @@ fn agent_status_persists_resumed_from_participant_id_for_replacement_members() { ); } +#[test] +fn agent_status_alias_world_member_keeps_exact_backend_and_canonical_runtime_family() { + let fixture = AgentSuccessorFixture::new(); + fixture.init_workspace(); + fixture.write_global_config_patch( + r#"agents: + enabled: true + hub: + orchestrator_agent_id: codex +"#, + ); + fixture.write_global_policy_patch( + r#"agents: + allowed_backends: + - cli:codex + - cli:codex_world +"#, + ); + fixture.write_agent_file( + "codex.yaml", + &cli_agent_file("codex", "host", true, true, true), + ); + fixture.write_agent_file( + "codex_world.yaml", + &cli_agent_file_with_runtime_family("codex_world", "world", true, false, true, "codex"), + ); + write_live_runtime_manifest( + &fixture, + "codex", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fd1", + "ash_orchestrator_alias_world", + "2026-04-05T00:00:01Z", + ); + write_active_orchestration_session( + &fixture, + "codex", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fd1", + Some("ash_orchestrator_alias_world"), + "2026-04-05T00:00:01Z", + ); + write_replacement_world_member_manifest( + &fixture, + "codex_world", + "0195f8f1-7a34-7b7f-9c4d-9a7c2f5d6fd1", + "ash_codex_world_member", + "ash_orchestrator_alias_world", + "wld_alias_0001", + 4, + "ash_codex_world_member_previous", + "2026-04-05T00:00:03Z", + ); + + let output = fixture.run(&["agent", "status", "--scope", "world", "--json"]); + assert!( + output.status.success(), + "world status should succeed for aliased exact backend fixtures: {output:?}" + ); + + let json = parse_json_output(&output); + let sessions = json["sessions"] + .as_array() + .expect("sessions should be an array"); + let alias_member = find_session_by_agent(sessions, "codex_world"); + assert_eq!( + alias_member.pointer("/agent_id").and_then(Value::as_str), + Some("codex_world") + ); + assert_eq!( + alias_member.pointer("/backend_id").and_then(Value::as_str), + Some("cli:codex_world"), + "status must keep the exact aliased backend selector visible: {alias_member}" + ); + + let persisted = serde_json::from_str::( + &fs::read_to_string(participant_manifest_path( + &fixture, + "ash_codex_world_member", + )) + .expect("aliased participant manifest should be readable"), + ) + .expect("aliased participant manifest should be valid JSON"); + assert_eq!( + persisted.pointer("/agent_id").and_then(Value::as_str), + Some("codex_world") + ); + assert_eq!( + persisted.pointer("/backend_id").and_then(Value::as_str), + Some("cli:codex_world") + ); + assert_eq!( + persisted + .pointer("/internal/resolved_agent_kind") + .and_then(Value::as_str), + Some("codex"), + "persisted alias manifests must keep canonical runtime-family truth separate from alias identity: {persisted}" + ); +} + #[test] fn agent_status_surfaces_host_successor_lineage_for_active_orchestrator_sessions() { let fixture = AgentSuccessorFixture::new(); @@ -4544,6 +4891,7 @@ fn agent_doctor_fails_at_orchestrator_selection_when_protocol_is_missing() { true, true, true, + "claude_code", SessionContractOptions { protocol: None, capability_override: None, @@ -4576,6 +4924,7 @@ fn agent_doctor_fails_at_orchestrator_selection_when_protocol_is_wrong() { true, true, true, + "claude_code", SessionContractOptions { protocol: Some("openai.responses"), capability_override: None, @@ -4608,6 +4957,7 @@ fn agent_doctor_fails_at_orchestrator_selection_when_required_capability_is_fals true, true, true, + "claude_code", SessionContractOptions { protocol: Some(PURE_AGENT_PROTOCOL), capability_override: Some(CapabilityOverride::ForceFalse("event_stream")), @@ -4640,6 +4990,7 @@ fn agent_doctor_fails_at_orchestrator_selection_when_required_capability_is_omit true, true, true, + "claude_code", SessionContractOptions { protocol: Some(PURE_AGENT_PROTOCOL), capability_override: Some(CapabilityOverride::Omit("event_stream")), @@ -4672,6 +5023,7 @@ fn agent_doctor_fails_at_runtime_realizability_when_selected_binary_is_missing() true, true, true, + "claude_code", SessionContractOptions { binary: "definitely_missing_substrate_agent_binary", ..SessionContractOptions::default() @@ -4725,6 +5077,7 @@ fn agent_doctor_fails_at_runtime_realizability_when_selected_cli_mode_is_per_req true, true, true, + "claude_code", SessionContractOptions { cli_mode: "per_request", ..SessionContractOptions::default() @@ -4814,7 +5167,7 @@ fn agent_doctor_fails_closed_on_world_member_allowlist_before_world_boundary() { agents: enabled: true hub: - orchestrator_agent_id: claude_code + orchestrator_agent_id: codex "#, ); fixture.write_global_policy_patch( @@ -4830,7 +5183,7 @@ world_fs: agents: allowed_backends: - - "cli:claude_code" + - "cli:codex" net_allowed: [] cmd_allowed: [] @@ -4850,12 +5203,12 @@ metadata: {} "#, ); fixture.write_agent_file( - "claude_code.yaml", - &cli_agent_file("claude_code", "host", true, true, true), + "codex.yaml", + &cli_agent_file("codex", "host", true, true, true), ); fixture.write_agent_file( - "codex.yaml", - &cli_agent_file("codex", "world", true, false, true), + "codex_world.yaml", + &cli_agent_file_with_runtime_family("codex_world", "world", true, false, true, "codex"), ); let output = fixture.run(&["agent", "doctor", "--json"]); @@ -4896,7 +5249,7 @@ metadata: {} assert_eq!( checks[5].pointer("/reason").and_then(Value::as_str), Some( - "required world-scoped member backend 'cli:codex' is not allowlisted by effective policy agents.allowed_backends" + "required world-scoped member backend 'cli:codex_world' is not allowlisted by effective policy agents.allowed_backends" ), "member dispatch must be gated by the derived backend_id before world boundary handling: {json}" ); @@ -4948,7 +5301,7 @@ fn agent_doctor_exactly_one_world_member_candidate_continues_into_world_boundary agents: enabled: true hub: - orchestrator_agent_id: claude_code + orchestrator_agent_id: codex "#, ); fixture.write_global_policy_patch( @@ -4964,8 +5317,8 @@ world_fs: agents: allowed_backends: - - "cli:claude_code" - "cli:codex" + - "cli:codex_world" net_allowed: [] cmd_allowed: [] @@ -4985,12 +5338,12 @@ metadata: {} "#, ); fixture.write_agent_file( - "claude_code.yaml", - &cli_agent_file("claude_code", "host", true, true, true), + "codex.yaml", + &cli_agent_file("codex", "host", true, true, true), ); fixture.write_agent_file( - "codex.yaml", - &cli_agent_file("codex", "world", true, false, true), + "codex_world.yaml", + &cli_agent_file_with_runtime_family("codex_world", "world", true, false, true, "codex"), ); let output = fixture.run(&["agent", "doctor", "--json"]); diff --git a/crates/shell/tests/agents_validate.rs b/crates/shell/tests/agents_validate.rs index 92d02d32f..1e71eb335 100644 --- a/crates/shell/tests/agents_validate.rs +++ b/crates/shell/tests/agents_validate.rs @@ -79,15 +79,22 @@ impl AgentsValidateFixture { } } -fn valid_cli_agent_file(agent_id: &str, policy_overlay: Option<&str>) -> String { +fn valid_cli_agent_file( + agent_id: &str, + runtime_family: Option<&str>, + policy_overlay: Option<&str>, +) -> String { let overlay = policy_overlay.unwrap_or(""); + let runtime_family = runtime_family + .map(|value| format!(" runtime_family: {value}\n")) + .unwrap_or_default(); if overlay.is_empty() { format!( - "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: world\n cli:\n binary: codex\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" + "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: world\n cli:\n binary: codex\n mode: persistent\n{runtime_family} 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" ) } else { format!( - "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: world\n cli:\n binary: codex\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\npolicy_overlay:\n{overlay}" + "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: world\n cli:\n binary: codex\n mode: persistent\n{runtime_family} 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\npolicy_overlay:\n{overlay}" ) } } @@ -119,6 +126,7 @@ world_fs: "codex.yaml", &valid_cli_agent_file( "codex", + Some("codex"), Some(" world_fs:\n read:\n allow_list:\n - \".\"\n"), ), ); @@ -130,6 +138,24 @@ world_fs: ); } +#[test] +fn agents_validate_accepts_supported_cli_runtime_family_values() { + for (agent_id, runtime_family) in [("codex", "codex"), ("claude_code", "claude_code")] { + let fixture = AgentsValidateFixture::new(); + fixture.init_workspace(); + fixture.write_agent_file( + &format!("{agent_id}.yaml"), + &valid_cli_agent_file(agent_id, Some(runtime_family), None), + ); + + let output = fixture.validate(); + assert!( + output.status.success(), + "supported runtime_family '{runtime_family}' should validate: {output:?}" + ); + } +} + #[test] fn agents_validate_accepts_legacy_inventory_without_protocol_and_session_capabilities() { let fixture = AgentsValidateFixture::new(); @@ -258,6 +284,49 @@ config: ); } +#[test] +fn agents_validate_rejects_invalid_cli_runtime_family_with_exit_2() { + let fixture = AgentsValidateFixture::new(); + fixture.init_workspace(); + fixture.write_agent_file( + "bad_runtime_family.yaml", + r#"version: 1 +id: bad_runtime_family +config: + kind: cli + enabled: true + protocol: substrate.agent.session + execution: + scope: world + cli: + binary: codex + mode: persistent + runtime_family: codex_world + capabilities: + session_start: true + session_resume: true + session_fork: true + session_stop: true + status_snapshot: true + event_stream: true +"#, + ); + + let output = fixture.validate(); + assert_eq!( + output.status.code(), + Some(2), + "invalid runtime_family should exit 2: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("bad_runtime_family.yaml") + && stderr.contains("runtime_family") + && stderr.contains("unknown variant"), + "stderr should mention runtime_family enum validation\nstderr: {stderr}" + ); +} + #[test] fn agents_validate_rejects_filename_id_mismatch_with_exit_2() { let fixture = AgentsValidateFixture::new(); @@ -305,6 +374,7 @@ world_fs: "broaden.yaml", &valid_cli_agent_file( "broaden", + Some("codex"), Some(" world_fs:\n read:\n allow_list:\n - \"/tmp\"\n"), ), ); diff --git a/crates/shell/tests/repl_world_first_routing_v1.rs b/crates/shell/tests/repl_world_first_routing_v1.rs index 6764676da..8c6b5e42b 100644 --- a/crates/shell/tests/repl_world_first_routing_v1.rs +++ b/crates/shell/tests/repl_world_first_routing_v1.rs @@ -335,10 +335,7 @@ agents: .expect("write agent runtime policy"); fs::write( home_substrate.join("agents/codex.yaml"), - format!( - "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\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() - ), + runtime_agent_yaml("codex", "host", fake_codex, "codex"), ) .expect("write codex agent file"); } @@ -349,6 +346,25 @@ fn write_orchestrator_and_world_member_runtime_world_config( fake_orchestrator: &Path, fake_member: &Path, on_drift: &str, +) { + write_orchestrator_and_exact_world_member_runtime_world_config( + home_substrate, + fake_orchestrator, + fake_member, + "codex", + "codex", + on_drift, + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn write_orchestrator_and_exact_world_member_runtime_world_config( + home_substrate: &Path, + fake_orchestrator: &Path, + fake_member: &Path, + member_agent_id: &str, + member_runtime_family: &str, + on_drift: &str, ) { fs::create_dir_all(home_substrate.join("agents")).expect("create agents dir"); let config = format!( @@ -375,23 +391,21 @@ agents: "# ); fs::write(home_substrate.join("config.yaml"), config).expect("write config.yaml"); - write_member_runtime_policy(home_substrate, true); + write_member_runtime_policy_with_member_backend( + home_substrate, + true, + &format!("cli:{member_agent_id}"), + ); fs::write( home_substrate.join("agents/claude_code.yaml"), - format!( - "version: 1\nid: claude_code\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\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_orchestrator.display() - ), + runtime_agent_yaml("claude_code", "host", fake_orchestrator, "claude_code"), ) .expect("write claude_code agent file"); fs::write( - home_substrate.join("agents/codex.yaml"), - format!( - "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: world\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_member.display() - ), + home_substrate.join(format!("agents/{member_agent_id}.yaml")), + runtime_agent_yaml(member_agent_id, "world", fake_member, member_runtime_family), ) - .expect("write codex agent file"); + .unwrap_or_else(|_| panic!("write {member_agent_id} agent file")); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -429,24 +443,27 @@ agents: write_member_runtime_policy(home_substrate, true); fs::write( home_substrate.join("agents/claude_code.yaml"), - format!( - "version: 1\nid: claude_code\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\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_orchestrator.display() - ), + runtime_agent_yaml("claude_code", "host", fake_orchestrator, "claude_code"), ) .expect("write claude_code agent file"); fs::write( home_substrate.join("agents/codex.yaml"), - format!( - "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\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_secondary_host.display() - ), + runtime_agent_yaml("codex", "host", fake_secondary_host, "codex"), ) .expect("write codex agent file"); } #[cfg(any(target_os = "linux", target_os = "macos"))] fn write_member_runtime_policy(home_substrate: &Path, require_world: bool) { + write_member_runtime_policy_with_member_backend(home_substrate, require_world, "cli:codex"); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn write_member_runtime_policy_with_member_backend( + home_substrate: &Path, + require_world: bool, + member_backend_id: &str, +) { fs::create_dir_all(home_substrate).expect("create SUBSTRATE_HOME"); let require_world = if require_world { "true" } else { "false" }; let policy = format!( @@ -473,12 +490,20 @@ metadata: {{}} agents: allowed_backends: - cli:claude_code - - cli:codex + - {member_backend_id} "# ); fs::write(home_substrate.join("policy.yaml"), policy).expect("write policy.yaml"); } +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn runtime_agent_yaml(agent_id: &str, scope: &str, binary: &Path, runtime_family: &str) -> String { + format!( + "version: 1\nid: {agent_id}\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\n execution:\n scope: {scope}\n cli:\n runtime_family: {runtime_family}\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", + binary.display() + ) +} + #[cfg(any(target_os = "linux", target_os = "macos"))] fn write_fake_codex_script(temp: &Path) -> PathBuf { let path = temp.join("fake-codex.sh"); @@ -589,10 +614,7 @@ agents: .expect("write agent runtime policy"); fs::write( home_substrate.join("agents/codex.yaml"), - format!( - "version: 1\nid: codex\nconfig:\n kind: cli\n enabled: true\n protocol: substrate.agent.session\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() - ), + runtime_agent_yaml("codex", "host", fake_codex, "codex"), ) .expect("write codex agent file"); } @@ -2590,6 +2612,166 @@ fn c3_targeted_world_turn_relaunches_exact_backend_after_world_restart() { let (_code, _out) = repl.shutdown_graceful(Duration::from_secs(3)); } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +#[serial] +fn c3_targeted_world_turn_preserves_aliased_exact_backend_identity() { + let temp = temp_dir("substrate-c3-targeted-world-alias-"); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + let substrate_home = home.join(".substrate"); + fs::create_dir_all(&home).expect("create home"); + fs::create_dir_all(&project).expect("create project"); + fs::create_dir_all(&substrate_home).expect("create substrate home"); + write_profile(&project); + let fake_orchestrator = write_fake_claude_script(temp.path()); + let fake_member = write_fake_codex_script(temp.path()); + write_orchestrator_and_exact_world_member_runtime_world_config( + &substrate_home, + &fake_orchestrator, + &fake_member, + "codex_world", + "codex", + "auto_restart", + ); + + let sock_temp = short_socket_dir("sub-c3ws-targeted-world-alias-"); + let sock = sock_temp.path().join("world.sock"); + let server = ReplWorldAgentStub::start_with_member_dispatch_scripts( + &sock, + StreamBehavior::Normal, + vec![MemberDispatchStreamScript::ReadyAndHoldUntilCancel { + session_handle_id: "session-targeted-world-alias".to_string(), + exit_code_on_cancel: 130, + }], + ); + let records = server.records(); + + let mut repl = PtyRepl::spawn(&project, &home, &substrate_home, &sock, &[], &["--world"]); + repl.wait_for_output("Substrate v", Duration::from_secs(6)) + .expect("banner"); + repl.wait_for_prompt(Duration::from_secs(2)) + .expect("initial prompt"); + launch_host_runtime_via_targeted_turn(&mut repl, "cli:claude_code"); + + let orchestration_session_id = load_single_orchestration_session_id(&substrate_home); + + repl.send_line("echo first"); + wait_for_min_records(&records, 1, 1, Duration::from_secs(3)); + wait_for_min_member_dispatch_requests(&records, 1, Duration::from_secs(3)); + repl.wait_for_output("first", Duration::from_secs(3)) + .expect("first command output"); + + let live_participants = authoritative_live_participant_manifests_for_session( + &substrate_home, + &orchestration_session_id, + ); + assert_eq!( + live_participants + .iter() + .map(|manifest| manifest.get("backend_id").and_then(Value::as_str)) + .collect::>(), + vec![Some("cli:claude_code"), Some("cli:codex_world")], + "aliased world startup must preserve exact authoritative-live backend identity" + ); + + let live_members = wait_for_live_world_member_count( + &substrate_home, + &orchestration_session_id, + 1, + Duration::from_secs(5), + ); + let member = &live_members[0]; + let member_participant_id = member + .get("participant_id") + .and_then(Value::as_str) + .expect("member participant_id") + .to_string(); + let member_orchestrator_participant_id = member + .get("orchestrator_participant_id") + .and_then(Value::as_str) + .expect("member orchestrator_participant_id") + .to_string(); + let world_id = member + .get("world_id") + .and_then(Value::as_str) + .expect("member world_id") + .to_string(); + let world_generation = member + .get("world_generation") + .and_then(Value::as_u64) + .expect("member world_generation"); + assert_eq!( + member.get("agent_id").and_then(Value::as_str), + Some("codex_world"), + "persisted member truth must keep the exact aliased agent id" + ); + assert_eq!( + member.get("backend_id").and_then(Value::as_str), + Some("cli:codex_world") + ); + assert_eq!( + member + .pointer("/internal/resolved_agent_kind") + .and_then(Value::as_str), + Some("codex"), + "persisted member truth must keep canonical runtime-family spelling separate from alias identity" + ); + + repl.send_line("::cli:codex_world second"); + wait_for_min_member_turn_submit_requests(&records, 1, Duration::from_secs(3)); + repl.wait_for_output("__MEMBER_TURN_SUBMIT_STUB__ second", Duration::from_secs(3)) + .expect("typed submit route output"); + + let guard = records.lock().expect("lock records"); + assert_eq!( + guard.member_dispatch_requests.len(), + 1, + "targeted follow-up turn must reuse the retained aliased member instead of relaunching it: {guard:#?}" + ); + let member_dispatch = guard + .member_dispatch_requests + .first() + .and_then(|request| request.member_dispatch.as_ref()) + .expect("member dispatch request"); + let submit = guard + .member_turn_submit_requests + .first() + .expect("member turn submit request"); + assert_eq!(submit.orchestration_session_id, orchestration_session_id); + assert_eq!(submit.participant_id, member_participant_id); + assert_eq!( + submit.orchestrator_participant_id, + member_orchestrator_participant_id + ); + assert_eq!(submit.backend_id, "cli:codex_world"); + 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_world", + "retained member dispatch must preserve the exact aliased backend identity" + ); + drop(guard); + + let live_members_after = wait_for_live_world_member_count( + &substrate_home, + &orchestration_session_id, + 1, + Duration::from_secs(3), + ); + assert_eq!( + live_members_after[0] + .get("participant_id") + .and_then(Value::as_str), + Some(member_participant_id.as_str()), + "targeted alias submit must keep one retained world member rather than swap or duplicate it" + ); + + repl.send_line("exit"); + let (_code, _out) = repl.shutdown_graceful(Duration::from_secs(3)); +} + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] #[serial] diff --git a/crates/shell/tests/world_deps_apt_fail_early_wdap1.rs b/crates/shell/tests/world_deps_apt_fail_early_wdap1.rs index ccf088bcb..5a30a101c 100644 --- a/crates/shell/tests/world_deps_apt_fail_early_wdap1.rs +++ b/crates/shell/tests/world_deps_apt_fail_early_wdap1.rs @@ -1,10 +1,9 @@ mod support; +#[cfg(unix)] use std::fs; -use std::path::PathBuf; - #[cfg(unix)] -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(unix)] use std::sync::{Arc, Mutex}; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 50d6b5835..26cd692c1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -40,7 +40,8 @@ Config keys: - `agents.hub.orchestrator_agent_id` selects the canonical host-scoped orchestrator agent for `substrate agent status` and `substrate agent doctor`. - Agent inventory entries continue to define each agent's adapter kind and execution posture; the derived `backend_id` remains `:`. - The shell-owned v1 runtime only realizes selected orchestrators with `config.kind=cli`, `protocol=substrate.agent.session`, and `cli.mode=persistent`. -- The first realized shell-owned UAA backends are `cli:codex` and `cli:claude_code`. Other inventory items may still validate and list successfully, but they are not runtime-realizable on the selected orchestrator path in v1. +- `config.cli.runtime_family` is the inventory-only runtime-realization truth for shell-owned UAA candidates. Supported values are `codex` and `claude_code`. +- Runtime realization still keys policy and exact backend routing off the derived `backend_id`. For example, `cli:codex_world` may realize the canonical `codex` runtime family while remaining a distinct exact backend id from `cli:codex`. - `config.cli.binary` for the selected orchestrator must resolve on the host during `substrate agent doctor` and async REPL bootstrap. Policy keys: @@ -64,6 +65,7 @@ config: execution: scope: host cli: + runtime_family: claude_code binary: claude mode: persistent capabilities: diff --git a/docs/USAGE.md b/docs/USAGE.md index c9a8ca1e3..a1e30a2fb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -106,23 +106,29 @@ The public prompt-taking and control surface is intentionally narrow: ```bash substrate agent start --backend --prompt "hello" --json +substrate agent start --backend --scope world --disable-capability event_stream --prompt "hello" --json substrate agent turn --session --backend --prompt "next" --json substrate agent reattach --session --json substrate agent fork --session --json substrate agent stop --session --json ``` -- `substrate agent start` is the canonical public root prompt-taking surface and remains host-only in v1. +- `substrate agent start` is the canonical public root prompt-taking surface. +- Omitting `--scope` or passing `--scope host` preserves the current host-rooted root-start behavior. +- `substrate agent start --scope world` is the Linux-first public world-start surface in this slice. It creates a host-rooted durable session, persists deferred host-attach truth, launches the retained world member through the shared contract, and reports the pre-attach session posture as `born_unattached`. +- `--disable-capability ` is the canonical public capability-narrowing flag for `start`; `--disable-cap` is the only alias. The supported narrowing family remains `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, and `event_stream`. - `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 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`. +- `substrate agent status --json` is the authoritative live-runtime read surface for `posture`, `attached_participant_id`, and `pending_inbox_count`, including `born_unattached` for never-attached host-rooted world starts. - 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`. +- There is still no default-agent routing and there is still no standalone world-root continuity model. - 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 `. +- Non-Linux `substrate agent start --scope world ...` fails closed with `unsupported_platform_or_posture`. +- Pre-attach world follow-up from a `born_unattached` session fails closed with `unsupported_platform_or_posture`; there is no public sanctioned host-attach surface for that state in this slice. - Detached world follow-up fails closed until `reattach` restores an active host owner. - Durable inbox behavior is intentionally narrow: persistence exists, pending work can normalize posture into `awaiting_attention`, internal ack/dismiss plus dev-support/test ingress exist, and no public inbox command surface or automatic resume-from-inbox workflow is shipped. - `substrate -c`, `--command`, and piped stdin remain shell execution surfaces rather than agent-prompt aliases. diff --git a/llm-last-mile/15-targeted-repl-agent-turns-linux-first.md b/llm-last-mile/15-targeted-repl-agent-turns-linux-first.md index 09288724e..8c3378e12 100644 --- a/llm-last-mile/15-targeted-repl-agent-turns-linux-first.md +++ b/llm-last-mile/15-targeted-repl-agent-turns-linux-first.md @@ -1,6 +1,6 @@ # SOW: Targeted REPL Agent Turns, Linux-First -Status: implementation-oriented draft. This SOW defines the next landing after the Linux-first member-runtime placement and gateway-auth slices. It is intentionally narrow: add one explicit REPL caller grammar for targeted agent turns, route those turns by named `backend_id`, submit a real user prompt into the already-live UAA session for the selected backend, and stream the result back through the REPL. It does not redesign `substrate -c`, and it does not productize a broader `substrate agent start|resume|fork|stop` command family. +Status: implementation-oriented draft. This SOW defines the next landing after the Linux-first member-runtime placement and gateway-auth slices. It is intentionally narrow: add one explicit REPL caller grammar for targeted agent turns, route those turns by named `backend_id`, submit a real user prompt by lazily launching the selected runtime on first targeted use or reusing it when already live, and stream the result back through the REPL. It does not redesign `substrate -c`, and it does not productize a broader `substrate agent start|resume|fork|stop` command family. ## Objective @@ -9,7 +9,7 @@ Land one Linux-first production path where the interactive REPL can: - parse an explicit targeted agent-turn syntax, - resolve that syntax to a named backend such as `cli:codex`, - require routing by `backend_id` instead of "the one eligible member," -- submit the user’s prompt into the already-active retained-control UAA session for the selected runtime, +- submit the user’s prompt by lazily launching the selected runtime on first targeted use or reusing it when already live, - and stream the resulting agent output back through the REPL without collapsing the input into normal shell execution. The preferred syntax decision for this slice is: 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 26915cc25..86f149867 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,32 +1,39 @@ # SOW: Public World-Scoped Agent Start And Capability Flags -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. +Status: draft realigned to host-first product intent. 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 no longer carries host-rooted versus standalone world-root as an open product decision. The validated architecture has already closed that question. ## Frozen Direction -The only valid forward meaning of public `substrate agent start --scope world ...` is: +The thin-slice meaning of public `substrate agent start` is now: 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. +2. submit the inaugural operator prompt to the host orchestration agent, +3. persist the resolved host attach contract under that session, +4. when scope resolves to world, also create or bind the authoritative world session/binding that later host-dispatched world agents will use, +5. keep the host agent as the primary operator-facing control surface. + +Scope meaning is now: + +1. omitting `--scope` resolves requested execution substrate through workspace-local config/profile/policy first, then global config/policy; +2. `--scope world` explicitly requests the world-backed default path; +3. `--scope host` explicitly bypasses world and keeps orchestration plus later dispatch host-scoped. This slice must not reopen standalone world-root continuity. ## Objective -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. +Broaden the public `substrate agent start` surface so a human launches a host orchestration session first, with world-backed execution as the default substrate when scope resolution selects it. This slice is done only when all of the following are true: 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. +2. omitting `--scope` resolves through workspace-local config/profile/policy first, then global config/policy, instead of hardcoding host. +3. `--scope world` means host-rooted orchestration with a world-backed session/binding available for later host-dispatched world work. +4. `--scope host` means bypass world and keep orchestration plus later dispatch host-scoped. +5. the inaugural operator prompt goes to the host orchestration agent, not directly to a first world worker/member. +6. the CLI surface and docs explain the resulting durable session truth clearly. ## What This Slice Assumes Is Already Landed @@ -38,30 +45,11 @@ This slice is done only when all of the following are true: ## What This Slice Leaves To 31 -This slice does not finish lazy host attach behavior. 31 owns: - -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. - -## Open Architectural Split: Born-Unattached Status Truth - -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. - -That remaining architecture split is intentionally divided across 30 and 31: - -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 slice does not finish automatic world-agent dispatch policy or any specialized born-unattached flow. 31 owns any later work on: -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. +1. automatic dispatch or attach triggers beyond the inaugural host-start path, +2. any future explicit world-first/headless path that truly starts unattached, +3. broader pending-work or inbox-driven trigger policy for detached or unattached sessions. ## 29.75 Contract Floor This Slice Must Reuse @@ -70,7 +58,7 @@ Before this public surface is promoted, it must inherit the 29.75 closeout floor 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. +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-dispatch 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. @@ -80,18 +68,20 @@ Before this public surface is promoted, it must inherit the 29.75 closeout floor Required direction: -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. +1. omitting `--scope` resolves requested execution substrate through workspace-local config/profile/policy first, then global config/policy. +2. `--scope world` explicitly requests the world-backed path. +3. `--scope host` explicitly bypasses world. +4. invalid scope/backend combinations fail closed. -### 2. Define the public world-start contract in operator-visible terms +### 2. Define the public host-first start contract in operator-visible terms Required direction: 1. a durable host-rooted orchestration session exists immediately, -2. a world worker/member is launched under that session, +2. the inaugural operator prompt is submitted to the host orchestration agent, 3. the host attach contract is already persisted, -4. no host execution client must be attached yet. +4. when scope resolves to world, authoritative world binding/session truth is established for later host-dispatched world work, +5. the default world-backed path must still feel like starting a host conversation rather than a specialized unattached world-first mode. ### 3. Reuse the same capability flag families exposed by 29 @@ -107,8 +97,8 @@ Required direction: Required direction: 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. +2. `start` does not imply standalone world continuity, +3. this slice does not introduce a second inaugural prompt or immediate world-agent bootstrap conversation. ## Draft Acceptance Shape @@ -117,18 +107,20 @@ This slice should only be promoted out of draft once: 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. +4. the command can create a host-rooted durable session with host-first prompt handling and world-backed default substrate resolution, +5. the public CLI and docs no longer imply standalone world-root as an option or a born-unattached default. ## Draft Validation Targets When this slice is eventually promoted, validation must include: 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. +2. bare root start resolving scope through workspace/global config and policy in the documented order, +3. world-backed root start creating a host-rooted durable session plus world binding, +4. inaugural prompt flowing through the host orchestration path, +5. resolved capability flags matching the 29 contract, +6. truthful host lifecycle/status output for the newly created session, +7. no regression to explicit `--scope host` bypass behavior. ## Sequencing @@ -138,4 +130,4 @@ Current stack status: 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. +5. [31-lazy-host-attach-for-host-rooted-world-start.md](31-lazy-host-attach-for-host-rooted-world-start.md): later follow-on only if the product reopens explicit unattached or automatic-trigger flows. diff --git a/llm-last-mile/CLOSEOUT-30-packet-4-linux-manual-smoke-2026-05-28.md b/llm-last-mile/CLOSEOUT-30-packet-4-linux-manual-smoke-2026-05-28.md new file mode 100644 index 000000000..11a58fc38 --- /dev/null +++ b/llm-last-mile/CLOSEOUT-30-packet-4-linux-manual-smoke-2026-05-28.md @@ -0,0 +1,166 @@ +# CLOSEOUT-30 Packet 4 Linux Manual Smoke (2026-05-28) + +Status: completed. Packet 4 closed on 2026-05-28 after the Linux manual smoke was rerun with valid world-socket access and the full automated validation wall stayed green. + +## Assumptions + +1. Packet 4 closeout must rely on real Linux command evidence, not on a weakened validation contract. +2. Docs-only updates are still the right scope for this pass because the runtime behavior and tests were already in the intended Packet 4 shape. +3. Non-Linux fail-closed behavior remains pinned by the public control suite; this closeout note records the required Linux manual smoke. + +## Baseline Linux Runtime Access + +Commands run: + +1. `target/debug/substrate world doctor --json` +2. `target/debug/substrate host doctor --json` +3. `whoami` +4. `id` +5. `getent group substrate` +6. `sudo target/debug/substrate world doctor --json` +7. `sg substrate -c 'id && target/debug/substrate world doctor --json'` + +Observed outcomes: + +1. The initial blocker was real but local to the current shell credentials, not to the world service itself: + - `id` for the active shell did not include supplementary group `substrate`, + - `/run/substrate.sock` was `root:substrate` with mode `0660`, + - `getent group substrate` showed `azureuser` was enrolled in that group. +2. `sudo target/debug/substrate world doctor --json` succeeded and reported: + - `host.world_socket.probe_ok = true` + - `world.status = "ok"` +3. `sg substrate -c 'id && target/debug/substrate world doctor --json'` also succeeded, which confirmed the blocker was stale supplementary-group membership in the current session rather than runtime failure inside world-service. +4. The rerun commands below therefore used `sg substrate -c ...` so the Linux manual smoke exercised the real world-backed path from a correct authorization context. + +## Host-Scoped Public Root Start Smoke + +Fixture: + +1. root: `/tmp/slice30-host-smoke-final-II5JT1` +2. host-scoped `codex` CLI backend +3. fake persistent binary at `/tmp/slice30-host-smoke-final-II5JT1/fake-codex.sh` +4. toolbox enabled over UDS + +Commands run: + +1. `sg substrate -c "HOME=/tmp/slice30-host-smoke-final-II5JT1/home SUBSTRATE_HOME=/tmp/slice30-host-smoke-final-II5JT1/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent start --backend cli:codex --scope host --prompt 'hello host smoke final' --json"` +2. `sg substrate -c "HOME=/tmp/slice30-host-smoke-final-II5JT1/home SUBSTRATE_HOME=/tmp/slice30-host-smoke-final-II5JT1/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent status --json"` +3. `sg substrate -c "HOME=/tmp/slice30-host-smoke-final-II5JT1/home SUBSTRATE_HOME=/tmp/slice30-host-smoke-final-II5JT1/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent toolbox status --json"` +4. `sg substrate -c "HOME=/tmp/slice30-host-smoke-final-II5JT1/home SUBSTRATE_HOME=/tmp/slice30-host-smoke-final-II5JT1/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent toolbox env --json"` +5. `sg substrate -c "HOME=/tmp/slice30-host-smoke-final-II5JT1/home SUBSTRATE_HOME=/tmp/slice30-host-smoke-final-II5JT1/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent doctor --json"` +6. `sg substrate -c "HOME=/tmp/slice30-host-smoke-final-II5JT1/home SUBSTRATE_HOME=/tmp/slice30-host-smoke-final-II5JT1/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent stop --session 019e6c35-4be2-7ac0-851d-3fdf29c175fd --json"` + +Observed outcomes: + +1. `agent start --scope host` emitted: + - `accepted.scope = "host"` + - `completed.turn_outcome = "success"` + - `completed.session_posture = "active"` +2. The fake agent argv captured `exec`, and stdin captured `hello host smoke final`, so the host-scoped public happy path stayed on the normal host exec startup path. +3. `agent status --json` remained readable and showed the host orchestrator row as: + - `backend_id = "cli:codex"` + - `execution.scope = "host"` + - `posture = "parked_resumable"` +4. `agent toolbox status --json` degraded readably to: + - `eligibility.state = "dependency_unavailable"` + - `reason = "no live host-scoped orchestrator participant found for the selected orchestrator"` +5. `agent toolbox env --json` failed closed with: + - `no live host-scoped orchestrator participant found for the selected orchestrator` +6. `agent doctor --json` stayed healthy and reported all checks passing, including `world_boundary`. + +## Explicit World-Backed Public Root Start Smoke + +Fixture: + +1. root: `/tmp/slice30-world-explicit-live-yM1IIM` +2. host-scoped `codex` orchestrator backend +3. world-scoped `claude_code` backend +4. real Linux world-service socket via the default `/run/substrate.sock` + +Commands run: + +1. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent start --backend cli:claude_code --scope world --prompt 'hello explicit world smoke live' --json"` +2. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent status --json"` +3. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent toolbox status --json"` +4. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent toolbox env --json"` +5. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent doctor --json"` +6. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent turn --session 019e6c2c-f13e-7751-82b0-077cc7e3d75c --backend cli:claude_code --prompt 'next world smoke live' --json"` +7. `sg substrate -c "HOME=/tmp/slice30-world-explicit-live-yM1IIM/home SUBSTRATE_HOME=/tmp/slice30-world-explicit-live-yM1IIM/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent stop --session 019e6c2c-f13e-7751-82b0-077cc7e3d75c --json"` + +Observed outcomes: + +1. `agent start --scope world` emitted: + - `accepted.scope = "world"` + - `accepted.backend_id = "cli:claude_code"` + - `completed.turn_outcome = "success"` + - `completed.session_posture = "active"` +2. The fake orchestrator argv still captured `exec`, and stdin captured `hello explicit world smoke live`, which confirms the inaugural prompt remained host-routed even though the requested public scope was world. +3. The authoritative session truth at `/tmp/slice30-world-explicit-live-yM1IIM/substrate-home/run/agent-hub/sessions/019e6c2c-f13e-7751-82b0-077cc7e3d75c/session.json` persisted: + - `world_id = "wld_019e6c2c-f17b-7092-87dc-ca8edf12d8d5"` + - `world_generation = 0` + - `host_attach_contract.execution_scope = "host"` + - `host_attach_contract.attach_launch_knobs.requested_execution_scope = "host"` +4. `agent status --json` stayed on the normal host lifecycle truth and showed: + - orchestrator `backend_id = "cli:codex"` + - `execution.scope = "host"` + - `posture = "active_attached"` + - `attached_participant_id` present + - no `born_unattached` posture +5. `agent toolbox status --json` succeeded and reported: + - `eligibility.state = "allowed"` + - `active_orchestration_session_id = "019e6c2c-f13e-7751-82b0-077cc7e3d75c"` + - `active_world_binding.world_id = "wld_019e6c2c-f17b-7092-87dc-ca8edf12d8d5"` +6. `agent toolbox env --json` succeeded and emitted: + - `SUBSTRATE_AGENT_TOOLBOX_ENDPOINT` + - `SUBSTRATE_AGENT_TOOLBOX_VERSION = "1"` +7. `agent doctor --json` stayed healthy and reported all checks passing, including `world_boundary`. +8. The later follow-up command failed closed with: + - `backend_not_in_session: orchestration session 019e6c2c-f13e-7751-82b0-077cc7e3d75c has no exact backend slot for cli:claude_code` + This preserves the host-mediated follow-up contract instead of silently bootstrapping a public world-first member path. + +## Omitted-Scope World-Default Smoke + +Fixture: + +1. root: `/tmp/slice30-world-omitted-live-GlRd3e` +2. host-scoped `codex` orchestrator backend +3. unscoped `claude_code` backend +4. workspace override at `/tmp/slice30-world-omitted-live-GlRd3e/workspace/.substrate/workspace.yaml` setting `agents.defaults.execution.scope: world` + +Commands run: + +1. `sg substrate -c "cd /tmp/slice30-world-omitted-live-GlRd3e/workspace && HOME=/tmp/slice30-world-omitted-live-GlRd3e/home SUBSTRATE_HOME=/tmp/slice30-world-omitted-live-GlRd3e/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent start --backend cli:claude_code --prompt 'hello omitted world smoke live' --json"` +2. `sg substrate -c "cd /tmp/slice30-world-omitted-live-GlRd3e/workspace && HOME=/tmp/slice30-world-omitted-live-GlRd3e/home SUBSTRATE_HOME=/tmp/slice30-world-omitted-live-GlRd3e/substrate-home /home/azureuser/__Active_Code/atomize-hq/substrate/target/debug/substrate agent stop --session 019e6c2e-476c-7e10-930c-73f918190770 --json"` + +Observed outcomes: + +1. The omitted-scope start emitted: + - `accepted.scope = "world"` + - `accepted.backend_id = "cli:claude_code"` + - `completed.turn_outcome = "success"` +2. The fake orchestrator argv again captured `exec`, and stdin captured `hello omitted world smoke live`. +3. The authoritative session truth at `/tmp/slice30-world-omitted-live-GlRd3e/substrate-home/run/agent-hub/sessions/019e6c2e-476c-7e10-930c-73f918190770/session.json` persisted: + - `world_id = "wld_019e6c2e-4776-7420-93b6-0de1509c2267"` + - `world_generation = 0` + - `host_attach_contract.execution_scope = "host"` +4. This confirms omitted `--scope` honored the workspace-default preferred scope and still delivered the same host-first world-backed session truth. + +## Automated Validation Wall + +All required automated commands were rerun successfully on 2026-05-28: + +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets -- -D warnings` +3. `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` +4. `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` +5. `cargo test --workspace -- --nocapture` + +## Honest Packet 4 Closeout Status + +Packet 4 is honestly closed on 2026-05-28. + +Why this now clears the slice: + +1. The Linux-first public world-backed happy path now has real command evidence for successful start, persisted host/world truth, readable `agent status`, allowed toolbox surfaces, healthy doctor output, omitted-scope world-default routing, and later host-mediated fail-closed follow-up behavior. +2. The host-scoped public happy path remains unchanged and still shows the readable-status versus fail-closed-control split when no live host-scoped orchestrator participant remains. +3. The public control suites still pin the non-Linux `unsupported_platform_or_posture` fail-closed behavior and the legacy `born_unattached` shape as specialized truth rather than the default public happy path. diff --git a/llm-last-mile/PLAN-30.md b/llm-last-mile/PLAN-30.md index 2a5110a0c..21b60f43b 100644 --- a/llm-last-mile/PLAN-30.md +++ b/llm-last-mile/PLAN-30.md @@ -6,31 +6,34 @@ Adjacent landed slices: [29-shared-agent-dispatch-envelope-and-capability-overri Follow-on slice: [31-lazy-host-attach-for-host-rooted-world-start.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md) Proposed branch: `feat/public-world-scoped-agent-start` Base branch: `main` -Plan type: public caller-surface expansion with Linux-first world-start delivery -Status: planning draft based on approved spec decisions on 2026-05-27 +Plan type: public caller-surface expansion with host-first world-backed delivery +Status: completed on 2026-05-28 after Packet 4 closeout reran the required Linux manual smoke and the full validation wall ## Objective -Ship a truthful public `substrate agent start --scope world ...` surface without reopening the durable authority model. +Ship a truthful public `substrate agent start` surface that starts a host orchestration session first and uses world as the default execution substrate when scope resolution selects it. This slice is complete only when all of the following are true: -1. `substrate agent start` accepts explicit scope selection and keeps host-scoped root start behavior unchanged. -2. `substrate agent start --scope world` creates a host-rooted durable orchestration session, persists authoritative host attach truth at session birth, and launches a world worker/member through the shared dispatch contract. -3. Public dispatch-time capability narrowing is available only through: +1. `substrate agent start` accepts explicit scope selection and bare `start` resolves a preferred default scope through workspace-local config/profile/policy first, then global config/policy, probes for an exact backend in that preferred scope, and falls back once to the alternate scope only if the preferred scope has no exact match. +2. The resolved scope from item 1 is the authoritative scope stamped into the request and reported back to the operator. +3. `substrate agent start --scope world`, or omitted `--scope` that resolves to world, creates a host-rooted durable orchestration session, persists authoritative host attach truth at session birth, and establishes world binding/session truth for later host-dispatched world work before `start` returns. +4. The same successful world-backed start is already truthfully host-attached at return time rather than surfacing a participant-less `born_unattached` success posture. +5. `substrate agent start --scope host` is the explicit bypass-world path. +6. Public dispatch-time capability narrowing is available only through: - `--disable-capability ` - `--disable-cap ` -4. The only supported narrowing targets remain: +7. The only supported narrowing targets remain: - `session_resume` - `session_fork` - `session_stop` - `status_snapshot` - `event_stream` -5. A born-unattached host-rooted world-start session reports the truthful operator-visible status `born_unattached`. -6. Public world-scoped root start is Linux-first in this slice; non-Linux platforms fail closed with explicit posture guidance. -7. Detached-world follow-up remains fail closed until sanctioned host attach occurs. +8. The inaugural operator prompt is handled by the host orchestration agent rather than being sent directly to a first world worker/member. +9. The default world-backed path uses the normal host lifecycle rather than `born_unattached` as the happy-path operator state. +10. Public world-scoped root start is Linux-first in this slice; non-Linux platforms fail closed with explicit posture guidance. -This is productization of an already-validated architecture. It is not a new orchestration model. +This is productization of a host-first orchestration model. It is not a world-first inaugural prompt model. ## Plan Summary @@ -38,24 +41,24 @@ The repo already has the key ingredients: 1. public `agent start` and `agent turn` entrypoints in [`agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs), 2. one shared dispatch-envelope contract in [`dispatch_contract.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs), -3. authoritative persisted host attach truth in [`orchestration_session.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs), -4. Linux world-member follow-up plumbing in [`control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs), -5. integration suites that already pin most public control behavior in [`agent_public_control_surface_v1.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) and [`agent_successor_contract_ahcsitc0.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs). +3. landed Packet-1 caller surface for `--scope`, capability narrowing, and omitted-scope resolution in [`cli.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/cli.rs) and [`agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs), +4. authoritative persisted host attach truth in [`orchestration_session.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs), +5. Linux world binding/session plumbing plus later world-member dispatch seams in [`control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs), +6. integration suites that already pin most public control behavior in [`agent_public_control_surface_v1.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) and [`agent_successor_contract_ahcsitc0.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs). -What is still missing is narrower: +The remaining Packet 4 closeout work was narrower: -1. the public CLI still hardcodes host-only root start, -2. public capability narrowing has no caller-facing syntax for `agent start`, -3. world-scoped root start has no host-rooted session-birth path with deferred host attach, -4. the public status vocabulary has no truthful born-unattached state, -5. docs and tests still describe host-only public root start as the only shipped contract. +1. The slice docs still describe Packet 3-era work as active instead of treating Packets 1-3 as landed floor. +2. The remaining contract to freeze is operator-facing: what `agent status`, toolbox, and doctor must preserve or fail closed under the landed host-first world-backed model. +3. The docs do not yet clearly separate legacy/specialized `born_unattached` semantics from the default public happy path for world-backed root start. +4. The final Linux-first/non-Linux fail-closed wall and honest closeout validation bar are not yet stated tightly enough to make Packet 4 implementation-ready. -The minimum honest implementation is one ordered slice with four workstreams: +The minimum honest implementation was Packet 4 only: -1. freeze the public start input contract in code and tests, -2. deliver host-rooted world-start birth and world-member launch, -3. publish truthful operator status and fail-closed posture, -4. update docs and land the end-to-end validation wall. +1. preserve truthful lifecycle/status semantics for the landed start floor, +2. harden and pin the operator control-surface contract, +3. align the llm-last-mile docs to the shipped Packet-1 through Packet-3 floor, +4. close the slice only behind the final validation wall. ## Locked Starting State @@ -67,29 +70,29 @@ The minimum honest implementation is one ordered slice with four workstreams: | Shared dispatch envelope | [`DispatchRequestEnvelope`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:113) | Reuse exactly. All new public scope/capability behavior must map here. | | Supported narrowing family | [`validate_capability_override_shape(...)`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs:784) | Reuse exactly. Do not broaden the allowed family in this slice. | | Persisted attach truth | [`HostAttachContract`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:72) | Reuse exactly. World-scoped root start must persist this truth at birth. | -| Current host-only root-start guard | [`build_start_launch_plan(...)`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs:1066) | Replace the host-only hardcoding, but keep fail-closed behavior for invalid scope/backend combinations. | -| Public session posture vocabulary | [`PublicSessionPosture`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:103) | Extend carefully. Do not collapse born-unattached into detached continuity semantics. | -| Durable orchestration posture vocabulary | [`OrchestrationSessionPosture`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:69) | Extend or layer carefully to carry `born_unattached` truth without breaking existing detached host semantics. | -| Linux world-member submit path | [`submit_world_prompt_turn(...)`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:1511) | Reuse exactly. Root world start should converge onto the same retained world-member semantics. | -| Existing rejection coverage | [`public_root_start_rejects_world_scoped_backends_in_v1()`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs:3757) | Replace with the new approved world-start contract and preserve equivalent fail-closed coverage where still required. | +| Omitted-scope resolver floor | [`resolve_requested_start_scope(...)`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs:1077) | Freeze the preferred-scope probe plus one alternate-scope fallback as intended Packet-1 behavior. | +| Current world-start planner | [`build_world_start_session_birth_plan(...)`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs:1348) | Packet 2 landed the host-first world-backed start floor; Packet 4 must treat it as frozen. | +| Public session posture vocabulary | [`PublicSessionPosture`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:103) | Preserve current host lifecycle semantics for the thin slice. | +| Durable orchestration posture vocabulary | [`OrchestrationSessionPosture`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs:69) | Reuse current attached/detached host lifecycle truth; do not make `born_unattached` the default happy path. | +| Linux world-member dispatch path | [`submit_world_prompt_turn(...)`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs:1511) | Keep for later host-dispatched world work rather than inaugural prompt handling. | +| World-start integration coverage | [`public_root_start_world_scope_starts_attached_host_session_with_world_binding_truth()`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs:4011) | Packet 2 already landed the host-first success contract; Packet 4 should preserve and extend the validation wall around it. | ### Exact remaining gap -1. Public root-start CLI arguments do not yet carry `scope` or capability narrowing input. -2. `build_start_dispatch_envelope(...)` is still pinned to host execution scope and eager host execution-client startup. -3. There is no launch path that creates a host-rooted durable session for world scope without immediately attaching a host execution client. -4. The runtime does not yet expose `born_unattached` as a truthful public status for never-attached world-start sessions. -5. Docs and integration tests still reflect the old host-only public root-start contract. +1. The repo has landed omitted-scope fallback, host-first world-backed start birth, and authoritative parent world-binding reuse, but the slice docs do not yet treat Packets 1-3 as finished floor. +2. The remaining work is no longer first-time runtime behavior; it is freezing the operator-facing truth for status, toolbox, doctor, and Linux/non-Linux posture under that landed floor. +3. The current docs do not yet say clearly enough that `agent status` may degrade readably while toolbox and doctor fail closed at authoritative parent/world-boundary seams. +4. The final closeout wall still needs an explicit requirement for both automated and manual validation before slice 30 is considered honestly done. ### Scope decision -Proceed as one cohesive slice. +Proceed as one final Packet 4 packet. -Do not split this into separate “CLI flags first,” “runtime birth later,” and “status/doc cleanup last” branches. The contract is only honest when: +Do not reopen Packets 1-3 or split Packet 4 into speculative follow-ons. The closeout is only honest when: -1. parsing, +1. parsing and resolution precedence, 2. runtime behavior, -3. status truth, and +3. host lifecycle/status truth, and 4. docs/tests all converge at the same time. @@ -108,11 +111,13 @@ substrate agent start --backend [--scope host|world] (--prompt `, even when the target session was never previously attached to a host execution client. +3. This bridge slice is manual-only. It does not auto-launch host attach from pending work, inbox state, `turn`, `status`, or world follow-up attempts. +4. `born_unattached` remains the truthful pre-attach status label introduced by slice 30. This slice may define how a session leaves that state, but it does not rename or soften it. +5. Later attach must reuse the persisted `HostAttachContract` as the only durable launch baseline. It may honor that baseline, narrow it where already permitted, and may not synthesize a bootstrap prompt or re-derive launch truth from ambient live participant state. + +## Objective + +Ship the smallest honest follow-on after slice 30: + +1. a host-rooted world-start session born as `born_unattached` can later acquire real host ownership through one sanctioned public control path; +2. that path reuses the already-persisted attach contract truth instead of inventing a second attach object or synthetic prompt; +3. success produces real `active_attached` truth for the same orchestration session; +4. failure remains explicit and fail closed; +5. automatic attach policy remains deferred to the broader slice 31 discussion. + +## Plan Summary + +The repo already has most of the hard prerequisites: + +1. slice 30 can create a truthful `born_unattached` host-rooted session for `agent start --scope world`; +2. the session already persists authoritative `HostAttachContract` truth at birth; +3. public `reattach`, `turn`, `fork`, and `stop` already route through the shared dispatch and persisted-attach contract family; +4. pre-attach world follow-up is already fail closed; +5. slice 31 has already identified continuity-attach versus fresh-attach as the real mode split. + +What is still missing is narrower: + +1. public `reattach` still reflects parked or previously attached host-session recovery semantics more than never-attached fresh attach semantics; +2. there is no frozen attach-worker planner that explicitly selects continuity attach versus fresh attach for a `born_unattached` session; +3. the authoritative status-transition rules after sanctioned manual attach are not yet frozen for `born_unattached` sessions; +4. docs still imply that later host attach is future work rather than a landed public control path. + +The minimum honest bridge implementation is one ordered slice with four workstreams: + +1. freeze the sanctioned manual attach contract, +2. implement explicit attach-mode selection from persisted attach truth, +3. persist truthful lifecycle/status transitions for success and failure, +4. update docs and land the validation wall. + +## Locked Starting State + +### What already exists + +| Sub-problem | Existing code / doc | Decision | +| --- | --- | --- | +| Truthful pre-attach posture | slice 30 landed `born_unattached` status and state invariants | Reuse as the floor. Do not collapse it into parked or detached host semantics. | +| Durable attach baseline | persisted `HostAttachContract` from slices 29 and 29.75 | Reuse exactly. No second durable attach object may be introduced. | +| Public sanctioned recovery verb | `substrate agent reattach --session ...` already exists for session-scoped host ownership recovery | Prefer extending this exact caller surface instead of adding a new public `attach` verb in the bridge slice. | +| World follow-up fail-closed posture | slice 30 already rejects pre-attach world follow-up | Preserve unchanged until host attach actually succeeds. | +| Draft attach-mode architecture | slice 31 already distinguishes continuity attach from fresh attach | Pull this split down into an implementation-ready manual-only contract for 30.25. | + +### Exact remaining gap + +1. There is no approved public control path that can attach a host execution client to a never-attached `born_unattached` session using persisted launch truth. +2. There is no explicit runtime rule for when `reattach` means continuity attach versus fresh attach. +3. There is no frozen post-attach status contract for born-unattached sessions. +4. There is no narrow reviewed bridge between landed slice 30 and the broader draft slice 31. + +## Frozen Execution Contract + +If implementation wants to deviate from this contract, revise this plan first. + +### Public control contract + +The sanctioned public host-attach surface remains: + +```text +substrate agent reattach --session [--json] +``` + +Rules: + +1. `reattach` remains non-prompt-taking and session-scoped. +2. `reattach` may target: + - an already-attached host session only when the existing public recovery rules already allow it, + - a parked or detached host session under the current durable recovery rules, + - a `born_unattached` host-rooted world-start session under the new sanctioned manual attach rules. +3. `reattach` must not create a new orchestration session. +4. `reattach` success for a `born_unattached` session means the same orchestration session becomes truthfully host-attached. +5. `reattach` failure must not manufacture an attached owner, fake continuity, or consume prompt-like work implicitly. + +### Attach-mode contract + +Manual host attach supports exactly two internal modes: + +1. continuity attach + - resume or reconnect through a valid persisted continuity selector when one exists; +2. fresh attach + - start a new host execution client from the persisted `HostAttachContract` baseline when no valid continuity selector exists. + +Rules: + +1. mode selection must be explicit, testable, and auditable; +2. the runtime may not guess from the last participant snapshot if persisted attach truth is already present; +3. neither mode may inject a hidden bootstrap prompt, fake inbox-consumption prompt, or synthetic warm-up turn; +4. both modes must use the persisted effective policy, attach-relevant capabilities, and attach defaults already frozen by slice 29.75. + +### Status and lifecycle contract + +For a session born unattached: + +1. pre-attach truthful status remains `born_unattached`; +2. successful sanctioned manual attach transitions that same session to truthful `active_attached`; +3. failed sanctioned manual attach leaves the session non-terminal and must not pretend continuity or attached ownership exists; +4. `parked_resumable` remains reserved for a session that really had host ownership and then detached cleanly; +5. `awaiting_attention` remains reserved for real durable attention semantics, not generic attach failure theater. + +### Fail-closed contract + +1. Pre-attach world follow-up remains fail closed until host attach actually succeeds. +2. Missing or invalid persisted attach truth remains fail closed with no backfill or repair branch. +3. Unsupported platform or backend combinations remain fail closed. +4. Fresh attach failure must not emit success-looking ownership or status output. + +### Deferred scope + +This bridge slice explicitly does not own: + +1. automatic host attach from durable pending work, +2. auto-trigger policy on `turn`, `status`, or inbox stimuli, +3. broad pending-work taxonomy for born-unattached sessions, +4. a new public `attach` verb, +5. world-to-world continuity that bypasses sanctioned host attach. + +Those remain for a later revision of slice 31 if still desired after this bridge lands. + +## Implementation Order + +### Phase 1: Freeze Manual Attach Contract And Selector Taxonomy + +Goal: + +1. define exactly when `reattach` is allowed for `born_unattached`, +2. pin exact rejection behavior when it is not, +3. freeze continuity-attach versus fresh-attach as the only manual attach modes. + +Why first: + +1. this is the biggest semantic decision in the slice; +2. later runtime work should not guess whether `reattach` is “recover only” or “attach or recover”; +3. it keeps implementation from accidentally widening public control semantics. + +Primary touch surface: + +1. `crates/shell/src/execution/agents_cmd.rs` +2. `crates/shell/src/execution/agent_runtime/state_store.rs` +3. `crates/shell/tests/agent_public_control_surface_v1.rs` + +Verification checkpoint: + +1. parser and public-control tests show `reattach` can select an eligible `born_unattached` session; +2. existing parked-host `reattach` behavior stays green; +3. ineligible sessions still fail closed with explicit reasons. + +### Phase 2: Implement Explicit Manual Attach Planning From Persisted Truth + +Goal: + +1. build one sanctioned attach-worker plan from persisted `HostAttachContract` truth, +2. select continuity attach when valid continuity exists, +3. select fresh attach when continuity does not exist but the persisted baseline is complete. + +Why second: + +1. this is the core runtime seam the repo is still missing; +2. the born-unattached public contract is not honest until fresh attach is real; +3. it isolates the highest-risk runtime path before status/doc work begins. + +Primary touch surface: + +1. `crates/shell/src/execution/agents_cmd.rs` +2. `crates/shell/src/execution/agent_runtime/control.rs` +3. `crates/shell/src/execution/agent_runtime/orchestration_session.rs` +4. `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` + +Verification checkpoint: + +1. a `born_unattached` session can attach successfully through the sanctioned path; +2. planner output is stable about which attach mode was selected; +3. no hidden prompt submission is present in either mode. + +### Phase 3: Persist Truthful Post-Attach And Failure Lifecycle State + +Goal: + +1. transition successful manual attach to truthful attached session state, +2. preserve truthful born-unattached or attention-needed outcomes on failure, +3. keep successor and later-detach semantics aligned with the existing host durable-session model. + +Why third: + +1. status truth should be written against a real working attach path, not a hypothetical one; +2. it separates runtime attach mechanics from the lifecycle projection rules that describe the result; +3. it prevents a partial attach implementation from leaking misleading status states. + +Primary touch surface: + +1. `crates/shell/src/execution/agent_runtime/orchestration_session.rs` +2. `crates/shell/src/execution/agent_runtime/state_store.rs` +3. `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` + +Verification checkpoint: + +1. post-attach status reflects truthful `active_attached` for the same session; +2. attach failure does not produce false parked/detached semantics; +3. later clean detach still reuses the already-landed parked-host lifecycle truth. + +### Phase 4: Docs, Slice Alignment, And Validation Wall + +Goal: + +1. align repo docs with the landed manual attach contract, +2. narrow slice 31 back down to the still-deferred automatic-attach and taxonomy questions, +3. run the final validation wall. + +Why last: + +1. docs should describe the exact contract that actually shipped; +2. slice 31 should only retain the work that 30.25 intentionally leaves behind; +3. the full wall belongs after control, runtime, and status truth all converge. + +Primary touch surface: + +1. `docs/USAGE.md` +2. `llm-last-mile/README.md` +3. `llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md` +4. this plan and any follow-on tasks doc created from it + +Verification checkpoint: + +1. docs explicitly say sanctioned manual host attach is now available; +2. docs still say automatic attach policy is not part of this bridge slice; +3. formatting, clippy, targeted shell suites, and the full workspace wall pass. + +## Risks And Mitigations + +| Risk | Why it matters | Mitigation | +| --- | --- | --- | +| `reattach` meaning becomes too ambiguous | The current public verb historically implies recovery, not first attach | Freeze the bridge assumption explicitly; if reviewers reject that meaning, stop before coding and write a narrower public-contract revision. | +| Fresh attach accidentally becomes synthetic prompt submission | That would violate slices 29.75 and 30 and make operator state dishonest | Add explicit tests that attach planning is non-prompt-taking and does not consume pending work implicitly. | +| Born-unattached status drifts into parked-host vocabulary | Operators would lose the ability to tell never-attached from previously attached sessions | Keep `born_unattached`, `active_attached`, `parked_resumable`, and `awaiting_attention` assertions distinct in status tests. | +| Bridge slice reopens the broad slice 31 debate | Scope expands into policy questions instead of implementation | Defer all automatic trigger policy and broad pending-work taxonomy to the still-draft 31 follow-on. | + +## Parallel Versus Sequential Work + +Sequential: + +1. Phase 1 must land before any runtime attach implementation. +2. Phase 2 must land before status semantics are finalized in Phase 3. +3. Phase 3 must land before docs claim the bridge is shipped. + +Parallelizable within a phase: + +1. test authoring can proceed alongside runtime changes if it stays inside that phase’s frozen contract; +2. doc wording can be drafted during Phase 3 but should not merge until Phase 4 verification is green. + +## Validation Wall + +Targeted contract suites: + +```bash +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture +``` + +Focused runtime/unit coverage: + +```bash +cargo test -p shell reattach -- --nocapture +cargo test -p shell attach_contract -- --nocapture +``` + +Full validation: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace -- --nocapture +``` + +Manual operator validation targets: + +```bash +substrate agent start --backend --scope world --prompt "hello" --json +substrate agent status --json +substrate agent reattach --session --json +substrate agent turn --session --backend --prompt "next" --json +substrate agent stop --session --json +``` + +## Success Criteria + +This bridge slice is complete only when all of the following are true: + +1. a `born_unattached` host-rooted world-start session can later gain real host ownership through one sanctioned public control path; +2. that path reuses persisted `HostAttachContract` truth and chooses either continuity attach or fresh attach explicitly; +3. success keeps the same orchestration session id and results in truthful attached state; +4. failure remains fail closed and non-theatrical; +5. pre-attach world follow-up still fails closed until host attach actually succeeds; +6. no hidden prompt submission or synthetic inbox-consumption attach path exists; +7. docs and tests describe the same shipped contract; +8. the remaining slice 31 draft scope is reduced to the still-unshipped automatic-attach and broader taxonomy questions. + +## Review Gate + +Do not advance this slice to `TASKS` until a human explicitly reviews this plan against [SPEC-30_25.md](./SPEC-30_25.md), especially the still-deferred slice-31 boundaries: + +1. whether automatic host attach from durable pending work should remain deferred, +2. whether any future auto-trigger policy should live only in slice 31, +3. whether `born_unattached` should remain the pre-attach status name with no rename in this bridge. diff --git a/llm-last-mile/PLAN-shell-owned-uaa-runtime-family-alias-support.md b/llm-last-mile/PLAN-shell-owned-uaa-runtime-family-alias-support.md new file mode 100644 index 000000000..fea15ee59 --- /dev/null +++ b/llm-last-mile/PLAN-shell-owned-uaa-runtime-family-alias-support.md @@ -0,0 +1,321 @@ +# Plan: Shell-Owned UAA Runtime Family Alias Support + +Source spec: [SPEC-shell-owned-uaa-runtime-family-alias-support.md](./SPEC-shell-owned-uaa-runtime-family-alias-support.md) +Plan type: architecture-corrective follow-on for shell-owned UAA runtime realization +Status: draft for review +Implementation posture: greenfield contract, no compatibility fallback + +## Objective + +Implement the approved architecture from the spec: + +1. exact backend selection remains keyed on derived `backend_id`, +2. shell-owned UAA runtime family becomes an explicit inventory property at `config.cli.runtime_family`, +3. runtime family is required only for the shell-owned UAA runtime-realizability path, +4. policy and top-level config remain unchanged, +5. canonical persisted/wire runtime-family state remains `codex` / `claude_code`, +6. no literal-`agent_id` fallback remains anywhere in production runtime realization. + +## Plan Summary + +The current bug is produced by one architectural mistake repeated in a few places: + +1. exact backend identity is selected correctly, +2. runtime family is then inferred from literal inventory `agent_id`, +3. persisted readers also assume runtime family can be reconstructed from hard-coded strings, +4. tests and docs encode the same shortcut. + +The correct fix is to separate these concerns cleanly: + +1. inventory owns exact backend identity plus explicit runtime-family declaration, +2. dispatch/validator resolve runtime family from projected inventory, +3. runtime launch and world transport continue to operate on canonical runtime-family enums, +4. persisted state continues to serialize canonical runtime-family names only, +5. tests and docs explicitly declare runtime family where runtime realization is expected. + +Because the contract is greenfield, the plan should not preserve any implicit legacy shape. Instead, it should make missing `config.cli.runtime_family` a fail-closed error for shell-owned UAA runtime candidates and update all runtime-realizable fixtures/examples accordingly. + +## Locked Decisions + +### What changes + +1. Add `config.cli.runtime_family` to agent inventory schema under `AgentCliConfigV1`. +2. Add a typed inventory enum for supported runtime families. +3. Thread projected runtime-family truth through inventory projection into runtime contract construction. +4. Replace `orchestrator_backend_kind(agent_id)` with an explicitly named resolver that consumes runtime-family truth rather than literal `agent_id`. +5. Update runtime-realizable tests and docs so shell-owned UAA entries declare runtime family explicitly. + +### What does not change + +1. No policy key changes. +2. No top-level config changes. +3. No backend-id grammar changes. +4. No host-rooted authority or world-binding contract changes. +5. No canonical persisted/wire runtime-family spelling changes. +6. No heuristic inference from binary, alias naming, or tuple axes. + +## Implementation Order + +### Phase 1: Add Typed Inventory Runtime-Family Schema + +Goal: + +1. introduce a typed inventory field at `config.cli.runtime_family`, +2. keep schema ownership in agent inventory rather than policy or top-level config, +3. make the field available to projected inventory entries without yet changing runtime behavior. + +Primary touch surface: + +1. `crates/shell/src/execution/agent_inventory.rs` +2. `docs/CONFIGURATION.md` +3. agent inventory test fixtures that assert parse/validation behavior + +Recommended shape: + +1. add `runtime_family: Option` to `AgentCliConfigV1`, +2. define `AgentCliRuntimeFamilyV1` as a `snake_case` enum with: + - `Codex` + - `ClaudeCode` +3. add projected inventory carriage for `cli_runtime_family`, +4. keep `deny_unknown_fields` behavior intact so the new field is typed and validated automatically. + +Why first: + +1. it sets the contract truth in the right typed layer, +2. it avoids inventing ad hoc string parsing later in runtime code, +3. it lets later phases fail closed with better errors because the schema exists. + +Verification checkpoint: + +1. inventory parsing accepts valid `runtime_family` values, +2. invalid values fail with the normal schema/serde error posture, +3. policy validation remains untouched, +4. projected inventory can carry `cli_runtime_family` for selected rows. + +Risks: + +1. putting the enum in the wrong module can create awkward layering or duplicate types, +2. adding the field too high in the stack could pollute top-level config ergonomics. + +Mitigation: + +1. keep the schema enum in or adjacent to `agent_inventory.rs`, +2. convert from inventory enum into runtime enum at the runtime-resolution seam rather than the other way around. + +### Phase 2: Replace Agent-Id Runtime Inference With Explicit Runtime-Family Resolution + +Goal: + +1. remove literal-`agent_id` inference from production launch-contract construction, +2. require explicit runtime-family truth for shell-owned UAA runtime-realizable candidates, +3. preserve exact backend-id routing and fail-closed behavior. + +Primary touch surface: + +1. `crates/shell/src/execution/agent_runtime/mapping.rs` +2. `crates/shell/src/execution/agent_runtime/dispatch_contract.rs` +3. `crates/shell/src/execution/agent_runtime/validator.rs` + +Required changes: + +1. replace `orchestrator_backend_kind(agent_id)` with a better-named helper such as: + - `resolve_shell_owned_runtime_backend_kind(...)` + - or equivalent +2. feed that helper explicit projected/inventory runtime-family truth, +3. keep orchestrator-only validation semantics separate from general runtime-family resolution, +4. add a fail-closed error when a shell-owned UAA runtime candidate lacks `config.cli.runtime_family`. + +Why second: + +1. this is the real bug fix, +2. it restores architectural honesty at the selection-to-realization seam, +3. later persistence/test updates should build on the corrected production contract. + +Verification checkpoint: + +1. `validate_runtime_realizability(...)` succeeds for explicit host/runtime entries with declared runtime family, +2. `validate_member_selection(...)` succeeds for `cli:codex_world` with `runtime_family: codex`, +3. exact backend selection still rejects wrong scope/protocol/capability rows fail-closed, +4. missing `runtime_family` on a shell-owned UAA candidate fails with explicit contract wording. + +Risks: + +1. conflating orchestrator validation with general runtime-family resolution again, +2. accidentally requiring `runtime_family` for unrelated CLI rows. + +Mitigation: + +1. scope the new requirement inside the shell-owned UAA runtime-realizability checks only, +2. keep the existing `kind/protocol/cli.mode` gates as the entry criteria for that requirement. + +### Phase 3: Align Persistence, REPL Parity, and Runtime Readers + +Goal: + +1. ensure the corrected runtime-family contract is consistent with persisted session truth, +2. preserve canonical `resolved_agent_kind` serialization, +3. eliminate any remaining implicit coupling between alias identity and runtime family in runtime readers. + +Primary touch surface: + +1. `crates/shell/src/execution/agent_runtime/orchestration_session.rs` +2. `crates/shell/src/repl/async_repl.rs` +3. `crates/shell/src/execution/agent_runtime/control.rs` +4. possibly `crates/shell/src/execution/agent_runtime/session.rs` + +Required changes: + +1. keep canonical writes of runtime family as `codex` / `claude_code`, +2. ensure readers continue to interpret those values as runtime family, not agent alias, +3. update any reconstructors that still conceptually blur runtime family and inventory identity, +4. avoid changing wire enums or persisted spelling unless a concrete blocker forces it. + +Why third: + +1. production selection must be correct before persistence parity can be audited meaningfully, +2. this phase is about consistency and retained-session safety, not initial resolution. + +Verification checkpoint: + +1. persisted manifests for `codex_world` keep `agent_id = codex_world`, `backend_id = cli:codex_world`, `resolved_agent_kind = codex`, +2. retained-member reconstruction continues to work for aliased exact backends, +3. host attach truth and world member truth stay architecturally distinct. + +Risks: + +1. accidentally changing canonical persisted spellings, +2. widening the fix into a wire compatibility migration. + +Mitigation: + +1. preserve canonical runtime-family enum strings, +2. keep wire/persistence changes minimal and local to readers if possible. + +### Phase 4: Test Fixture Conversion, Regression Coverage, and Docs + +Goal: + +1. convert runtime-realizable fixtures to the explicit runtime-family contract, +2. add alias-specific regression coverage, +3. align docs with the new greenfield contract. + +Primary touch surface: + +1. `crates/shell/tests/agent_public_control_surface_v1.rs` +2. `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` +3. `crates/shell/tests/repl_world_first_routing_v1.rs` +4. nearby unit tests in `validator.rs`, `async_repl.rs`, `state_store.rs` +5. `docs/CONFIGURATION.md` +6. slice docs in `llm-last-mile/` as needed + +Required coverage: + +1. host orchestrator `codex` plus world alias `codex_world`, +2. doctor/runtime-realizability success for explicit alias entries, +3. exact targeted world turns for `::cli:codex_world`, +4. retained-member reuse/relaunch preserving exact backend identity, +5. fail-closed missing-field behavior. + +Why last: + +1. tests should lock the final contract, not a half-finished intermediate state, +2. docs should describe the exact shipped contract. + +Verification checkpoint: + +1. all runtime-realizable test helpers explicitly declare `runtime_family`, +2. zero remaining production reliance on literal `agent_id` for runtime-family resolution, +3. docs explain that supported shell-owned runtime families are explicit inventory declarations, while policy still keys on exact backend ids. + +Risks: + +1. because no compatibility path exists, the test-fixture churn may be broad, +2. doc language may accidentally drift into policy/config overreach. + +Mitigation: + +1. treat fixture conversion as an explicit phase, not incidental cleanup, +2. keep doc wording disciplined: inventory field for realization, policy for exact backend selectors. + +## Sequencing and Parallelism + +### Must stay sequential + +1. Phase 1 before Phase 2 because runtime resolution needs typed inventory truth first. +2. Phase 2 before Phase 3 because persistence/parity should reflect the corrected runtime contract. +3. Phase 2 before most integration test updates because the intended behavior has to exist before alias regressions can pass. + +### Can be parallelized later + +1. unit-test updates in `validator.rs` and `agent_inventory.rs` can proceed in parallel once the schema shape is stable, +2. integration fixture conversion can be split by suite, +3. doc updates can proceed in parallel with late-stage testing once wording is frozen. + +## Verification Wall + +Minimum verification before calling the work complete: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p shell agent_runtime::validator -- --nocapture +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture +cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture +``` + +Recommended focused smoke after tests: + +```bash +substrate agent doctor --json +substrate agent start --backend cli:codex --scope host --prompt "host ok" --json +substrate agent start --backend cli:codex_world --scope world --prompt "world ok" --json +substrate agent turn --session --backend cli:codex_world --prompt "next" --json +``` + +## Major Risks and Mitigations + +### Risk 1: Field placed in the wrong schema layer + +If `runtime_family` lands in top-level config or policy, the contract will fight the repo's typed ergonomics. + +Mitigation: + +1. keep it only in `config.cli`, +2. keep policy unchanged, +3. document the separation explicitly. + +### Risk 2: Runtime-family requirement leaks too broadly + +If the field becomes mandatory for all CLI rows, unrelated inventory becomes noisy and semantically misleading. + +Mitigation: + +1. gate the requirement inside the shell-owned UAA runtime-realizability path only, +2. preserve ordinary inventory parse success for non-UAA CLI rows. + +### Risk 3: Persistence contract accidentally changes + +If the fix changes canonical `resolved_agent_kind` spellings, retained-session and wire behavior could drift. + +Mitigation: + +1. preserve canonical runtime-family serialization, +2. change only how runtime family is sourced before launch, not how it is stored after launch. + +### Risk 4: Greenfield stance causes underestimated fixture churn + +Because there is no compatibility fallback, every runtime-realizable fixture that currently relies on implicit `codex` / `claude_code` behavior must be updated. + +Mitigation: + +1. inventory-search the suites first, +2. convert shared helper writers before chasing individual failing tests, +3. keep fixture conversion as a named workstream. + +## Review Questions + +1. Is the inventory-only field placement acceptable as the final contract shape? +2. Is the planned requirement scope narrow enough: only `kind=cli` + `protocol=substrate.agent.session` + effective `cli.mode=persistent`? +3. Is the greenfield stance acceptable even though it broadens test/doc churn by removing any fallback? + diff --git a/llm-last-mile/README.md b/llm-last-mile/README.md index 18a34f98f..daa393139 100644 --- a/llm-last-mile/README.md +++ b/llm-last-mile/README.md @@ -85,7 +85,7 @@ The original packet stops before public control-plane productization. The follow - [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. + - Landed Linux-first public world-start slice: `substrate agent start --scope world` now keeps durable authority host-rooted, supports public capability narrowing through `--disable-capability` / `--disable-cap`, surfaces `born_unattached` before any host attach occurs, and keeps pre-attach world follow-up fail closed. - [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. diff --git a/llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md b/llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md index 0af304931..6c6889057 100644 --- a/llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md +++ b/llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md @@ -3,29 +3,145 @@ Source SOW: [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) Decomposition basis: feature-slice breakdown produced on 2026-05-27 Phase: `SPECIFY` -Status: reviewed decisions accepted on 2026-05-27; ready to advance to `PLAN` +Status: completed on 2026-05-28 after Packet 4 closeout reran the required Linux manual smoke and the full validation wall ## Assumptions These are the assumptions I am making so the spec stays concrete. Correct any of them before moving to planning. -1. Omitting `--scope` must preserve current behavior, which means public `agent start` still defaults to host-scoped start. +1. Omitting `--scope` should resolve a preferred execution scope through workspace-local config/profile/policy first, then global config/policy, probe for an exact backend match in that preferred scope, and fall back once to the alternate scope only when the preferred scope has no exact match. 2. Public capability controls use `--disable-capability ` as the primary flag and `--disable-cap ` as the alias, with values restricted to the already-landed narrowing family from slice 29.75: `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, and `event_stream`. 3. Public world-scoped root start is explicitly Linux-first for this slice; non-Linux behavior must fail closed with explicit guidance. -4. Born-unattached host-rooted world start uses the distinct operator-visible label `born_unattached` and must not reuse `parked_resumable` or `detached_reattachable` when that would imply prior attach history. -5. This slice may change CLI parsing, runtime session state, status projection, and docs, but it must not change the durable authority model validated by slices 28.5, 29, and 29.75. +4. `--scope host` is the explicit bypass-world path: orchestration starts on the host and later dispatch stays host-scoped unless a later slice reopens that behavior. +5. The thin slice should treat world scope as the default execution substrate behind a host session, not as “run the first visible prompt directly in a world agent before the host session is attached,” and the inaugural prompt should therefore remain strictly host-routed. +6. Packet 4 may still tighten status/control-surface behavior, docs, and validation coverage, but it must not change the durable authority model or runtime start floor validated by slices 28.5, 29, 29.75, and landed Packets 1-3 of slice 30. + +## Observed Repo Floor + +The current repo already freezes the Packet-1 through Packet-3 behavior that this spec now treats as the starting floor: + +1. Omitted `--scope` resolves the effective default scope, probes for an exact backend match in that preferred scope, falls back once to the alternate scope if needed, and stamps the resolved scope into `DispatchRequestEnvelope`. +2. Public world-scoped root start now uses the host-first attached runtime model: + a host-rooted orchestration session is launched through the hidden owner-helper path, the inaugural prompt is host-routed, authoritative world session/binding truth is established before `start` returns, and the successful session remains `active_attached` rather than `born_unattached`. +3. That same Packet-2 floor already persists top-level `world_id` and `world_generation` on the orchestration session as the durable projection of the authoritative world session/binding truth established at start. +4. Later world-member launch logic already contains fail-closed checks that require authoritative parent world binding truth and reject missing or mismatched binding before member launch. +5. The public status/control surfaces already distinguish readable degradation from fail-closed control boundaries: `agent status` may stay readable with warnings, while toolbox and doctor surfaces fail closed when authoritative parent or world-boundary proof is unavailable. +6. Packet 4 should therefore treat items 1-5 as landed floor and freeze only the remaining operator-facing truth, control-surface hardening, docs alignment, and validation wall. ## Objective -Build a public `substrate agent start --scope world ...` surface that launches a world-scoped worker under a host-rooted durable orchestration session, persists authoritative host attach truth at session birth, and avoids eager host execution-client startup. +Build a public `substrate agent start` surface that launches a host-rooted durable orchestration session first, persists authoritative host attach truth at session birth, and treats world scope as the default execution substrate the host agent later uses for dispatched world work. Primary operator story: -1. The operator selects `--scope host` or `--scope world` explicitly. -2. `--scope host` preserves the current root-start behavior. -3. `--scope world` creates a host-rooted durable session, persists the resolved host attach contract, launches a world worker through the shared dispatch contract, and leaves host attach deferred. -4. Before any sanctioned host attach occurs, status and follow-up surfaces must tell the truth about the session without implying active host ownership or prior attach continuity. +1. The operator may omit `--scope`, or select `--scope host` / `--scope world` explicitly. +2. Omitting `--scope` resolves a preferred execution scope through workspace-local config/profile/policy first, then global config/policy, and falls back once to the alternate scope only if the preferred scope has no exact backend match. +3. `--scope host` explicitly bypasses world. +4. `--scope world` explicitly requests the default world-backed path while still starting a host-rooted attached orchestration session. +5. The inaugural operator prompt is fulfilled by the host orchestration agent; later world work is dispatched by that host agent under the established world session/binding. + +## Landed Packet 2 Floor + +### Omitted `--scope` Resolution + +1. Omitted `--scope` first resolves the preferred default scope from workspace-local config/profile/policy, then global config/policy. +2. The runtime then probes for an exact backend match in that preferred scope. +3. If the preferred scope has no exact backend match, the runtime probes the alternate scope exactly once. +4. If the alternate scope matches, that resolved scope becomes the authoritative scope for the request, the persisted launch truth, and operator-visible `scope` output. +5. If neither scope has an exact backend match, `start` fails closed with `unknown_backend`. +6. This alternate-scope fallback is intended slice-30 product behavior for Packet 2; changing it later requires an explicit contract update rather than a silent runtime tweak. + +### What “World-Backed Host Session” Means At `agent start` Time + +For `--scope world`, or omitted `--scope` that resolves to world, a successful `substrate agent start` must mean all of the following before the command returns: + +1. A durable host-rooted orchestration session already exists. +2. That session is already in the normal host-attached lifecycle, not `born_unattached`. +3. Authoritative `HostAttachContract` truth is persisted at birth. +4. Authoritative world session/binding truth needed for later host-dispatched world work is already persisted. +5. The host session is the primary operator-facing thing that was launched; world is the execution substrate behind it. + +### Immediate Truth Versus Lazy Truth + +Truth that must exist immediately at successful start: + +1. The orchestration session record. +2. An attached host owner/participant suitable for the normal public host lifecycle. +3. Persisted host attach truth. +4. Persisted authoritative world session/binding truth, including the durable world identity needed for later dispatch. + +Truth that may remain lazy in this thin slice: + +1. The first host-decided world dispatch after the inaugural prompt. +2. Any later world worker/member conversation created because the host chooses world work. +3. Any broader automatic dispatch or attach policy beyond the root-start path itself. + +### Inaugural Prompt Routing + +1. The inaugural prompt is strictly host-routed in slice 30. +2. Public `agent start` must not submit the inaugural operator prompt directly to a first world worker/member. +3. If the host later dispatches work into world, that is subsequent host behavior, not the meaning of public root start. + +### Deferred Beyond Packet 2 + +This spec intentionally leaves the following outside Packet 2 and outside the thin-slice contract: + +1. Any explicit world-first or `born_unattached` public start mode. +2. Any manual-attach or automatic-attach bridge work associated with parked `30.25` or later slice 31 follow-ons. +3. Automatic world-dispatch policy, pending-work triggers, or inbox-driven attach behavior. +4. Capability broadening beyond the already-supported narrowing-only family. +5. Non-Linux parity for public world-backed root start. + +## Landed Packet 3 Floor + +### Canonical World Identity Reuse + +1. The `world_id` and `world_generation` persisted by Packet 2 are already the canonical durable projection of the authoritative world session/binding truth for that orchestration session. +2. Packet 3 must treat that persisted parent binding as the single source of truth for later host-decided world work. +3. Later world-member launch must reuse the same authoritative parent binding rather than minting an unrelated or detached world identity. +4. If the authoritative parent binding is missing or does not match the active world session, later world-member launch must fail closed. + +### Later World Work Must Stay Lazy + +1. Packet 3 must not introduce an eager first world-member conversation at public `start` return. +2. Packet 3 must not introduce automatic world dispatch from pending work, inbox activity, or other background triggers. +3. The first world worker/member conversation may still remain lazy until the host actually chooses world work. + +### Lifecycle Guardrails + +1. The default public world-backed path remains the normal host-attached lifecycle rather than `born_unattached`. +2. Packet 3 may preserve specialized `born_unattached` status semantics for older or specialized sessions, but it must not reintroduce that posture as the thin-slice happy path. +3. Packet 3 is runtime/readiness floor only; broader operator-facing status hardening remains Packet 4 work. + +## Frozen Packet 4 Contract + +### Operator-Facing Lifecycle And Status Truth + +1. The default public world-backed happy path remains the normal host-attached lifecycle from the first successful `start` return. +2. `agent status` must continue to project that happy path as attached host truth rather than reviving `born_unattached` as the default slice-30 success posture. +3. Existing `active_attached`, `parked_resumable`, and `awaiting_attention` semantics remain valid and must not be repurposed by Packet 4. +4. `born_unattached` may still appear for specialized or legacy sessions that genuinely persist that posture, but Packet 4 must not describe or test it as the default world-backed start path. + +### Control-Surface Hardening + +1. `agent status` remains a readable projection surface: when authoritative parent/session linkage is incomplete, it may degrade with warnings rather than fail closed. +2. `agent toolbox status` remains a fail-closed control surface for active-session authorization: it must prefer authoritative live parent/session manifests over trace history and may expose `active_world_binding` only when the live parent session carries both `world_id` and `world_generation`. +3. `agent toolbox env` must continue to fail closed when no authoritative live orchestrator session is available, even if historical trace events suggest otherwise. +4. `agent doctor` must continue to fail closed at orchestrator selection, runtime realizability, policy allowlist, and required world-boundary checks instead of implying partial readiness. + +### Linux-First And Non-Linux Fail-Closed Expectations + +1. Linux remains the only supported public happy path for `agent start --scope world` in slice 30. +2. Non-Linux `--scope world` root start must remain explicit fail-closed behavior with `unsupported_platform_or_posture` guidance rather than a degraded “best effort” mode. +3. Packet 4 must preserve the distinction between supported Linux world-backed start and specialized/legacy postures that may still surface elsewhere in status output. + +### Final Validation Wall + +1. Slice 30 cannot close honestly until `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture`, `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture`, and `cargo test --workspace -- --nocapture` are green. +2. Required Linux manual smoke must record the actual commands and operator-visible outcomes for host-scoped start stability, successful world-backed start truth, `agent status`, `agent toolbox status`, `agent toolbox env`, `agent doctor`, omitted-scope world-default routing, and later host-mediated world dispatch. +3. If any required Linux manual smoke step cannot run because of environment or runtime limitations, Packet 4 remains open and the exact command, blocker, and unmet acceptance items must be recorded without relaxing the closeout bar. +4. The public control suites must continue to pin the non-Linux `unsupported_platform_or_posture` fail-closed posture for public world-backed root start. ## Tech Stack @@ -35,7 +151,7 @@ Primary operator story: - Shared dispatch contract: [`crates/shell/src/execution/agent_runtime/dispatch_contract.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs) - Durable session truth: [`crates/shell/src/execution/agent_runtime/orchestration_session.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs), [`crates/shell/src/execution/agent_runtime/state_store.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) - World member follow-up and dispatch plumbing: [`crates/shell/src/execution/agent_runtime/control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs), [`crates/shell/src/execution/routing/dispatch/`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/routing/dispatch) -- Operator docs: [`docs/USAGE.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md), [`llm-last-mile/README.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/README.md) +- Slice planning docs: [`llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md), [`llm-last-mile/PLAN-30.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/PLAN-30.md), [`llm-last-mile/TASKS-30.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/TASKS-30.md) ## Commands @@ -77,7 +193,6 @@ substrate agent start --backend --prompt "hello" --json substrate agent start --backend --scope world --prompt "hello" --json substrate agent start --backend --scope world --disable-capability event_stream --json substrate agent status --json -substrate agent reattach --session --json substrate agent turn --session --backend --prompt "next" --json substrate agent doctor --json ``` @@ -97,15 +212,13 @@ This feature is expected to touch these areas: - `crates/shell/src/execution/agent_runtime/state_store.rs` - Public session posture classification and live-session control/status selection - `crates/shell/src/execution/agent_runtime/control.rs` - - Helper launch plans, prompt streaming envelopes, and Linux world-member submit behavior + - Helper launch plans, prompt streaming envelopes, authoritative world binding persistence, and Linux world-member dispatch behavior - `crates/shell/tests/agent_public_control_surface_v1.rs` - End-to-end public CLI control-plane regression coverage - `crates/shell/tests/agent_successor_contract_ahcsitc0.rs` - Status / doctor / contract regression coverage -- `docs/USAGE.md` - - Operator-facing contract source for public CLI behavior - `llm-last-mile/` - - Planning and scope documents that must stay aligned with shipped behavior + - Planning and scope documents that become the Packet-4 source of truth in this narrow pass ## Code Style @@ -149,37 +262,43 @@ Test levels for this feature: 1. Unit tests in `dispatch_contract.rs` - Validate `--scope` mapping, supported capability override families, narrowing-only behavior, policy denial, and persisted-attach constraints. 2. Integration tests in `agent_public_control_surface_v1.rs` - - Validate host start parity, world-scoped root start success or fail-closed posture, deferred host attach, world member launch, and public follow-up behavior. + - Validate omitted-scope preferred-scope resolution plus alternate-scope fallback, host-scoped bypass behavior, host-first world-backed start success, authoritative world session/binding truth at start, and public follow-up behavior. 3. Integration tests in `agent_successor_contract_ahcsitc0.rs` - - Validate status / doctor JSON truth for born-unattached host-rooted sessions and preserve current host vs world projection contracts. + - Validate authoritative world identity/status truth for later world work and preserve current parked / awaiting-attention projection contracts. 4. Manual smoke checks - - Validate the exact operator story for `start`, `status`, `reattach`, and `turn`. + - Validate the exact operator story for `start`, `status`, `toolbox`, `doctor`, `reattach`, and `turn`. Coverage expectations: - Every new public flag must have at least one positive parser/behavior test and one negative fail-closed test. -- Every new status/posture state must have command-level JSON assertions. -- Existing host-only public root-start behavior must keep regression coverage so this slice cannot silently break it. -- `born_unattached` must have explicit status-surface coverage distinct from detached continuity coverage. +- Every new public resolution rule must have command-level assertions. +- Existing host lifecycle semantics (`active_attached`, `parked_resumable`, `awaiting_attention`) must keep regression coverage so this slice cannot silently break them. +- World-backed start must prove host-first prompt handling plus authoritative world session/binding setup without depending on a born-unattached default posture. +- `agent status` readable degradation, toolbox fail-closed authorization, and doctor fail-closed readiness checks must all have explicit regression coverage because Packet 4 freezes those surfaces as distinct operator contracts. ## Boundaries - Always: - Reuse the shared dispatch contract from slice 29 instead of adding a CLI-only launch dialect. + - Treat the Packet-1 omitted-scope fallback behavior as frozen unless the spec is deliberately changed. - Keep durable authority host-rooted for all `--scope world` starts. - Persist authoritative `HostAttachContract` truth at session birth. + - Treat the landed Packet-2 host-first world-start success shape as floor rather than reopening it. + - Treat the inaugural operator prompt as a host-orchestrator concern, even when scope resolves to world. + - Treat Packet-2 `world_id` and `world_generation` persistence plus landed Packet-3 reuse/fail-closed behavior as the canonical parent binding floor rather than first-time work to be rediscovered. + - Treat `agent status` as a readable degradation surface, but toolbox and doctor as fail-closed control surfaces at authoritative parent/world-boundary seams. - Fail closed on unsupported scope/backend combinations and unsupported capability overrides. - Update docs and tests together with runtime behavior. - Ask first: - Introducing any new dependency. - - Changing the public JSON shape beyond what this feature requires for truthful born-unattached status. + - Changing the public JSON shape beyond what this feature requires for truthful host lifecycle or scope-resolution reporting. - Broadening capability overrides beyond the five narrowing-only fields. - Relaxing the current Linux-first world-member behavior or changing cross-platform rollout promises. - Never: - Reopen standalone world-root continuity. - Reintroduce backfill/repair logic for missing persisted attach truth. - - Treat born-unattached sessions as `parked_resumable` or `detached_reattachable` if that implies prior attach history. - - Start a host execution client eagerly just to manufacture ownership theater for `--scope world`. + - Treat the thin slice as a two-prompt “host plus world bootstrap” product. + - Route the inaugural public prompt directly into a first world agent while describing the feature as host-first orchestration. - Add a second public follow-up dialect for world workers. ## Success Criteria @@ -187,15 +306,21 @@ Coverage expectations: The feature is done only when all of the following are true: 1. `substrate agent start` accepts explicit scope selection through one shared dispatch-envelope flow. -2. `substrate agent start --scope host` preserves the current host-rooted root-start behavior. -3. `substrate agent start --scope world` creates a host-rooted durable orchestration session, persists authoritative host attach truth at birth, launches a world worker/member under that session, and does not eagerly start a host execution client. -4. Public capability flags, if present, only affect the already-supported narrowing family: +2. Omitting `--scope` resolves the preferred default scope through workspace-local config/profile/policy first, then global config/policy, probes for an exact backend in that preferred scope, and falls back once to the alternate scope only if needed. +3. `substrate agent start --scope host` explicitly bypasses world and preserves host-rooted root-start behavior. +4. The resolved scope from step 2 is stamped into the request and is the authoritative scope reported back to the operator. +5. `substrate agent start --scope world`, or omitted `--scope` that resolves to world, creates a host-rooted durable orchestration session, persists authoritative host attach truth at birth, and establishes authoritative world session/binding truth for later host-dispatched world work before `start` returns. +6. The same successful world-backed start is already truthfully host-attached at return time and does not use a participant-less `born_unattached` success posture. +7. The `world_id` and `world_generation` persisted at start are treated as the canonical durable projection of that authoritative world session/binding truth, and later host-decided world work reuses that same authoritative parent binding with fail-closed mismatch handling. +8. The inaugural operator prompt is handled by the host orchestration agent rather than being sent directly to a first world worker/member. +9. Public capability flags, if present, only affect the already-supported narrowing family: `session_resume`, `session_fork`, `session_stop`, `status_snapshot`, and `event_stream`, exposed as `--disable-capability ` with `--disable-cap ` as the alias. -5. Unsupported capability fields such as `session_start`, `llm`, and `mcp_client` remain fail closed. -6. A born-unattached host-rooted world-start session has the truthful non-terminal operator-visible status `born_unattached`, which does not imply active host attachment or prior attach history. -7. Public world-scoped root start is supported only on Linux for this slice; non-Linux platforms fail closed with explicit posture guidance. -8. Detached-world follow-up remains fail closed until sanctioned host attach occurs. -9. `docs/USAGE.md`, the slice doc, and integration tests all describe the same operator contract. +10. Unsupported capability fields such as `session_start`, `llm`, and `mcp_client` remain fail closed. +11. `agent status` remains readable when parent/session linkage is degraded, while `agent toolbox` and `agent doctor` preserve fail-closed control-surface behavior at authoritative parent/world-boundary seams. +12. `agent toolbox status` only surfaces `active_world_binding` when the authoritative live parent session carries both `world_id` and `world_generation`; missing binding proof is non-fatal for status but not a license to infer one. +13. The default public world-backed path uses the normal host-attached lifecycle and does not require `born_unattached` as the operator-facing happy-path posture. +14. Public world-scoped root start is supported only on Linux for this slice; non-Linux platforms fail closed with explicit posture guidance. +15. The llm-last-mile slice docs and validation expectations become sufficient to implement and close Packet 4 without reopening the landed Packet-1 through Packet-3 floor. ## Resolved Decisions @@ -203,9 +328,19 @@ These review decisions are now frozen for this spec: 1. Public capability narrowing uses `--disable-capability ` with `--disable-cap ` as the alias. 2. There is no single-letter short flag for capability narrowing. -3. The born-unattached operator-visible status label is `born_unattached`. -4. Public world-scoped root start is Linux-first for this slice. -5. Omitting `--scope` preserves the current host-scoped default behavior. +3. Public world-scoped root start is Linux-first for this slice. +4. Omitting `--scope` resolves the preferred default scope through workspace-local config/profile/policy first, then global config/policy, and falls back once to the alternate scope if the preferred scope has no exact backend match. +5. The resolved scope after that probe/fallback sequence is the authoritative scope for the request and operator-visible output. +6. `--scope host` is the explicit bypass-world path. +7. `born_unattached` is not the default thin-slice happy-path posture. +8. Packet 2 requires immediate host-session truth plus persisted world binding truth, but it does not require an eager first world-worker conversation at `start` return. +9. Packet 4 preserves readable `agent status` degradation while keeping toolbox and doctor fail closed at authoritative parent/world-boundary seams. + +## Open Questions + +1. This spec freezes resolved `scope` as operator-visible truth, but it does not require a second public field exposing the preferred-versus-fallback provenance. If Packet 4 needs that extra reporting, it should be called out explicitly during implementation rather than inferred. +2. This spec requires authoritative world binding truth before `start` returns, but it does not freeze one specific internal mechanism for proving world readiness beyond that durable contract. +3. This spec freezes the operator-facing rule that `agent status` may degrade readably while toolbox and doctor fail closed, but it does not require Packet 4 to invent new user-facing labels beyond the current warning/reason strings already exercised by the regression suites. ## Review Gate diff --git a/llm-last-mile/SPEC-30_25.md b/llm-last-mile/SPEC-30_25.md new file mode 100644 index 000000000..02879ff0c --- /dev/null +++ b/llm-last-mile/SPEC-30_25.md @@ -0,0 +1,122 @@ +# Spec: Slice 30.25 Manual Host Attach Contract For Born-Unattached World Start + +Source gap context: [30-public-world-scoped-agent-start-and-capability-flags.md](./30-public-world-scoped-agent-start-and-capability-flags.md), [SPEC-30-public-world-scoped-agent-start-and-capability-flags.md](./SPEC-30-public-world-scoped-agent-start-and-capability-flags.md), [PLAN-30.md](./PLAN-30.md), [TASKS-30.md](./TASKS-30.md), [31-lazy-host-attach-for-host-rooted-world-start.md](./31-lazy-host-attach-for-host-rooted-world-start.md) +Phase: `SPECIFY` +Status: parked on 2026-05-27 after slice-30 was realigned to a host-first default path + +## Parking Note + +This spec is no longer on the immediate implementation path. + +The updated slice-30 intent treats world-backed start as a host-first session with world as the default execution substrate behind that host session. That makes `born_unattached` no longer the thin-slice happy path, so the manual-attach bridge described below is parked unless a later slice deliberately reintroduces an explicit world-first or unattached public start mode. + +## Assumptions + +These are the assumptions this narrow spec relies on. If any are wrong, stop before tasking and revise the spec rather than guessing in code. + +1. Slice 30 is already the source of truth for host-rooted authority, persisted host-attach truth at session birth, `born_unattached`, and pre-attach fail-closed world follow-up. +2. The current slice-30 docs and [docs/USAGE.md](../docs/USAGE.md) still describe `substrate agent reattach --session ...` as attached-owner recovery only and explicitly say there is no sanctioned host-attach surface for a `born_unattached` session in slice 30. +3. This spec is therefore freezing a new bridge-slice contract for `30.25`; it is not restating already-shipped slice-30 behavior. +4. The missing decision before tasking is the public manual trigger/verb contract. Automatic attach from pending work and broader attach trigger policy remain outside this slice unless reopened explicitly. + +## Objective + +Freeze the smallest missing contract needed before slice `30.25` can be tasked: + +1. sanction one public manual host-attach path for an eligible `born_unattached` host-rooted world-start session; +2. make clear that `30.25` is manual-only; +3. state exactly what remains deferred to later slice-31 work. + +## Tech Stack + +- Documentation source of truth: `llm-last-mile/*.md`, `docs/USAGE.md` +- Public control surface to be updated later from this spec: + - `crates/shell/src/execution/cli.rs` + - `crates/shell/src/execution/agents_cmd.rs` +- Durable session truth used by the later implementation: + - `crates/shell/src/execution/agent_runtime/orchestration_session.rs` + - `crates/shell/src/execution/agent_runtime/state_store.rs` + +## Commands + +Manual operator flow this spec governs: + +```bash +substrate agent start --backend --scope world --prompt "hello" --json +substrate agent status --json +substrate agent reattach --session --json +``` + +Targeted validation commands for the later implementation: + +```bash +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture +``` + +## Project Structure + +- `llm-last-mile/SPEC-30_25.md` + - narrow source of truth for the manual attach contract +- `llm-last-mile/PLAN-30_25.md` + - implementation plan derived from this spec +- `llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md` + - follow-on slice that retains automatic attach policy and broader trigger semantics +- `docs/USAGE.md` + - public operator wording that must eventually match this contract + +## Code Style + +Use exact public CLI vocabulary and explicit fail-closed wording. The contract shape this spec freezes is: + +```text +substrate agent reattach --session [--json] +``` + +Conventions: + +- `reattach` remains session-scoped and non-prompt-taking. +- `born_unattached` remains the truthful pre-attach posture name in `30.25`. +- spec and docs must distinguish "manual attach is now sanctioned in 30.25" from "automatic attach remains deferred." + +## Testing Strategy + +This spec itself is satisfied by document alignment, but later implementation work should prove: + +1. a `born_unattached` session can be manually host-attached through `reattach` without allocating a new orchestration session; +2. pre-attach world follow-up remains fail closed until that manual attach actually succeeds; +3. `30.25` does not auto-launch attach from pending work, inbox state, `turn`, or `status`; +4. repo docs stop claiming that `born_unattached` has no sanctioned manual attach path once `30.25` lands. + +## Boundaries + +- Always: + - Treat `substrate agent reattach --session ` as the sanctioned manual host-attach path for eligible `born_unattached` sessions in slice `30.25`. + - Keep `30.25` manual-only. + - Preserve the same orchestration session id and reuse persisted `HostAttachContract` truth. + - Preserve slice-30 pre-attach fail-closed world follow-up until manual attach succeeds. +- Ask first: + - Adding a new public `attach` verb or alias for this slice. + - Allowing any automatic attach trigger from pending work, `turn`, `status`, or inbox state. + - Renaming `born_unattached` or collapsing it into parked/detached vocabulary. +- Never: + - Rewrite slice 30 as if this manual attach surface was already shipped there. + - Let `30.25` decide automatic attach policy for the product as a whole. + - Introduce synthetic prompt submission, hidden warm-up turns, or inbox-consumption theater as the meaning of attach. + +## Success Criteria + +This spec is complete only when it makes the following contract explicit: + +1. `substrate agent reattach --session ` is the sanctioned manual host-attach path for eligible `born_unattached` host-rooted world-start sessions in slice `30.25`. +2. Slice `30.25` is manual-only. It does not auto-launch host attach from pending work or any other trigger. +3. The following remain deferred to later slice-31 work: + - whether automatic host attach from durable pending work is allowed at all; + - which stimuli, if any, may trigger automatic attach (`turn`, `status`, inbox state, or other pending-work surfaces); + - broader born-unattached attach trigger policy and lifecycle taxonomy beyond this single sanctioned manual entrypoint. +4. `PLAN-30_25.md` consumes the verb/manual-only decision as frozen spec truth rather than as an unreviewed assumption. + +## Open Questions + +1. This spec intentionally extends earlier repo wording that treated `reattach` as recovery-only. If reviewers reject that extension, `30.25` should be replanned rather than silently widened in implementation. +2. No additional automatic-attach decision is frozen here. That remains owned by slice 31. diff --git a/llm-last-mile/SPEC-shell-owned-uaa-runtime-family-alias-support.md b/llm-last-mile/SPEC-shell-owned-uaa-runtime-family-alias-support.md new file mode 100644 index 000000000..b3402f181 --- /dev/null +++ b/llm-last-mile/SPEC-shell-owned-uaa-runtime-family-alias-support.md @@ -0,0 +1,295 @@ +# Spec: Shell-Owned UAA Runtime Family Alias Support + +## Assumptions + +ASSUMPTIONS I'M MAKING: +1. The real user-facing goal is to support exact backend aliases such as `cli:codex_world` without weakening the existing exact-`backend_id` routing and policy model. +2. The correct fix should preserve the current host-rooted world-start architecture from slices 28.5, 29, 29.75, 30, and the draft 31 direction. +3. The current bug is not just a bad error message; it is an architectural mismatch where runtime family is being inferred from literal inventory `agent_id`. +4. This is greenfield for the intended contract, so no compatibility fallback or migration posture should shape the design. +5. We should not infer runtime family from binary path, backend-id naming conventions, provider tuple, or other heuristics. +6. The preferred solution is to make shell-owned UAA runtime family an explicit required property of CLI inventory entries used by the shell-owned runtime-realizability path. + +If any of these are wrong, correct them before planning or implementation. + +## Objective + +Fix shell-owned UAA runtime realization so Substrate can support aliased exact backends such as `cli:codex_world` and `cli:claude_code_world` honestly, without conflating: + +1. selected backend identity, +2. inventory agent identity, +3. runtime family / adapter family, +4. host versus world execution role. + +The correct architecture is: + +1. Exact selection remains keyed by derived `backend_id` in `:` form. +2. Runtime family is a separate resolved property that tells the shell-owned runtime which supported family to instantiate after exact selection succeeds. +3. The shell-owned runtime family for CLI-backed persistent agent-session entries is declared explicitly in inventory, not guessed from literal `agent_id`. +4. Persisted and wire-visible runtime-family state remains canonical as `codex` or `claude_code`; aliases remain exact backend identity only. +5. All shell-owned UAA inventory entries must declare runtime family explicitly; there is no legacy agent-id fallback path. + +Primary operator story: + +1. A host-scoped orchestrator entry may remain `id: codex`, `backend_id: cli:codex`. +2. A separate world-scoped entry may be `id: codex_world`, `backend_id: cli:codex_world`. +3. Both entries may point at the same underlying binary. +4. Both entries may declare the same runtime family, for example `runtime_family: codex`. +5. Exact routing, policy allowlisting, status, and persisted session truth continue to preserve `cli:codex` versus `cli:codex_world` as distinct backends. + +## Architectural Decision + +### New source of truth + +Introduce an explicit required CLI inventory field for shell-owned runtime-family resolution: + +```yaml +version: 1 +id: codex_world +config: + kind: cli + protocol: substrate.agent.session + execution: + scope: world + cli: + binary: codex + mode: persistent + runtime_family: codex + capabilities: + session_start: true + session_resume: true + session_fork: true + session_stop: true + status_snapshot: true + event_stream: true + llm: true + mcp_client: false +``` + +`runtime_family` is the explicit shell-owned UAA adapter/runtime family. Initial supported values: + +1. `codex` +2. `claude_code` + +### Config and policy alignment + +`runtime_family` must live in agent inventory at `config.cli.runtime_family`. + +It must not be added to: + +1. top-level Substrate config in `config_model.rs`, +2. policy surfaces in `policy_model.rs`, +3. backend allowlist or tuple-constraint policy families. + +Rationale: + +1. The repo's typed architecture already treats agent inventory as the home for backend-specific runtime realization details. +2. Policy is keyed on exact `backend_id` selectors such as `cli:codex_world`, and that should remain unchanged. +3. `runtime_family` is not a routing or authorization axis; it is a post-selection realization detail for the shell-owned UAA runtime. +4. Keeping it in `config.cli` matches current ergonomics because `binary` and `mode` already live there as CLI runtime-realization fields. + +### Requirement scope + +`config.cli.runtime_family` is required only when a selected inventory entry is attempting to enter the shell-owned UAA runtime-realizability path. + +Concretely, that means the selected entry has: + +1. `config.kind = cli` +2. `protocol = substrate.agent.session` +3. effective `cli.mode = persistent` + +It is not required for unrelated CLI entries outside that runtime path. + +### Why this is the correct fix + +This design preserves the repo's current architecture: + +1. `backend_id` remains the exact selector and policy token. +2. `agent_id` remains the inventory identity and operator-facing name component. +3. `runtime_family` becomes the shell-owned runtime realization input. +4. `binary` remains a runtime prerequisite, not the selector or identity source. +5. The field lands in the same typed inventory object that already owns CLI runtime details, preserving existing shape and ergonomics. + +This matches the stable contract surfaces: + +1. `backend_id` is an adapter selector only, not an overloaded identity label. +2. exact backend selection remains fail-closed, +3. host/world role split remains separate from runtime family, +4. persisted attach truth and world binding truth remain authoritative. + +### Rejected alternatives + +Do not: + +1. infer runtime family from literal `agent_id`, +2. infer runtime family from `backend_id` naming patterns such as `_world`, +3. infer runtime family from `config.cli.binary`, +4. infer runtime family from provider/router/protocol tuple fields, +5. widen support to all `kind=cli` entries without an explicit runtime-family contract. +6. add a temporary fallback that maps literal `agent_id` values like `codex` or `claude_code` to runtime family. +7. add `runtime_family` to policy or top-level config instead of inventory. + +Those options are either brittle, violate existing backend-selection architecture, or hide new product semantics inside heuristics. + +## Commands + +Build: + +```bash +cargo build --workspace +``` + +Format: + +```bash +cargo fmt --all -- --check +``` + +Lint: + +```bash +cargo clippy --workspace --all-targets -- -D warnings +``` + +Targeted tests: + +```bash +cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture +cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture +cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture +cargo test -p shell agent_runtime::validator -- --nocapture +``` + +Focused local smoke once implemented: + +```bash +substrate agent doctor --json +substrate agent start --backend cli:codex --scope host --prompt "host ok" --json +substrate agent start --backend cli:codex_world --scope world --prompt "world ok" --json +substrate agent turn --session --backend cli:codex_world --prompt "next" --json +``` + +## Project Structure + +Relevant implementation seams: + +```text +crates/shell/src/execution/agent_inventory.rs + Inventory schema and derived backend identity. + +crates/shell/src/execution/agent_runtime/mapping.rs + Runtime-family enum and family-resolution helpers. + +crates/shell/src/execution/agent_runtime/dispatch_contract.rs + Exact backend selection and resolved launch contract construction. + +crates/shell/src/execution/agent_runtime/validator.rs + Doctor/runtime-realizability checks for orchestrator and member selection. + +crates/shell/src/execution/agent_runtime/control.rs + Canonical resolved runtime descriptor and runtime-family serialization boundary. + +crates/shell/src/execution/agent_runtime/orchestration_session.rs + HostAttachContract persistence/reconstruction. + +crates/shell/src/repl/async_repl.rs + World-member bootstrap parity and retained-member reconstruction. + +crates/shell/src/execution/prompt_fulfillment.rs + Runtime-family to concrete runtime/client dispatch. + +docs/CONFIGURATION.md + Public configuration and runtime-realizability contract. + +llm-last-mile/ + Slice/spec alignment docs for the fix. +``` + +## Code Style + +Use explicit, typed resolution helpers instead of stringly typed identity checks. + +Preferred style: + +```rust +fn resolve_shell_owned_runtime_family( + entry: &ProjectedInventoryEntryV1, +) -> Result { + entry.cli_runtime_family.ok_or_else(|| { + anyhow::anyhow!( + "selected runtime '{}' is not runtime-realizable because config.cli.runtime_family is required for shell-owned UAA runtimes", + entry.agent_id + ) + }) +} +``` + +Conventions: + +1. Prefer `runtime_family` for config/schema naming. +2. Reserve `backend_id` for exact selector identity. +3. Reserve `agent_id` for inventory identity. +4. Keep canonical persisted runtime-family strings as `codex` and `claude_code`. +5. Fail closed with explicit contract-language errors when runtime-family truth is missing or unsupported. + +## Testing Strategy + +Frameworks: + +1. Rust unit tests inline with source modules. +2. Shell integration suites in `crates/shell/tests/`. + +Coverage expectations: + +1. Unit coverage for runtime-family resolution from inventory config, including fail-closed rejection when the field is missing. +2. Unit coverage for exact backend selection preserving `agent_id` / `backend_id` while resolving shared runtime family. +3. Integration coverage for host orchestrator `codex` plus world member `codex_world`. +4. Regression coverage for doctor, status, REPL retained-member reuse, and targeted world turns. +5. No change to canonical persisted/wire runtime-family spellings unless a compatibility migration is explicitly designed and tested. + +Required regression cases: + +1. `validate_member_selection(...)` returns `backend_kind = Codex` for `id: codex_world` with `cli.runtime_family: codex`. +2. `substrate agent doctor --json` succeeds with host orchestrator `codex` and world runtime alias `codex_world`. +3. `substrate agent start --backend cli:codex_world --scope world ...` selects the exact world backend and keeps canonical host/world truth. +4. `::cli:codex_world` follow-up reuses or relaunches the exact retained world backend rather than collapsing to `cli:codex`. +5. Persisted session manifests keep `resolved_agent_kind = "codex"` while preserving `backend_id = "cli:codex_world"` and `agent_id = "codex_world"`. + +## Boundaries + +- Always: + - Keep exact backend selection keyed on `backend_id`. + - Keep runtime-family serialization canonical as `codex` / `claude_code`. + - Preserve fail-closed behavior when runtime-family truth is missing, invalid, or unsupported. + - Preserve host-rooted authority and host-first inaugural prompt routing. + - Preserve policy semantics keyed on exact `backend_id` only. + +- Ask first: + - Renaming persisted fields like `resolved_agent_kind`. + - Changing wire enums in `world-api`. + - Broadening shell-owned runtime support beyond `codex` and `claude_code`. + - Introducing a different config field name than `config.cli.runtime_family`. + - Promoting `runtime_family` into policy or top-level config. + +- Never: + - Infer runtime family from binary path or alias naming conventions. + - Collapse distinct exact backends into a shared backend identity. + - Change allowlist semantics away from exact `backend_id`. + - Reopen slice-30 architecture to make world runtime the durable authority. + +## Success Criteria + +This work is successful only when all of the following are true: + +1. An aliased CLI inventory entry such as `id: codex_world` can be runtime-realizable when it explicitly declares `config.cli.runtime_family: codex`. +2. Any shell-owned UAA inventory entry missing `config.cli.runtime_family` fails closed as not runtime-realizable. +3. Exact backend selection, policy allowlisting, status, and session truth continue to distinguish `cli:codex` from `cli:codex_world`. +4. The shell-owned runtime family is no longer inferred from literal inventory `agent_id` in production launch-contract resolution. +5. Canonical persisted/wire runtime-family values remain `codex` and `claude_code`; aliases do not leak into runtime-family serialization. +6. Doctor, start, retained-member reuse, and targeted world-turn flows all have regression coverage for the alias topology. +7. Policy schemas and allowlist semantics remain unchanged and continue to key only on exact `backend_id`. +8. Public config docs describe shell-owned runtime realizability in terms of explicit runtime family plus exact backend identity, not literal supported backend ids. + +## Open Questions + +1. Should the public config doc say “supported shell-owned runtime families are `codex` and `claude_code`,” or should it frame them as “supported `config.cli.runtime_family` values”? diff --git a/llm-last-mile/TASKS-30.md b/llm-last-mile/TASKS-30.md index 48a57b665..d644ac561 100644 --- a/llm-last-mile/TASKS-30.md +++ b/llm-last-mile/TASKS-30.md @@ -5,44 +5,46 @@ Source spec: [SPEC-30-public-world-scoped-agent-start-and-capability-flags.md](/ Source plan: [PLAN-30.md](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/PLAN-30.md) Phase: `TASKS` Execution model: four separate `/incremental-implementation` sessions -Status: ready for implementation +Status: Packets 1-4 landed; slice 30 closed on 2026-05-28 after Packet 4 reran the required Linux manual smoke and the full validation wall ## Execution Packets -This slice should be implemented as four separate `/incremental-implementation` sessions. +This slice was planned as four separate `/incremental-implementation` sessions, but Packets 1-3 are already landed in code and now serve as the frozen floor for Packet 4. -- Packet 1 implements Phase 1 only. -- Packet 2 implements Phase 2 only. -- Packet 3 implements Phase 3 only. -- Packet 4 implements Phase 4 only. +- Packet 1 is landed and should not be reopened unless the contract changes. +- Packet 2 is landed and should not be reopened unless the contract changes. +- Packet 3 is landed and should not be reopened unless the contract changes. +- Packet 4 is landed; no active implementation packets remain for slice 30. -Do not start a later packet until the prior packet’s checkpoint is green. +Treat the Packet 4 checkpoint as green repo floor for this slice. Slice 30 is now closed against the landed Packet 1-4 floor. -## Packet 1: Public Input Contract And Resolver Wiring +## Packet 1: Landed Public Input Contract And Resolver Wiring + +Packet 1 is already landed in code. These tasks remain here only as frozen context for Packet 4 review. Session goal: 1. add the approved public start flags, -2. map them into the shared dispatch-envelope contract, +2. map them plus omitted-scope resolution precedence into the shared dispatch-envelope contract, 3. pin fail-closed behavior for unsupported capability families. ### Tasks -- [ ] Task 1.1: Add the public `agent start` CLI surface for `--scope`, `--disable-capability`, and `--disable-cap` - - Acceptance: `AgentStartArgs` accepts `--scope host|world`; `--disable-capability ` is repeatable; `--disable-cap ` is the only alias; unsupported capability names fail at parse time; omitting `--scope` preserves the host default. +- [x] Task 1.1: Add the public `agent start` CLI surface for `--scope`, `--disable-capability`, and `--disable-cap` + - Acceptance: `AgentStartArgs` accepts `--scope host|world`; `--disable-capability ` is repeatable; `--disable-cap ` is the only alias; unsupported capability names fail at parse time; omitting `--scope` no longer hardcodes host. - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` - Files: - [`crates/shell/src/execution/cli.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/cli.rs) - [`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) -- [ ] Task 1.2: Map the new start flags into one shared dispatch-envelope builder - - Acceptance: public `agent start` builds `DispatchRequestEnvelope` from CLI inputs instead of hardcoded host-only defaults; supported disable flags map to narrowing-only capability overrides; host default behavior stays unchanged. +- [x] Task 1.2: Map scope inputs and omitted-scope resolution into one shared dispatch-envelope builder + - Acceptance: public `agent start` builds `DispatchRequestEnvelope` from CLI inputs and resolved scope instead of hardcoded host-only defaults; omitted `--scope` resolves the preferred default scope, probes that scope first, falls back once to the alternate scope if needed, and stamps the resolved scope into the envelope; supported disable flags map to narrowing-only capability overrides; explicit `--scope host` bypass behavior stays unchanged. - Verify: `cargo test -p shell dispatch_contract -- --nocapture` - Files: - [`crates/shell/src/execution/agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) - [`crates/shell/src/execution/agent_runtime/dispatch_contract.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/dispatch_contract.rs) -- [ ] Task 1.3: Pin fail-closed capability rejection for unsupported agent-level fields +- [x] Task 1.3: Pin fail-closed capability rejection for unsupported agent-level fields - Acceptance: public start rejects attempts to set or imply dispatch-time overrides for `session_start`, `llm`, and `mcp_client`; tests assert stable rejection classifiers and reasons. - Verify: `cargo test -p shell dispatch_contract -- --nocapture` - Files: @@ -54,31 +56,34 @@ Session goal: Packet 1 is complete only when: 1. the public CLI accepts the approved flags, -2. the shared dispatch envelope receives the new inputs, +2. the shared dispatch envelope receives the new inputs and resolved-scope truth, 3. unsupported capability families still fail closed, -4. host-default root-start behavior is unchanged. +4. explicit `--scope host` behavior is unchanged. + +Packet 4 should assume this checkpoint is already green. -Do not start Packet 2 until Packet 1 verification is green. +## Packet 2: Host-First Start Birth And World Session Setup -## Packet 2: Host-Rooted World-Start Session Birth +Packet 2 is already landed in code. These tasks remain here only as frozen context for Packet 4 review. Session goal: -1. preserve current host-scoped root start, -2. add scope-aware root-start planning, -3. create host-rooted world-start session birth with deferred host attach. +1. preserve explicit host-scoped root start, +2. preserve the landed omitted-scope resolution contract, +3. replace the deferred-host-attach world-start success shape, +4. create host-first world-backed session birth. ### Tasks -- [ ] Task 2.1: Refactor root-start planning so `--scope host` and omitted scope preserve current behavior - - Acceptance: host-scoped root start still resolves through the existing public prompt path, keeps eager host execution-client startup where already required, and existing host-scoped regressions remain green after the new flag surface lands. +- [x] Task 2.1: Refactor root-start planning so omitted scope resolves through config/policy and `--scope host` remains the bypass path + - Acceptance: explicit `--scope host` still resolves through the existing public host prompt path; omitted `--scope` preserves the landed preferred-scope probe plus one alternate-scope fallback; the resolved scope remains authoritative for the request and operator-visible output; host-scoped regressions remain green after the new runtime work lands. - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` - Files: - [`crates/shell/src/execution/agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) - [`crates/shell/src/execution/agent_runtime/control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs) -- [ ] Task 2.2: Implement host-rooted world-start session birth with deferred host attach - - Acceptance: `agent start --scope world` creates a durable host-rooted orchestration session, persists authoritative `HostAttachContract` truth at birth, and records deferred host execution-client startup rather than manufacturing an attached host owner. +- [x] Task 2.2: Implement host-first world-backed session birth + - Acceptance: `agent start --scope world`, or omitted `--scope` that resolves to world, no longer returns the old deferred-host-attach `WorldBirth` / `born_unattached` success shape; it creates a durable host-rooted orchestration session that is already truthfully host-attached at return time, persists authoritative `HostAttachContract` truth at birth, establishes authoritative world session/binding truth before `start` returns, and routes the inaugural operator prompt through the host orchestration agent instead of a first world-worker conversation. - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` - Files: - [`crates/shell/src/execution/agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) @@ -90,55 +95,64 @@ Session goal: Packet 2 is complete only when: 1. host-scoped root start remains compatible, -2. world-scoped root start creates a host-rooted durable session, +2. world-backed root start creates a host-rooted attached durable session rather than a `born_unattached` success posture, 3. authoritative host attach truth is persisted at birth, -4. host execution-client startup is deferred for the world-scoped path. +4. authoritative world session/binding truth is established for the world-backed path before `start` returns. -Do not start Packet 3 until Packet 2 verification is green. +Treat the Packet 2 checkpoint as green repo floor for this pass. -## Packet 3: World-Member Launch And Binding Persistence +## Packet 3: Canonical World Identity Reuse And Lazy Dispatch Readiness Session goal: -1. launch the world worker/member under the new host-rooted session, -2. persist authoritative world binding, -3. reuse existing world runtime semantics rather than inventing a start-only dialect. +1. treat Packet 2's persisted `world_id` and `world_generation` as the canonical durable projection of authoritative world session/binding truth, +2. require later host-decided world work to reuse that authoritative parent binding, +3. keep later world-worker allocation lazy until host orchestration chooses world work, +4. avoid inventing a world-first inaugural prompt dialect or reopening the Packet 2 start contract. ### Tasks -- [ ] Task 3.1: Launch the world worker under the new session and persist authoritative world binding - - Acceptance: Linux world-scoped root start launches the world member/worker through existing runtime plumbing, persists `world_id` and `world_generation`, and does not invent a second start-only world launch dialect. - - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` +- [x] Task 3.1: Reuse authoritative parent world binding for later world-member launch + - Acceptance: later host-decided world-member launch treats the Packet-2 `world_id` and `world_generation` as the canonical parent binding for the orchestration session, reuses that same authoritative binding for member launch, and fails closed when the authoritative parent binding is missing or mismatched against the active world session. + - Verify: `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` - Files: - [`crates/shell/src/execution/agent_runtime/control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs) - [`crates/shell/src/execution/agent_runtime/session.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/session.rs) - [`crates/shell/src/execution/agent_runtime/state_store.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) - [`crates/shell/src/execution/routing/dispatch/world_persistent_session.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/routing/dispatch/world_persistent_session.rs) +- [x] Task 3.2: Keep later world work lazy and preserve the Packet 2 host-first floor + - Acceptance: Packet 3 does not introduce an eager first world-member conversation at public `start` return, does not revive `born_unattached` as the default happy path, and keeps later world work opt-in from host orchestration rather than background-triggered. + - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - Files: + - [`crates/shell/src/execution/agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) + - [`crates/shell/src/execution/agent_runtime/control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs) + - [`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) + ### Packet 3 Checkpoint -Packet 3 is complete only when: +Packet 3 is treated as complete for this slice’s remaining work when: -1. Linux world-scoped root start succeeds end to end, -2. authoritative `world_id` and `world_generation` are persisted, -3. the implementation still reuses the shared-contract world runtime path. +1. later host-decided world work reuses the authoritative parent world binding established by Packet 2, +2. missing or mismatched authoritative world binding truth fails closed, +3. no eager world-member conversation or revived `born_unattached` default is introduced while wiring this readiness path. -Do not start Packet 4 until Packet 3 verification is green. +Packet 4 should treat this checkpoint as landed floor and only freeze the remaining operator-facing closeout contract. ## Packet 4: Status Truth, Control Hardening, Docs, And Final Validation Session goal: -1. expose `born_unattached`, -2. preserve detached host semantics, -3. pin Linux-first and pre-attach fail-closed behavior, +1. preserve truthful host lifecycle semantics, +2. preserve the readable-status versus fail-closed-control split across `agent status`, toolbox, and doctor, +3. pin Linux-first and scope-resolution behavior, 4. align docs with shipped behavior, 5. run the full validation wall. ### Tasks -- [ ] Task 4.1: Add truthful `born_unattached` status and preserve existing detached host semantics - - Acceptance: a never-attached host-rooted world-start session surfaces `born_unattached`; existing `parked_resumable`, `awaiting_attention`, and detached continuity semantics for previously attached host sessions remain unchanged; status JSON and human-readable output use the frozen vocabulary. +- [x] Task 4.1: Preserve truthful host lifecycle/status semantics for the world-backed default path + - Acceptance: world-backed root start uses the normal host lifecycle as the operator-facing happy path; existing `active_attached`, `parked_resumable`, and `awaiting_attention` semantics remain unchanged; `born_unattached` may remain valid for specialized or legacy sessions but no test or doc treats it as the default slice-30 success posture. - Verify: `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` - Files: - [`crates/shell/src/execution/agent_runtime/control.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs) @@ -146,24 +160,30 @@ Session goal: - [`crates/shell/src/execution/agent_runtime/state_store.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) - [`crates/shell/tests/agent_successor_contract_ahcsitc0.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs) -- [ ] Task 4.2: Pin Linux-first and pre-attach fail-closed behavior in the public control suite - - Acceptance: Linux world-scoped root start succeeds under the new contract; non-Linux world-scoped root start fails closed with `unsupported_platform_or_posture`; pre-attach world follow-up remains fail closed until sanctioned host attach; obsolete host-only world-start rejection assertions are replaced with the new contract wall. +- [x] Task 4.2: Preserve and harden the Packet-4 operator control-surface contract + - Acceptance: `agent status` remains readable and may degrade with warnings when authoritative parent/session linkage is incomplete; toolbox surfaces continue to fail closed for active-session authorization and prefer authoritative live parent/session manifests over trace history; `agent toolbox status` only surfaces `active_world_binding` when the live parent session carries both `world_id` and `world_generation`; `agent doctor` continues to fail closed at orchestrator selection, runtime realizability, policy allowlist, and required world-boundary checks. + - Verify: `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - Files: + - [`crates/shell/src/execution/agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) + - [`crates/shell/tests/agent_successor_contract_ahcsitc0.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs) + +- [x] Task 4.3: Pin Linux-first world-backed start and scope-resolution behavior in the public control suite + - Acceptance: Linux world-backed root start succeeds under the landed host-first contract; non-Linux world-backed root start fails closed with `unsupported_platform_or_posture`; omitted scope preserves the documented preferred-scope probe plus one alternate-scope fallback; obsolete deferred-host-attach / `born_unattached` root-start assertions are absent from the final slice-30 contract wall. - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` - Files: - [`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) - [`crates/shell/src/execution/agents_cmd.rs`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) -- [ ] Task 4.3: Update operator docs and planning docs to match the shipped contract - - Acceptance: public docs describe `--scope world`, `--disable-capability` / `--disable-cap`, `born_unattached`, Linux-first rollout, and pre-attach fail-closed behavior exactly as implemented; no doc still claims host-only root start as the only contract for this slice. +- [x] Task 4.4: Update planning docs to match the shipped host-first world-backed contract + - Acceptance: planning docs describe omitted-scope preferred-scope resolution plus alternate-scope fallback, `--scope host` as the bypass-world path, `--scope world` as the explicit world-backed host-session path, host-first inaugural prompt handling, immediate start-time host/world truth versus lazy follow-on world work, the readable-status versus fail-closed-control split, Linux-first world-backed support, non-Linux fail-closed posture, and the explicit deferred list exactly as implemented; no slice-30 doc still treats `born_unattached` as the default thin-slice success posture. - Verify: manual diff review plus `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` - Files: - - [`docs/USAGE.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/docs/USAGE.md) - - [`llm-last-mile/README.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/README.md) - [`llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/SPEC-30-public-world-scoped-agent-start-and-capability-flags.md) - [`llm-last-mile/PLAN-30.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/PLAN-30.md) + - [`llm-last-mile/TASKS-30.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/TASKS-30.md) -- [ ] Task 4.4: Run the final validation wall for the full slice - - Acceptance: formatting, clippy, targeted shell suites, and full workspace tests pass; any Linux manual smoke evidence needed for the slice is captured before closeout. +- [x] Task 4.5: Run the final validation wall for the full slice + - Acceptance: formatting, clippy, targeted shell suites, and full workspace tests pass; Linux manual smoke evidence is landed for the host-first world-backed path; the public control suites keep the non-Linux `unsupported_platform_or_posture` fail-closed posture pinned; no closeout step relaxes the Packet 4 contract. - Verify: - `cargo fmt --all -- --check` - `cargo clippy --workspace --all-targets -- -D warnings` @@ -171,27 +191,28 @@ Session goal: - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` - `cargo test --workspace -- --nocapture` - Files: - - No planned source edits; this is the validation gate after the implementation tasks above. + - [`llm-last-mile/CLOSEOUT-30-packet-4-linux-manual-smoke-2026-05-28.md`](/Users/spensermcconnell/__Active_Code/atomize-hq/substrate/llm-last-mile/CLOSEOUT-30-packet-4-linux-manual-smoke-2026-05-28.md) ### Packet 4 Checkpoint Packet 4 is complete only when: -1. `born_unattached` is visible and truthful, -2. Linux-first and non-Linux fail-closed behavior are both pinned, -3. pre-attach follow-up remains fail closed, -4. docs, spec, and plan all match shipped behavior, -5. the full validation wall passes. +1. world-backed root start keeps the normal host lifecycle as the default operator-facing truth, +2. `agent status` readable degradation and toolbox/doctor fail-closed behavior are both pinned, +3. Linux-first and non-Linux fail-closed behavior are both pinned, +4. omitted-scope resolution order is pinned, +5. docs, spec, and plan all match shipped behavior, +6. the full validation wall, including required manual smoke evidence, passes. ## Cross-Packet Dependency Order -1. Packet 1 blocks Packet 2. -2. Packet 2 blocks Packet 3. -3. Packet 3 blocks Packet 4. +1. Packet 1 landed before Packet 2. +2. Packet 2 landed before Packet 3. +3. Packet 3 landed before Packet 4. ## Notes For Implementation -- Packet 1 is the contract-freeze packet. Do not leak runtime behavior changes into it. -- Packet 2 is the highest-risk runtime packet. Keep it focused on session birth and deferred host attach. -- Packet 3 should stay narrow. If it expands into status or docs, stop and defer that work to Packet 4. -- Packet 4 is the integration packet. This is where obsolete host-only world-start assertions should be replaced and where wording should be aligned across runtime, tests, and docs. +- Packet 1 is landed floor. Do not reopen it while implementing Packet 4 unless the contract itself changes. +- Packet 2 is landed floor. Do not reopen runtime start birth or world-binding setup while implementing Packet 4 unless the contract itself changes. +- Packet 3 is landed floor. Do not reopen authoritative parent world-binding reuse or mismatch fail-closed behavior while implementing Packet 4 unless the contract itself changes. +- Packet 4 is landed. Keep any follow-on work out of slice 30 unless the frozen contract itself needs to change. diff --git a/llm-last-mile/TASKS-30_25.md b/llm-last-mile/TASKS-30_25.md new file mode 100644 index 000000000..f18b6add3 --- /dev/null +++ b/llm-last-mile/TASKS-30_25.md @@ -0,0 +1,186 @@ +# TASKS-30.25: Sanctioned Manual Host Attach For Born-Unattached World Start + +Source spec: [SPEC-30_25.md](./SPEC-30_25.md) +Source plan: [PLAN-30_25.md](./PLAN-30_25.md) +Adjacent landed slice inputs: [TASKS-30.md](./TASKS-30.md), [30-public-world-scoped-agent-start-and-capability-flags.md](./30-public-world-scoped-agent-start-and-capability-flags.md) +Deferred follow-on: [31-lazy-host-attach-for-host-rooted-world-start.md](./31-lazy-host-attach-for-host-rooted-world-start.md) +Phase: `TASKS` +Execution model: parked; do not implement until the slice is explicitly reactivated +Status: parked on 2026-05-27 after slice-30 was realigned to a host-first default path + +## Parking Note + +These tasks are not ready for implementation. + +They depend on `born_unattached` being the normal public world-start path. The updated slice-30 direction no longer treats that as the thin-slice happy path, so this task packet remains historical planning context only unless a later slice deliberately revives an explicit unattached public start mode. + +## Execution Packets + +This slice should be implemented as four separate `/incremental-implementation` sessions. + +- Packet 1 implements Phase 1 only. +- Packet 2 implements Phase 2 only. +- Packet 3 implements Phase 3 only. +- Packet 4 implements Phase 4 only. + +Do not start a later packet until the prior packet's checkpoint is green. + +## Packet 1: Manual Attach Contract And Eligibility + +Session goal: + +1. extend the public `reattach` contract to eligible `born_unattached` sessions, +2. preserve existing parked-host recovery semantics, +3. pin fail-closed rejection behavior for ineligible sessions. + +### Tasks + +- [ ] Task 1.1: Widen public `reattach` eligibility to include sanctioned manual attach for eligible `born_unattached` sessions + - Acceptance: `substrate agent reattach --session ` remains session-scoped and non-prompt-taking; it can target an eligible `born_unattached` session without allocating a new orchestration session; existing parked/detached host-session recovery behavior remains unchanged. + - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - Files: + - [`crates/shell/src/execution/agents_cmd.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) + - [`crates/shell/src/execution/agent_runtime/state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) + - [`crates/shell/tests/agent_public_control_surface_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) + +- [ ] Task 1.2: Pin fail-closed reattach rejection taxonomy for ineligible `born_unattached` and detached sessions + - Acceptance: ineligible targets still fail closed with explicit reasons; `reattach` does not silently broaden into prompt-taking follow-up, world-to-world continuity, or a new-session allocation path; tests assert stable rejection classifiers and messages. + - Verify: `cargo test -p shell reattach -- --nocapture` + - Files: + - [`crates/shell/src/execution/agents_cmd.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) + - [`crates/shell/src/execution/agent_runtime/state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) + - [`crates/shell/tests/agent_public_control_surface_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) + +### Packet 1 Checkpoint + +Packet 1 is complete only when: + +1. `reattach` is explicitly allowed for eligible `born_unattached` sessions, +2. existing parked-host `reattach` behavior stays green, +3. ineligible sessions still fail closed with explicit reasons. + +Do not start Packet 2 until Packet 1 verification is green. + +## Packet 2: Explicit Manual Attach Planning From Persisted Truth + +Session goal: + +1. plan manual attach from persisted `HostAttachContract` truth, +2. choose continuity attach versus fresh attach explicitly, +3. keep both modes non-prompt-taking and auditable. + +### Tasks + +- [ ] Task 2.1: Build one sanctioned attach planner that chooses continuity attach versus fresh attach from persisted attach truth + - Acceptance: the runtime selects continuity attach when a valid persisted continuity selector exists and fresh attach when continuity does not exist but the persisted baseline is complete; attach-mode choice is explicit and testable; the planner does not recover semantics from stale participant snapshots when durable attach truth is present. + - Verify: `cargo test -p shell attach_contract -- --nocapture` + - Files: + - [`crates/shell/src/execution/agents_cmd.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.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) + - [`crates/shell/src/execution/agent_runtime/control.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs) + +- [ ] Task 2.2: Execute fresh attach for `born_unattached` sessions without synthetic prompt submission + - Acceptance: an eligible `born_unattached` session can successfully attach a real host execution client through `reattach`; the attach path reuses the persisted `HostAttachContract`; no hidden bootstrap prompt, inbox-consumption prompt, or synthetic warm-up turn is submitted. + - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - Files: + - [`crates/shell/src/execution/agents_cmd.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agents_cmd.rs) + - [`crates/shell/src/execution/agent_runtime/control.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/control.rs) + - [`crates/shell/src/execution/agent_runtime/orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs) + +### Packet 2 Checkpoint + +Packet 2 is complete only when: + +1. manual attach planning explicitly chooses continuity or fresh attach, +2. `born_unattached` sessions can attach through the sanctioned path, +3. neither mode injects hidden prompt-bearing behavior. + +Do not start Packet 3 until Packet 2 verification is green. + +## Packet 3: Truthful Post-Attach And Failure Lifecycle State + +Session goal: + +1. transition successful manual attach to truthful attached state, +2. preserve non-theatrical failure outcomes, +3. keep parked and attention-needed semantics distinct from born-unattached attach flows. + +### Tasks + +- [ ] Task 3.1: Persist truthful `active_attached` state after successful manual attach + - Acceptance: successful `reattach` for a `born_unattached` session keeps the same orchestration session id and transitions that session to truthful `active_attached` state with authoritative attached-participant linkage; later `turn` and `stop` continue to operate on that same durable session. + - Verify: `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - Files: + - [`crates/shell/src/execution/agent_runtime/orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs) + - [`crates/shell/src/execution/agent_runtime/state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) + - [`crates/shell/tests/agent_public_control_surface_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) + +- [ ] Task 3.2: Preserve truthful failure and status-projection semantics for manual attach + - Acceptance: failed manual attach leaves the session non-terminal and does not misreport `parked_resumable`, `awaiting_attention`, or `active_attached` when that state was not actually achieved; status JSON and projection rules remain explicit for `born_unattached`, `active_attached`, `parked_resumable`, and `awaiting_attention`. + - Verify: `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - Files: + - [`crates/shell/src/execution/agent_runtime/orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs) + - [`crates/shell/src/execution/agent_runtime/state_store.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/state_store.rs) + - [`crates/shell/tests/agent_successor_contract_ahcsitc0.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs) + +### Packet 3 Checkpoint + +Packet 3 is complete only when: + +1. successful manual attach results in truthful `active_attached` state for the same session, +2. failed attach outcomes stay non-theatrical and non-terminal, +3. parked and attention-needed host lifecycle semantics remain intact. + +Do not start Packet 4 until Packet 3 verification is green. + +## Packet 4: Docs, Deferred-Scope Alignment, And Validation Wall + +Session goal: + +1. update operator/docs wording to reflect the shipped manual attach bridge, +2. keep automatic attach policy explicitly deferred to slice 31, +3. run the final validation wall. + +### Tasks + +- [ ] Task 4.1: Update operator and slice docs to describe sanctioned manual attach for `born_unattached` + - Acceptance: docs stop claiming that `born_unattached` has no sanctioned host-attach surface; `reattach` is documented as the manual-only path for this bridge slice; wording distinguishes the new `30.25` bridge contract from the broader automatic-attach work still deferred to slice 31. + - Verify: manual diff review plus `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - Files: + - [`docs/USAGE.md`](/home/azureuser/__Active_Code/atomize-hq/substrate/docs/USAGE.md) + - [`llm-last-mile/README.md`](/home/azureuser/__Active_Code/atomize-hq/substrate/llm-last-mile/README.md) + - [`llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md`](/home/azureuser/__Active_Code/atomize-hq/substrate/llm-last-mile/31-lazy-host-attach-for-host-rooted-world-start.md) + +- [ ] Task 4.2: Run the final validation wall for the full bridge slice + - Acceptance: targeted shell suites, focused reattach/attach-contract coverage, formatting, clippy, and full workspace tests pass; any Linux manual evidence needed for the bridge slice is captured before closeout. + - Verify: + - `cargo fmt --all -- --check` + - `cargo clippy --workspace --all-targets -- -D warnings` + - `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - `cargo test -p shell reattach -- --nocapture` + - `cargo test -p shell attach_contract -- --nocapture` + - `cargo test --workspace -- --nocapture` + - Files: + - No planned source edits; this is the validation gate after the implementation tasks above. + +### Packet 4 Checkpoint + +Packet 4 is complete only when: + +1. docs describe the shipped manual attach bridge truthfully, +2. slice 31 remains limited to automatic-trigger and broader taxonomy questions, +3. the full validation wall passes. + +## Cross-Packet Dependency Order + +1. Packet 1 blocks Packet 2. +2. Packet 2 blocks Packet 3. +3. Packet 3 blocks Packet 4. + +## Notes For Implementation + +- Packet 1 is the contract-widening packet. Do not leak attach-runtime behavior changes into it beyond what is needed to pin eligibility and rejection semantics. +- Packet 2 is the highest-risk runtime packet. Keep it focused on manual attach planning and execution from persisted truth. +- Packet 3 should stay focused on truthful lifecycle transitions and status projection. If it expands into docs or policy questions, stop and defer that work to Packet 4 or slice 31. +- Packet 4 is the integration packet. This is where stale “no sanctioned host-attach surface” wording should be retired and where deferred automatic-attach scope must remain explicit. diff --git a/llm-last-mile/TASKS-shell-owned-uaa-runtime-family-alias-support.md b/llm-last-mile/TASKS-shell-owned-uaa-runtime-family-alias-support.md new file mode 100644 index 000000000..db64e9d9f --- /dev/null +++ b/llm-last-mile/TASKS-shell-owned-uaa-runtime-family-alias-support.md @@ -0,0 +1,242 @@ +# TASKS: Shell-Owned UAA Runtime Family Alias Support + +Source spec: [SPEC-shell-owned-uaa-runtime-family-alias-support.md](./SPEC-shell-owned-uaa-runtime-family-alias-support.md) +Source plan: [PLAN-shell-owned-uaa-runtime-family-alias-support.md](./PLAN-shell-owned-uaa-runtime-family-alias-support.md) +Source handoff: [2026-05-28-151655-runtime-family-alias-support-architecture.md](../.codex/handoffs/2026-05-28-151655-runtime-family-alias-support-architecture.md) +Phase: `TASKS` +Execution model: four separate `/incremental-implementation` sessions +Status: draft for review + +## Execution Packets + +This fix should be implemented as four sequential `/incremental-implementation` sessions. + +- Packet 1 implements typed inventory schema and projection only. +- Packet 2 implements runtime-family resolution and fail-closed runtime-realizability checks. +- Packet 3 aligns persisted-session and REPL readers with the corrected runtime-family contract. +- Packet 4 converts fixtures/helpers, locks regression coverage, updates docs, and runs the final validation wall. + +Do not start a later packet until the prior packet checkpoint is green. + +## Packet 1: Typed Inventory Runtime-Family Contract + +Session goal: + +1. add explicit inventory truth at `config.cli.runtime_family`, +2. keep the field owned by typed agent inventory rather than policy or top-level config, +3. project that truth forward without changing runtime behavior yet. + +### Tasks + +- [ ] Task 1.1: Add typed `config.cli.runtime_family` schema and projected inventory carriage + - Acceptance: `AgentCliConfigV1` accepts `runtime_family` as a typed `snake_case` enum with supported values `codex` and `claude_code`; `ProjectedInventoryEntryV1` carries the projected CLI runtime-family truth; `deny_unknown_fields` behavior remains intact; no policy or top-level config types are widened. + - Verify: + - `cargo test -p shell agents_validate -- --nocapture` + - `cargo test -p shell agent_inventory -- --nocapture` + - Expected files touched: + - [`crates/shell/src/execution/agent_inventory.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs) + - [`crates/shell/tests/agents_validate.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agents_validate.rs) + +### Packet 1 Checkpoint + +Packet 1 is complete only when: + +1. inventory can parse valid `runtime_family` values, +2. projected inventory can carry runtime-family truth for selected rows, +3. invalid values still fail through the normal typed-schema path, +4. no policy or top-level config surfaces have changed. + +Do not start Packet 2 until Packet 1 verification is green. + +## Packet 2: Explicit Runtime-Family Resolution And Fail-Closed Validation + +Session goal: + +1. remove literal-`agent_id` runtime-family inference from production selection-to-launch code, +2. resolve runtime family from projected inventory truth, +3. require `config.cli.runtime_family` only for the shell-owned UAA runtime-realizability path. + +### Tasks + +- [ ] Task 2.1: Replace literal-`agent_id` runtime-family inference in runtime contract construction + - Acceptance: launch-contract construction no longer derives runtime family from literal inventory `agent_id`; `mapping.rs` exposes an explicitly named resolver that consumes projected runtime-family truth; exact backend selection remains keyed on `backend_id`; aliased exact backends such as `cli:codex_world` resolve to the canonical `Codex` runtime family when inventory says `runtime_family: codex`. + - Verify: + - `cargo test -p shell dispatch_contract -- --nocapture` + - Expected files touched: + - [`crates/shell/src/execution/agent_runtime/mapping.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/mapping.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) + - [`crates/shell/src/execution/agent_inventory.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_inventory.rs) + +- [ ] Task 2.2: Enforce fail-closed runtime-realizability checks for missing or unsupported runtime-family truth + - Acceptance: runtime-realizability validation requires `config.cli.runtime_family` only when `config.kind=cli`, `protocol=substrate.agent.session`, and effective `cli.mode=persistent`; missing `runtime_family` fails closed with explicit contract wording; unrelated CLI entries outside that path do not become newly invalid; policy allowlisting remains keyed only on exact `backend_id`. + - Verify: + - `cargo test -p shell agent_runtime::validator -- --nocapture` + - Expected files touched: + - [`crates/shell/src/execution/agent_runtime/validator.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/validator.rs) + - [`crates/shell/src/execution/agent_runtime/mapping.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/mapping.rs) + - [`crates/shell/tests/agents_validate.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agents_validate.rs) + +### Packet 2 Checkpoint + +Packet 2 is complete only when: + +1. production runtime resolution no longer depends on literal `agent_id`, +2. `cli:codex_world` and similar aliases remain distinct exact backends while resolving the correct canonical runtime family, +3. missing `runtime_family` fails closed only for the intended shell-owned UAA path, +4. policy and backend-selection semantics remain unchanged. + +Do not start Packet 3 until Packet 2 verification is green. + +## Packet 3: Persistence And REPL Parity + +Session goal: + +1. preserve canonical persisted runtime-family truth after the production resolver changes, +2. keep retained-session and retained-member readers aligned with canonical `resolved_agent_kind`, +3. prevent alias identity from leaking into persisted runtime-family fields. + +### Tasks + +- [ ] Task 3.1: Preserve canonical persisted session truth for aliased exact backends + - Acceptance: persisted manifests for aliased backends keep `agent_id` and `backend_id` exact, but continue to serialize canonical `resolved_agent_kind` values as `codex` or `claude_code`; manifest readers continue interpreting `resolved_agent_kind` as runtime family rather than alias identity; no wire or persistence field rename is introduced. + - Verify: + - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - Expected files touched: + - [`crates/shell/src/execution/agent_runtime/session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/session.rs) + - [`crates/shell/src/execution/agent_runtime/orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs) + - [`crates/shell/tests/agent_successor_contract_ahcsitc0.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs) + +- [ ] Task 3.2: Keep REPL retained-member reconstruction and targeted follow-up routing exact-backend aware + - Acceptance: retained-member reconstruction continues to derive the runtime implementation from canonical persisted runtime-family truth while preserving exact backend identity such as `cli:codex_world`; targeted follow-up flows do not collapse aliased world backends back to `cli:codex`; REPL parity remains host/world truthful. + - Verify: + - `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` + - Expected files touched: + - [`crates/shell/src/repl/async_repl.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/repl/async_repl.rs) + - [`crates/shell/src/execution/agent_runtime/orchestration_session.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/orchestration_session.rs) + - [`crates/shell/tests/repl_world_first_routing_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/repl_world_first_routing_v1.rs) + +### Packet 3 Checkpoint + +Packet 3 is complete only when: + +1. canonical persisted runtime-family spelling remains unchanged, +2. exact backend identity still survives through retained-session and retained-member paths, +3. alias identity is not serialized into `resolved_agent_kind`, +4. REPL follow-up routing preserves `cli:codex_world` versus `cli:codex`. + +Do not start Packet 4 until Packet 3 verification is green. + +## Packet 4: Fixture Conversion, Regression Locking, Docs, And Validation + +Session goal: + +1. treat fixture/helper churn as a first-class implementation stream, +2. convert runtime-realizable inventories and persisted-manifest writers to the explicit runtime-family contract, +3. update docs to reflect the shipped contract, +4. run the final validation wall. + +### Tasks + +- [ ] Task 4.1: Convert runtime-realizable fixture and helper writers to explicit runtime-family truth + - Acceptance: any shared helper or inline fixture that emits `kind=cli`, `protocol=substrate.agent.session`, and effective `cli.mode=persistent` now declares `config.cli.runtime_family`; persisted-manifest fixture writers stop mirroring alias `agent_id` into `resolved_agent_kind`; missing-field fixtures are kept only where the test is explicitly asserting fail-closed rejection. + - Verify: + - `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - Expected files touched: + - [`crates/shell/tests/agent_public_control_surface_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) + - [`crates/shell/tests/agent_successor_contract_ahcsitc0.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs) + - [`crates/shell/tests/repl_world_first_routing_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/repl_world_first_routing_v1.rs) + - [`crates/shell/tests/common.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/common.rs) + - [`crates/shell/tests/support/mod.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/support/mod.rs) + +- [ ] Task 4.2: Lock alias-topology regressions across doctor, start, turn, retained-member, and fail-closed missing-field behavior + - Acceptance: regression coverage explicitly exercises host orchestrator `codex` plus world alias `codex_world`; `substrate agent doctor --json` succeeds for the explicit alias topology; `agent start --backend cli:codex_world --scope world` and targeted follow-up/retained-member paths preserve exact backend identity; dedicated failures cover missing `runtime_family` on shell-owned UAA candidates. + - Verify: + - `cargo test -p shell agent_runtime::validator -- --nocapture` + - `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` + - Expected files touched: + - [`crates/shell/tests/agent_public_control_surface_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_public_control_surface_v1.rs) + - [`crates/shell/tests/agent_successor_contract_ahcsitc0.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/agent_successor_contract_ahcsitc0.rs) + - [`crates/shell/tests/repl_world_first_routing_v1.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/tests/repl_world_first_routing_v1.rs) + - [`crates/shell/src/execution/agent_runtime/validator.rs`](/home/azureuser/__Active_Code/atomize-hq/substrate/crates/shell/src/execution/agent_runtime/validator.rs) + +- [ ] Task 4.3: Update public config docs to describe explicit inventory runtime-family support without widening policy or top-level config + - Acceptance: docs stop framing runtime-realizable support as a hard-coded list of literal realized backends; `docs/CONFIGURATION.md` explains that shell-owned UAA runtime realization depends on exact backend selection plus explicit `config.cli.runtime_family`; supported values are documented as `codex` and `claude_code`; policy wording remains exact-`backend_id` only. + - Verify: + - manual diff review + - Expected files touched: + - [`docs/CONFIGURATION.md`](/home/azureuser/__Active_Code/atomize-hq/substrate/docs/CONFIGURATION.md) + +- [ ] Task 4.4: Run the final validation wall and focused operator smoke + - Acceptance: formatting, clippy, validator coverage, the three targeted shell integration suites, and the focused alias-topology smoke commands all pass; final results show no production reliance on literal `agent_id` for runtime-family resolution and no regression in exact backend selection or policy semantics. + - Verify: + - `cargo fmt --all -- --check` + - `cargo clippy --workspace --all-targets -- -D warnings` + - `cargo test -p shell agent_runtime::validator -- --nocapture` + - `cargo test -p shell --test agent_public_control_surface_v1 -- --nocapture` + - `cargo test -p shell --test agent_successor_contract_ahcsitc0 -- --nocapture` + - `cargo test -p shell --test repl_world_first_routing_v1 -- --nocapture` + - `substrate agent doctor --json` + - `substrate agent start --backend cli:codex --scope host --prompt "host ok" --json` + - `substrate agent start --backend cli:codex_world --scope world --prompt "world ok" --json` + - `substrate agent turn --session --backend cli:codex_world --prompt "next" --json` + - Expected files touched: + - No planned source edits; this is the final validation gate after the implementation tasks above. + +### Packet 4 Checkpoint + +Packet 4 is complete only when: + +1. helper and fixture conversion is complete and explicit, +2. alias-topology regressions are pinned across doctor, start, turn, and retained-member flows, +3. docs describe `config.cli.runtime_family` as inventory-only runtime-realization truth, +4. the full validation wall and focused smoke commands pass. + +## Cross-Packet Dependency Order + +1. Packet 1 blocks Packet 2. +2. Packet 2 blocks Packet 3. +3. Packet 3 blocks Packet 4. + +## Inter-Packet Review Rules + +After completing a packet, treat the next step as a packet checkpoint review, not a fresh spec-driven-development restart. + +Proceed directly to the next packet only when: + +1. the current packet's verification steps are green, +2. the current packet checkpoint is satisfied, +3. no approved architectural constraint was violated, +4. the next packet is still consistent with the source spec, plan, and this TASKS document. + +Reopen spec, plan, or TASKS only if one of these is true: + +1. implementation discovers a real contradiction in the approved architecture, +2. a packet forces a scope change, +3. verification shows the planned dependency order is wrong, +4. a new requirement appears. + +If none of those conditions are met, continue packet-to-packet without re-specifying or re-planning. + +## Packet Session Final Message Requirements + +Every packet implementation session should end with a final completion message that surfaces all of the following: + +1. whether the packet's verification commands passed or which ones did not, +2. whether the packet checkpoint is green, +3. whether the next packet is unblocked, +4. whether any condition to reopen spec, plan, or TASKS was discovered, +5. the GitNexus impact-analysis results for each production symbol edited in that packet, including any `HIGH` or `CRITICAL` warnings that had to be reviewed before editing, +6. any remaining risks, deferred follow-ups, or assumptions that the next packet needs to know. + +If a packet is not fully green, the final message must say explicitly that the next packet should not begin yet. + +## Notes For Implementation + +- Packet 1 is intentionally schema-first. Do not widen it into runtime behavior or doc rewrites beyond what is strictly needed to land typed inventory truth. +- Packet 2 is the architectural correction packet. Run GitNexus impact analysis on each concrete symbol before editing production runtime functions, and stop to review blast radius if risk comes back `HIGH` or `CRITICAL`. +- Packet 3 should preserve canonical persisted runtime-family spelling. If implementation pressure suggests renaming `resolved_agent_kind` or changing wire enums, stop and ask first. +- Packet 4 treats fixture/helper conversion as first-class work, not cleanup. Update shared writers before chasing individual failing tests so the greenfield contract is applied consistently. +- For Packet 4 and later follow-ons, prefer the globally installed `gitnexus` CLI rather than `npx gitnexus ...` in this repo. If the index may be stale, refresh it with `gitnexus analyze --repo /home/azureuser/__Active_Code/atomize-hq/substrate`, then run repo-qualified impact and changed-scope checks such as `gitnexus impact --repo /home/azureuser/__Active_Code/atomize-hq/substrate --target --direction upstream` and `gitnexus detect-changes --repo /home/azureuser/__Active_Code/atomize-hq/substrate --scope unstaged`. diff --git a/scripts/substrate/dev-fresh-install-gateway-smoke.sh b/scripts/substrate/dev-fresh-install-gateway-smoke.sh new file mode 100755 index 000000000..49fef4a36 --- /dev/null +++ b/scripts/substrate/dev-fresh-install-gateway-smoke.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_NAME="dev-fresh-install-gateway-smoke" + +log() { printf '[%s] %s\n' "${SCRIPT_NAME}" "$1"; } +warn() { printf '[%s][WARN] %s\n' "${SCRIPT_NAME}" "$1" >&2; } +fatal() { printf '[%s][ERROR] %s\n' "${SCRIPT_NAME}" "$1" >&2; exit 1; } + +usage() { + cat <<'USAGE' +Substrate Fresh-Install Gateway Smoke Helper + +Automates the common post-install configuration used to manually smoke-test the +Linux world gateway / agent startup path on a fresh install. + +Usage: + dev-fresh-install-gateway-smoke.sh [--prefix ] [--bin ] [--repo-root ] [--agent-manifest ] [--skip-sync] + dev-fresh-install-gateway-smoke.sh --help + +Options: + --prefix Installed Substrate home (default: ~/.substrate) + --bin Explicit substrate binary path (default: /bin/substrate) + --repo-root Repo root used to locate config/agents/codex.yaml + --agent-manifest Explicit codex agent manifest source + --skip-sync Stop after gateway status instead of running gateway sync + --help Show this message + +This helper assumes the fresh install is using the default Codex gateway smoke +path: + - llm.routing.default_backend = cli:codex + - codex is the orchestrator agent + - config/agents/codex.yaml is copied into /agents/ +USAGE +} + +SCRIPT_SOURCE="${BASH_SOURCE[0]:-}" +SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_SOURCE}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +PREFIX="${HOME}/.substrate" +SUBSTRATE_BIN="" +AGENT_MANIFEST="" +RUN_SYNC=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) + [[ $# -ge 2 ]] || fatal "--prefix requires a value" + PREFIX="$2" + shift 2 + ;; + --bin) + [[ $# -ge 2 ]] || fatal "--bin requires a value" + SUBSTRATE_BIN="$2" + shift 2 + ;; + --repo-root) + [[ $# -ge 2 ]] || fatal "--repo-root requires a value" + REPO_ROOT="$2" + shift 2 + ;; + --agent-manifest) + [[ $# -ge 2 ]] || fatal "--agent-manifest requires a value" + AGENT_MANIFEST="$2" + shift 2 + ;; + --skip-sync) + RUN_SYNC=0 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fatal "Unknown argument: $1" + ;; + esac +done + +PREFIX="${PREFIX%/}" +if [[ -z "${SUBSTRATE_BIN}" ]]; then + SUBSTRATE_BIN="${PREFIX}/bin/substrate" +fi +if [[ -z "${AGENT_MANIFEST}" ]]; then + AGENT_MANIFEST="${REPO_ROOT}/config/agents/codex.yaml" +fi + +[[ -x "${SUBSTRATE_BIN}" ]] || fatal "substrate binary not found or not executable at ${SUBSTRATE_BIN}" +[[ -f "${AGENT_MANIFEST}" ]] || fatal "agent manifest not found at ${AGENT_MANIFEST}" + +run_substrate() { + log "Running: ${SUBSTRATE_BIN} $*" + "${SUBSTRATE_BIN}" "$@" +} + +agents_dir="${PREFIX}/agents" +mkdir -p "${agents_dir}" +cp "${AGENT_MANIFEST}" "${agents_dir}/codex.yaml" +log "Copied codex agent manifest into ${agents_dir}/codex.yaml" + +run_substrate config global set llm.routing.default_backend=cli:codex +run_substrate policy global set 'agents.allowed_backends=["cli:codex"]' +run_substrate config global set agents.enabled=true +run_substrate config global set agents.hub.orchestrator_agent_id=codex +run_substrate config global set llm.enabled=true +run_substrate config global set llm.gateway.enabled=true +run_substrate policy global set 'llm.allowed_backends=["cli:codex"]' +run_substrate policy global set 'agents.host_credentials.read.allowed_backends=["cli:codex"]' + +log "Configured fresh install for Codex world-gateway smoke." +run_substrate world gateway status + +if [[ "${RUN_SYNC}" -eq 1 ]]; then + run_substrate world gateway sync +else + warn "Skipping 'substrate world gateway sync' because --skip-sync was requested." +fi + +log "Fresh-install gateway smoke setup complete." diff --git a/scripts/substrate/dev-install-substrate.sh b/scripts/substrate/dev-install-substrate.sh index f2ecc188d..279e601b8 100755 --- a/scripts/substrate/dev-install-substrate.sh +++ b/scripts/substrate/dev-install-substrate.sh @@ -764,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.now(datetime.UTC).isoformat().replace("+00:00", "Z") +timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") base = {} parsed_existing = False diff --git a/scripts/substrate/install-substrate.sh b/scripts/substrate/install-substrate.sh index 26a7b292b..08d00631e 100755 --- a/scripts/substrate/install-substrate.sh +++ b/scripts/substrate/install-substrate.sh @@ -367,7 +367,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.now(datetime.UTC).isoformat().replace("+00:00", "Z") +timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") base = {} parsed_existing = False diff --git a/tests/installers/install_state_smoke.sh b/tests/installers/install_state_smoke.sh index 148ac87e6..d413f0ebc 100755 --- a/tests/installers/install_state_smoke.sh +++ b/tests/installers/install_state_smoke.sh @@ -144,6 +144,19 @@ set -euo pipefail if [[ "\${SUBSTRATE_TEST_FAIL_HOST_STATE_WRITE:-0}" -eq 1 && "\${1:-}" == "-" && -n "\${STATE_EVENTS:-}" ]]; then exit 1 fi +if [[ "\${1:-}" == "-" ]]; then + script_file="\$(mktemp)" + cat >"\${script_file}" + if [[ "\${SUBSTRATE_TEST_REJECT_DATETIME_UTC:-0}" -eq 1 ]] && grep -q 'datetime\\.UTC' "\${script_file}"; then + rm -f "\${script_file}" + echo "AttributeError: module 'datetime' has no attribute 'UTC'" >&2 + exit 1 + fi + "${REAL_PYTHON3}" "\${script_file}" "\${@:2}" + status=\$? + rm -f "\${script_file}" + exit "\${status}" +fi exec "${REAL_PYTHON3}" "\$@" EOF_STUB chmod +x "${STUB_BIN}/python3" @@ -1213,6 +1226,24 @@ EOF_INVALID run_dev_install_branch "${work_root}/dev-fresh" "${dev_fresh_log}" "${dev_fresh_state}" "with-world" "__skip__" "__skip__" "${CURRENT_OS_RELEASE_ID}" "${CURRENT_OS_RELEASE_ID_LIKE}" "${expected_pkg_manager}" "os_release" assert_env_sh_has_no_override_exports "${dev_fresh_prefix}" "${HOST_PATH}" + local pycompat_prefix="${work_root}/pycompat" + local pycompat_state="${pycompat_prefix}/install_state.json" + local pycompat_log="${work_root}/pycompat/install.log" + mkdir -p "${pycompat_prefix}" "$(dirname "${pycompat_log}")" + SUBSTRATE_TEST_REJECT_DATETIME_UTC=1 \ + run_hosted_install_branch "${work_root}/pycompat" "${fake_version}" "${artifacts}" "${pycompat_log}" "${pycompat_state}" "with-world" "${missing_os_release}" + assert_env_sh_has_no_override_exports "${pycompat_prefix}" "${HOST_PATH}" + assert_log_not_contains "${pycompat_log}" "Failed to write host state metadata" + + local dev_pycompat_prefix="${work_root}/dev-pycompat" + local dev_pycompat_state="${dev_pycompat_prefix}/install_state.json" + local dev_pycompat_log="${work_root}/dev-pycompat/install.log" + mkdir -p "${dev_pycompat_prefix}" "$(dirname "${dev_pycompat_log}")" + SUBSTRATE_TEST_REJECT_DATETIME_UTC=1 \ + run_dev_install_branch "${work_root}/dev-pycompat" "${dev_pycompat_log}" "${dev_pycompat_state}" "with-world" "__skip__" "__skip__" "${CURRENT_OS_RELEASE_ID}" "${CURRENT_OS_RELEASE_ID_LIKE}" "${expected_pkg_manager}" "os_release" + assert_env_sh_has_no_override_exports "${dev_pycompat_prefix}" "${HOST_PATH}" + assert_log_not_contains "${dev_pycompat_log}" "Failed to write host state metadata" + local dev_prefix="${work_root}/dev" local dev_state="${dev_prefix}/install_state.json" local dev_log="${work_root}/dev/install.log"