set an agent's initial agents.md at create
This commit is contained in:
parent
a6147dc875
commit
456cf9a7cb
19 changed files with 708 additions and 19 deletions
|
|
@ -62,7 +62,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
published it into the `agents-md` KV bucket
|
||||
(`GET /api/agents/{name}/agents-md`), and edits to it as git diffs, which
|
||||
this daemon hands to the agent's harness to apply
|
||||
(`PUT /api/agents/{name}/agents-md`) →
|
||||
(`PUT /api/agents/{name}/agents-md`). `POST /api/agents` can set the text the
|
||||
file starts with (`agents_md`), stored in the `agents-md-initial` bucket for
|
||||
the harness to write when no file exists →
|
||||
[`docs/swarm/README.md`](../docs/swarm/README.md#an-agents-agentsmd).
|
||||
- **Review classifier** — try-outs from the swarm UI and reviews on review
|
||||
requests, and the newest 100 finished runs, one document in the
|
||||
|
|
|
|||
|
|
@ -812,6 +812,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
|
@ -873,6 +874,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
252
swarm-controller/src/agents_md_initial.rs
Normal file
252
swarm-controller/src/agents_md_initial.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//! The text an agent's `AGENTS.md` starts with: one key per agent in
|
||||
//! `agents-md-initial` (`swarm_queue_client::agents_md_initial`), which this
|
||||
//! daemon alone writes. `POST /api/agents` stores it before it queues the
|
||||
//! creation; a destroy deletes it. The agent's harness reads its own key at
|
||||
//! start and writes the file only when none exists.
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
use swarm_queue_client::agents_md_initial::{self, Initial, MAX_BYTES};
|
||||
|
||||
use crate::routes::{AppState, error_problem};
|
||||
use crate::schedules::{queue, read_or_default, write_revision};
|
||||
|
||||
/// How many times a losing writer re-reads and re-applies, as in
|
||||
/// `wanted::MAX_ATTEMPTS`.
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
|
||||
tracing::warn!(error = %format!("{e:#}"), "agents-md initial text: queue operation failed");
|
||||
error_problem(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{e:#}"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse a text longer than [`MAX_BYTES`].
|
||||
pub(crate) fn check(content: &str) -> Result<(), String> {
|
||||
if content.len() > MAX_BYTES {
|
||||
return Err(format!(
|
||||
"agents_md is {} bytes; the limit is {MAX_BYTES}",
|
||||
content.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`store`] replaced, for [`restore`]; `None` when it wrote nothing.
|
||||
pub(crate) type Replaced = Option<crate::schedules::Replaced<Initial>>;
|
||||
|
||||
/// Record that `agent`'s `AGENTS.md` starts with `content`.
|
||||
pub(crate) async fn store(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
content: &str,
|
||||
) -> Result<Replaced, ProblemDetails> {
|
||||
let store = agents_md_initial::open_or_create(client)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e.into()))?;
|
||||
let value = Initial {
|
||||
content: content.to_owned(),
|
||||
};
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let (current, revision, undecodable) = read_or_default::<Initial>(&store, agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
// An absent key reads as the default, which is not "stored": a first
|
||||
// write of an empty text still has to land.
|
||||
if current == value && !undecodable && revision.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(written) = write_revision(&store, agent, &value, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
tracing::info!(%agent, bytes = content.len(), "set agent's initial AGENTS.md at create");
|
||||
return Ok(Some(crate::schedules::Replaced::new(
|
||||
revision.map(|_| current),
|
||||
written,
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
"gave up writing the initial AGENTS.md of {agent} after {MAX_ATTEMPTS} conflicting writes"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Undo what [`store`] did, unless another write has landed since. Best
|
||||
/// effort: a failure is logged, since the caller is already on its way to an
|
||||
/// error of its own.
|
||||
pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: Replaced) {
|
||||
let Some(replaced) = replaced else { return };
|
||||
let undone = match agents_md_initial::open_or_create(client).await {
|
||||
Ok(store) => replaced.undo(&store, agent).await,
|
||||
Err(e) => Err(anyhow::anyhow!("{e:?}")),
|
||||
};
|
||||
match undone {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::info!(%agent, "left the agent's initial AGENTS.md: another write landed since");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%agent, error = %format!("{e:#}"), "could not put back the agent's initial AGENTS.md");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a destroyed agent's key, so the next agent of that name does not
|
||||
/// start from this one's text. A failure is logged and does not stop the
|
||||
/// destroy, as in `agent_archetype::clear_on_destroy`.
|
||||
pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) {
|
||||
let cleared = async {
|
||||
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
|
||||
agents_md_initial::open_or_create(&client)
|
||||
.await?
|
||||
.delete(agent)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await;
|
||||
match cleared {
|
||||
Ok(()) => tracing::info!(hive, agent, "destroyed agent's initial AGENTS.md cleared"),
|
||||
Err(e) => tracing::error!(
|
||||
hive,
|
||||
agent,
|
||||
error = %format!("{e:#}"),
|
||||
"clearing this agent's initial AGENTS.md FAILED; it was destroyed anyway, so a new \
|
||||
agent of the same name starts from this text. Declaring the agent destroyed again \
|
||||
re-runs this."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use super::*;
|
||||
use crate::test_nats::TestNats;
|
||||
|
||||
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
|
||||
let store = agents_md_initial::open_or_create(client)
|
||||
.await
|
||||
.expect("bucket");
|
||||
store
|
||||
.get(agent)
|
||||
.await
|
||||
.expect("read")
|
||||
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
|
||||
}
|
||||
|
||||
fn request(name: &str, agents_md: Option<String>) -> crate::routes::agents::CreateAgentRequest {
|
||||
crate::routes::agents::CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_over_the_limit_is_refused_and_one_at_it_passes() {
|
||||
assert_eq!(check(&"a".repeat(MAX_BYTES)), Ok(()));
|
||||
let why = check(&"a".repeat(MAX_BYTES + 1)).unwrap_err();
|
||||
assert!(why.contains("the limit is"), "{why}");
|
||||
}
|
||||
|
||||
/// `POST /api/agents` with `agents_md` stores it under the new agent's
|
||||
/// key; a text over the limit is refused before anything is stored or
|
||||
/// queued.
|
||||
#[tokio::test]
|
||||
async fn create_stores_the_text_it_is_given_and_refuses_one_too_big() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
|
||||
let problem = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(request("iris", Some("a".repeat(MAX_BYTES + 1)))),
|
||||
)
|
||||
.await
|
||||
.expect_err("too big");
|
||||
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
|
||||
assert_eq!(published(&client, "iris").await, None);
|
||||
let queued = state
|
||||
.jobq
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.graph()
|
||||
.nodes()
|
||||
.count();
|
||||
assert_eq!(queued, 0, "nothing is queued for a refused create");
|
||||
|
||||
let _ = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(request("iris", Some("# iris\n".to_owned()))),
|
||||
)
|
||||
.await
|
||||
.expect("created");
|
||||
assert_eq!(
|
||||
published(&client, "iris").await.as_deref(),
|
||||
Some(r##"{"content":"# iris\n"}"##)
|
||||
);
|
||||
|
||||
// Control: no `agents_md` writes no key.
|
||||
let _ = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(request("damocles", None)),
|
||||
)
|
||||
.await
|
||||
.expect("created");
|
||||
assert_eq!(published(&client, "damocles").await, None);
|
||||
}
|
||||
|
||||
/// An empty text is a text: the key is written, so the agent gets an
|
||||
/// empty file rather than none.
|
||||
#[tokio::test]
|
||||
async fn an_empty_text_is_stored() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let _ = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(request("iris", Some(String::new()))),
|
||||
)
|
||||
.await
|
||||
.expect("created");
|
||||
assert_eq!(
|
||||
published(&client, "iris").await.as_deref(),
|
||||
Some(r#"{"content":""}"#)
|
||||
);
|
||||
}
|
||||
|
||||
/// Destroying an agent deletes its key, so a new agent of that name does
|
||||
/// not start from the old text.
|
||||
#[tokio::test]
|
||||
async fn a_recreated_agent_does_not_start_from_the_destroyed_ones_text() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let _ = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(request("atlas", Some("old".to_owned()))),
|
||||
)
|
||||
.await
|
||||
.expect("created");
|
||||
assert!(published(&client, "atlas").await.is_some());
|
||||
|
||||
let _ = crate::routes::agents::set_agent_state(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned())),
|
||||
axum::Json(crate::routes::agents::SetAgentStateRequest {
|
||||
state: swarm_queue_client::wanted::AgentState::Destroyed,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("destroyed");
|
||||
assert_eq!(published(&client, "atlas").await, None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1688,6 +1688,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -1810,6 +1811,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -1861,6 +1863,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -1920,6 +1923,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -2126,6 +2130,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ mod agent_renewal;
|
|||
mod agent_state_stream;
|
||||
mod agent_status;
|
||||
mod agents_md;
|
||||
mod agents_md_initial;
|
||||
mod alerts;
|
||||
mod archetypes;
|
||||
mod auth;
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job};
|
|||
use crate::routes::hives::HiveEntry;
|
||||
use crate::routes::{AppState, StatusUnavailable, error_problem, wanted_error_status};
|
||||
use crate::{
|
||||
agent_archetype, agent_identity, agent_limits, agent_permissions, agent_status, auth,
|
||||
inference, schedules, wanted,
|
||||
agent_archetype, agent_identity, agent_limits, agent_permissions, agent_status,
|
||||
agents_md_initial, auth, inference, schedules, wanted,
|
||||
};
|
||||
|
||||
/// The swarm's agent roster — every agent the swarm holds an identity for.
|
||||
|
|
@ -214,6 +214,7 @@ pub(crate) async fn set_agent_state(
|
|||
revoke_or_complain(&hive, &agent).await;
|
||||
agent_permissions::clear_on_destroy(&state, &hive, &agent).await;
|
||||
agent_archetype::clear_on_destroy(&state, &hive, &agent).await;
|
||||
agents_md_initial::clear_on_destroy(&state, &hive, &agent).await;
|
||||
}
|
||||
Ok(Json(render(&declaration)))
|
||||
}
|
||||
|
|
@ -516,6 +517,13 @@ pub(crate) struct CreateAgentRequest {
|
|||
#[serde(default)]
|
||||
#[schema(value_type = Option<Object>, example = json!({"source": "hyperhive", "name": "reviewer"}))]
|
||||
pub(crate) archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||
/// The text the agent's `AGENTS.md` starts with, at most
|
||||
/// `swarm_queue_client::agents_md_initial::MAX_BYTES` bytes. The agent's
|
||||
/// harness writes it on its first start, and only when the agent has no
|
||||
/// `AGENTS.md` yet: an existing file is never replaced. Absent leaves the
|
||||
/// file to the harness.
|
||||
#[serde(default)]
|
||||
pub(crate) agents_md: Option<String>,
|
||||
}
|
||||
|
||||
/// Queue the whole agent-creation job graph for `name` — two independent
|
||||
|
|
@ -549,10 +557,10 @@ pub(crate) struct CreateAgentRequest {
|
|||
request_body = CreateAgentRequest,
|
||||
responses(
|
||||
(status = 200, description = "job chain queued", body = CreateAgentResponse),
|
||||
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, a permission is listed twice, or `archetype` is not one the stored archetype list holds (problem+json)", body = String),
|
||||
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, a permission is listed twice, `archetype` is not one the stored archetype list holds, or `agents_md` is over the size limit (problem+json)", body = String),
|
||||
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
|
||||
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"),
|
||||
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference`, `permissions` or `archetype` is set and cannot be stored (problem+json)", body = String),
|
||||
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference`, `permissions`, `archetype` or `agents_md` is set and cannot be stored (problem+json)", body = String),
|
||||
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
|
|
@ -567,7 +575,7 @@ pub(crate) async fn create_agent(
|
|||
|
||||
// Settings are checked before anything is created, so a bad value stops
|
||||
// the request rather than leaving an agent without them.
|
||||
let settings = CreateSettings::parse(req.limits, req.inference, req.permissions, req.archetype)
|
||||
let settings = CreateSettings::parse(&req)
|
||||
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
|
||||
|
||||
// Two checks, and the second is the one that makes the field worth
|
||||
|
|
@ -665,6 +673,7 @@ pub(crate) async fn create_agent(
|
|||
settings.inference.as_ref(),
|
||||
settings.permissions.as_ref(),
|
||||
settings.archetype.as_ref(),
|
||||
settings.agents_md.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
|
@ -706,18 +715,25 @@ struct CreateSettings {
|
|||
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
|
||||
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
|
||||
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||
agents_md: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateSettings {
|
||||
/// Each setting given, or why its `PUT` would refuse the first bad one.
|
||||
/// An archetype is checked here for its shape only; whether the swarm
|
||||
/// lists it is [`agent_archetype::check`].
|
||||
fn parse(
|
||||
limits: Option<agent_limits::Caps>,
|
||||
inference: Option<inference::InferenceSettings>,
|
||||
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
|
||||
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||
) -> Result<Self, String> {
|
||||
fn parse(req: &CreateAgentRequest) -> Result<Self, String> {
|
||||
let CreateAgentRequest {
|
||||
limits,
|
||||
inference,
|
||||
permissions,
|
||||
archetype,
|
||||
agents_md,
|
||||
..
|
||||
} = req.clone();
|
||||
if let Some(text) = &agents_md {
|
||||
agents_md_initial::check(text)?;
|
||||
}
|
||||
if let Some(a) = &archetype {
|
||||
for part in [&a.source, &a.name] {
|
||||
hive_types::Ident::parse(part)
|
||||
|
|
@ -731,6 +747,7 @@ impl CreateSettings {
|
|||
.transpose()?,
|
||||
permissions: permissions.map(agent_permissions::at_create).transpose()?,
|
||||
archetype,
|
||||
agents_md,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -740,6 +757,7 @@ impl CreateSettings {
|
|||
|| self.inference.is_some()
|
||||
|| self.permissions.is_some()
|
||||
|| self.archetype.is_some()
|
||||
|| self.agents_md.is_some()
|
||||
}
|
||||
|
||||
/// The queue to store the settings through, `None` when none is given.
|
||||
|
|
@ -767,10 +785,11 @@ struct StoredSettings {
|
|||
inference: inference::Replaced,
|
||||
permissions: agent_permissions::Replaced,
|
||||
archetype: agent_archetype::Replaced,
|
||||
agents_md: agents_md_initial::Replaced,
|
||||
}
|
||||
|
||||
impl StoredSettings {
|
||||
/// Store whichever of `limits`, `permissions`, `inference` and `archetype` are
|
||||
/// Store whichever of `limits`, `permissions`, `inference`, `archetype` and `agents_md` are
|
||||
/// given, in that order. A failure on one puts back the ones stored before it.
|
||||
async fn store(
|
||||
client: &async_nats::Client,
|
||||
|
|
@ -779,6 +798,7 @@ impl StoredSettings {
|
|||
inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
|
||||
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
|
||||
archetype: Option<&swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||
agents_md: Option<&str>,
|
||||
) -> Result<Self, problem_details::ProblemDetails> {
|
||||
let mut stored = Self::default();
|
||||
if let Some(limits) = limits {
|
||||
|
|
@ -811,6 +831,15 @@ impl StoredSettings {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(text) = agents_md {
|
||||
match agents_md_initial::store(client, agent, text).await {
|
||||
Ok(replaced) => stored.agents_md = replaced,
|
||||
Err(e) => {
|
||||
stored.roll_back(client, agent).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
|
|
@ -819,6 +848,7 @@ impl StoredSettings {
|
|||
inference::restore(client, agent, self.inference).await;
|
||||
agent_permissions::restore(client, agent, self.permissions).await;
|
||||
agent_archetype::restore(client, agent, self.archetype).await;
|
||||
agents_md_initial::restore(client, agent, self.agents_md).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1238,6 +1268,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -1280,6 +1311,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -1444,6 +1476,7 @@ mod tests {
|
|||
Some(&inference),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(stored.is_err(), "{agent}: the inference write must fail");
|
||||
|
|
@ -1583,6 +1616,7 @@ mod tests {
|
|||
inference: None,
|
||||
permissions: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
Loading…
Reference in a new issue