use std::{
fmt::Write as _,
sync::{Arc, LazyLock, Mutex},
time::{Duration, Instant},
};
use futures::StreamExt;
use kube::{
Api, Client, Resource, ResourceExt,
api::{DeleteParams, ListParams, Patch, PatchParams, Preconditions},
runtime::{
controller::{Action, Controller},
watcher,
},
};
use polyc_k8s_types::sandboxclaim::SandboxClaim;
use prometheus::{IntCounter, register_int_counter};
use serde_json::json;
use crate::conversation::{Condition, ConditionStatus, Conversation, FINALIZER, upsert_condition};
use crate::execution_backend::{
ClaimReadiness, DialAddress, ExecutionBackend, SandboxClaimBackend,
};
pub const DEFAULT_TEMPLATE: &str = "polychrome-harness-default";
pub const COND_READY: &str = "Ready";
pub const COND_PROGRESSING: &str = "Progressing";
pub const COND_DEGRADED: &str = "Degraded";
pub const PHASE_READY: &str = "Ready";
pub const PHASE_PENDING: &str = "Pending";
pub const PHASE_PAUSED: &str = "Paused";
pub const PHASE_ROLLING_HARNESS: &str = "RollingHarness";
const DEFAULT_HARNESS_ROLL_CONCURRENCY: usize = 5;
const DEFAULT_CLOSED_CONVERSATION_RETENTION_SECS: i64 = 86_400;
#[must_use]
fn parse_closed_conversation_retention_seconds(raw: Option<&str>) -> Option<i64> {
match raw {
None => Some(DEFAULT_CLOSED_CONVERSATION_RETENTION_SECS),
Some(v) if v.trim().is_empty() => None,
Some(v) => match v.trim().parse::<i64>() {
Ok(n) if n > 0 => Some(n),
Ok(_) | Err(_) => None,
},
}
}
const ROLL_COUNT_CACHE_TTL: Duration = Duration::from_secs(5);
const ROLL_REQUEUE: Duration = Duration::from_secs(1);
const ROLL_DEFER_POLL: Duration = Duration::from_secs(15);
pub(crate) const PENDING_POLL: Duration = Duration::from_secs(30);
pub(crate) const SANDBOX_READY_POLL: Duration = Duration::from_mins(5);
const TEARDOWN_GRACE: Duration = Duration::from_mins(10);
fn deletion_age(conv: &Conversation) -> Duration {
use k8s_openapi::jiff::Timestamp;
conv.meta()
.deletion_timestamp
.as_ref()
.map_or(Duration::ZERO, |t| {
Timestamp::now().duration_since(t.0).unsigned_abs()
})
}
fn now_rfc3339() -> String {
k8s_openapi::jiff::Timestamp::now().to_string()
}
#[must_use]
fn conditions_triad(
previous: &[Condition],
ready: bool,
progressing: bool,
degraded: bool,
reason: &str,
message: &str,
now: &str,
) -> Vec<Condition> {
let mut conditions = previous.to_vec();
upsert_condition(
&mut conditions,
COND_READY,
ConditionStatus::from(ready),
reason,
message,
now,
);
upsert_condition(
&mut conditions,
COND_PROGRESSING,
ConditionStatus::from(progressing),
reason,
message,
now,
);
upsert_condition(
&mut conditions,
COND_DEGRADED,
ConditionStatus::from(degraded),
reason,
message,
now,
);
conditions
}
static RECONCILE_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!(
"polychrome_reconcile_total",
"Total Conversation reconcile passes"
)
.expect("register polychrome_reconcile_total")
});
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("kube api: {0}")]
Kube(#[from] kube::Error),
#[error("conversation has no namespace")]
NoNamespace,
#[error("execution backend: {0}")]
Backend(String),
}
impl From<crate::toolservice_reconcile::Error> for Error {
fn from(err: crate::toolservice_reconcile::Error) -> Self {
match err {
crate::toolservice_reconcile::Error::Kube(e) => Self::Kube(e),
crate::toolservice_reconcile::Error::NoNamespace => Self::NoNamespace,
}
}
}
impl From<crate::routine_reconcile::Error> for Error {
fn from(err: crate::routine_reconcile::Error) -> Self {
match err {
crate::routine_reconcile::Error::Kube(e) => Self::Kube(e),
crate::routine_reconcile::Error::NoNamespace => Self::NoNamespace,
}
}
}
impl From<crate::servicedefinition_reconcile::Error> for Error {
fn from(err: crate::servicedefinition_reconcile::Error) -> Self {
match err {
crate::servicedefinition_reconcile::Error::Kube(e) => Self::Kube(e),
crate::servicedefinition_reconcile::Error::NoNamespace => Self::NoNamespace,
}
}
}
impl From<crate::workflow_reconcile::Error> for Error {
fn from(err: crate::workflow_reconcile::Error) -> Self {
match err {
crate::workflow_reconcile::Error::Kube(e) => Self::Kube(e),
crate::workflow_reconcile::Error::NoNamespace => Self::NoNamespace,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconcileAction {
AddFinalizer,
CreateSandboxClaim {
claim_name: String,
template: String,
},
Cleanup {
claim_name: Option<String>,
remove_finalizer: bool,
},
SyncStatus {
claim_name: String,
},
RollHarness {
claim_name: String,
},
CloseIdle,
DeleteConversation,
Noop,
}
#[must_use]
fn unit_is_gone(
status: &crate::conversation::ConversationStatus,
readiness: &ClaimReadiness,
) -> bool {
if readiness.unit_present {
return false;
}
let was_ready = status.phase.as_deref() == Some(PHASE_READY);
let had_address = status.pod_ip.is_some() || status.harness_endpoint.is_some();
was_ready || had_address
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SyncOutcome {
Vanished,
Synced(ClaimReadiness),
}
async fn sync_execution_unit(
backend: &dyn ExecutionBackend,
conv: &Conversation,
claim_name: &str,
template: &str,
ns: &str,
) -> Result<SyncOutcome, Error> {
let current = conv.status.clone().unwrap_or_default();
let readiness = backend.readiness(claim_name, ns).await?;
if unit_is_gone(¤t, &readiness) {
return Ok(SyncOutcome::Vanished);
}
backend.ensure(conv, claim_name, template, ns).await?;
Ok(SyncOutcome::Synced(readiness))
}
#[must_use]
pub fn plan(
conv: &Conversation,
default_template: &str,
now_unix: i64,
desired_harness_generation: Option<&str>,
retention_seconds: Option<i64>,
) -> ReconcileAction {
let being_deleted = conv.meta().deletion_timestamp.is_some();
let has_finalizer = conv.finalizers().iter().any(|f| f == FINALIZER);
let closed = conv.status.as_ref().is_some_and(|s| s.closed);
let claim_name = conv
.status
.as_ref()
.and_then(|s| s.sandbox_claim_name.clone());
if being_deleted {
return if has_finalizer {
ReconcileAction::Cleanup {
claim_name,
remove_finalizer: true,
}
} else {
ReconcileAction::Noop
};
}
if closed {
return if claim_name.is_some() {
ReconcileAction::Cleanup {
claim_name,
remove_finalizer: false,
}
} else if let Some(retention) = retention_seconds
&& let Some(closed_at) = conv.status.as_ref().and_then(|s| s.closed_at)
&& now_unix.saturating_sub(closed_at) > retention
{
ReconcileAction::DeleteConversation
} else {
ReconcileAction::Noop
};
}
if !has_finalizer {
return ReconcileAction::AddFinalizer;
}
let idle_timeout = i64::from(conv.spec.idle_timeout_seconds);
if idle_timeout > 0
&& let Some(last) = conv.status.as_ref().and_then(|s| s.last_activity_unix)
&& now_unix.saturating_sub(last) > idle_timeout
&& !conv.status.as_ref().is_some_and(|s| s.idle_reclaimed)
{
return ReconcileAction::CloseIdle;
}
claim_name.map_or_else(
|| ReconcileAction::CreateSandboxClaim {
claim_name: conv.name_any(),
template: default_template.to_owned(),
},
|claim_name| {
if let Some(desired) = desired_harness_generation {
let recorded = conv
.status
.as_ref()
.and_then(|s| s.harness_image_generation.as_deref());
if let Some(recorded) = recorded
&& recorded != desired
{
return ReconcileAction::RollHarness { claim_name };
}
}
ReconcileAction::SyncStatus { claim_name }
},
)
}
pub struct Context {
pub client: Client,
pub default_template: String,
pub backend: Arc<dyn ExecutionBackend>,
pub desired_harness_generation: Option<String>,
pub harness_roll_concurrency: usize,
pub closed_conversation_retention_seconds: Option<i64>,
roll_count_cache: Mutex<RollCountCache>,
}
#[derive(Debug, Default)]
struct RollCountCache {
refreshed_at: Option<Instant>,
count: usize,
}
impl Context {
#[must_use]
pub fn new(
client: Client,
default_template: String,
backend: Arc<dyn ExecutionBackend>,
desired_harness_generation: Option<String>,
harness_roll_concurrency: usize,
closed_conversation_retention_seconds: Option<i64>,
) -> Self {
Self {
client,
default_template,
backend,
desired_harness_generation,
harness_roll_concurrency,
closed_conversation_retention_seconds,
roll_count_cache: Mutex::new(RollCountCache::default()),
}
}
async fn count_in_flight_rolls(&self, ns: &str) -> Result<usize, Error> {
let cached = self
.roll_count_cache
.lock()
.expect("roll count cache lock poisoned")
.fresh();
if let Some(count) = cached {
return Ok(count);
}
let convs: Api<Conversation> = Api::namespaced(self.client.clone(), ns);
let list = convs.list(&ListParams::default()).await?;
let count = list
.items
.iter()
.filter(|c| c.status.as_ref().is_some_and(|s| s.rolling_harness))
.count();
self.roll_count_cache
.lock()
.expect("roll count cache lock poisoned")
.store(count);
Ok(count)
}
}
impl RollCountCache {
fn fresh(&self) -> Option<usize> {
self.refreshed_at
.filter(|at| at.elapsed() < ROLL_COUNT_CACHE_TTL)
.map(|_| self.count)
}
fn store(&mut self, count: usize) {
self.refreshed_at = Some(Instant::now());
self.count = count;
}
}
#[must_use]
const fn roll_allowed(in_flight: usize, cap: usize) -> bool {
in_flight < cap
}
#[allow(clippy::too_many_lines)]
#[tracing::instrument(skip_all, fields(conversation = %conv.name_any()))]
pub async fn reconcile(conv: Arc<Conversation>, ctx: Arc<Context>) -> Result<Action, Error> {
RECONCILE_TOTAL.inc();
let ns = conv.namespace().ok_or(Error::NoNamespace)?;
let name = conv.name_any();
let convs: Api<Conversation> = Api::namespaced(ctx.client.clone(), &ns);
let pp = PatchParams::apply("polychrome.dev/controller");
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
match plan(
&conv,
&ctx.default_template,
now_unix,
ctx.desired_harness_generation.as_deref(),
ctx.closed_conversation_retention_seconds,
) {
ReconcileAction::AddFinalizer => {
let patch = json!({ "metadata": { "finalizers": [FINALIZER] } });
convs.patch(&name, &pp, &Patch::Merge(&patch)).await?;
Ok(Action::requeue(Duration::from_secs(1)))
}
ReconcileAction::CreateSandboxClaim {
claim_name,
template,
} => {
tracing::info!(
conversation = %name,
claim = %claim_name,
template = %template,
"creating sandbox claim for conversation"
);
ctx.backend
.ensure(&conv, &claim_name, &template, &ns)
.await?;
let conditions = conditions_triad(
&conv
.status
.as_ref()
.map(|s| s.conditions.clone())
.unwrap_or_default(),
false,
true,
false,
"CreatingExecutionUnit",
"waiting for the execution unit to become ready",
&now_rfc3339(),
);
let status = json!({
"status": {
"sandboxClaimName": claim_name,
"phase": PHASE_PENDING,
"conditions": conditions,
}
});
convs
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status))
.await?;
Ok(Action::requeue(PENDING_POLL))
}
ReconcileAction::Cleanup {
claim_name,
remove_finalizer,
} => {
if let Some(claim) = claim_name {
if let Err(e) = ctx.backend.teardown(&conv, &claim, &ns).await {
if deletion_age(&conv) >= TEARDOWN_GRACE {
tracing::error!(
claim = %claim,
error = %e,
grace_secs = TEARDOWN_GRACE.as_secs(),
"teardown still failing past grace; force-removing finalizer — \
execution unit may be leaked, operator should verify"
);
let patch = json!({ "metadata": { "finalizers": [] } });
convs.patch(&name, &pp, &Patch::Merge(&patch)).await?;
return Ok(Action::await_change());
}
return Err(e);
}
}
if remove_finalizer {
let patch = json!({ "metadata": { "finalizers": [] } });
convs.patch(&name, &pp, &Patch::Merge(&patch)).await?;
Ok(Action::await_change())
} else {
let current = conv.status.clone().unwrap_or_default();
let conditions = conditions_triad(
¤t.conditions,
false,
true,
false,
"TearingDown",
"deleting the execution unit",
&now_rfc3339(),
);
let patch = json!({ "status": {
"sandboxClaimName": null,
"podIp": null,
"harnessReady": false,
"phase": "Closing",
"closedAt": now_unix,
"conditions": conditions,
} });
convs
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
Ok(Action::requeue(Duration::from_secs(5)))
}
}
ReconcileAction::SyncStatus { claim_name } => {
let current = conv.status.clone().unwrap_or_default();
let outcome = sync_execution_unit(
ctx.backend.as_ref(),
&conv,
&claim_name,
&ctx.default_template,
&ns,
)
.await?;
let readiness = match outcome {
SyncOutcome::Vanished => {
let conditions = conditions_triad(
¤t.conditions,
false,
true,
true,
"ExecutionUnitVanished",
"the execution unit disappeared out-of-band; recreating it",
&now_rfc3339(),
);
let patch = json!({ "status": {
"sandboxClaimName": null,
"podIp": null,
"harnessEndpoint": null,
"harnessReady": false,
"phase": PHASE_PENDING,
"conditions": conditions,
} });
convs
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
tracing::warn!(
claim = %claim_name,
"execution unit vanished; cleared claim name to trigger re-create"
);
return Ok(Action::requeue(Duration::from_secs(1)));
}
SyncOutcome::Synced(readiness) => readiness,
};
let (pod_ip, harness_endpoint) = readiness
.address
.as_ref()
.map_or((None, None), DialAddress::to_status_fields);
let phase = if current.idle_reclaimed {
PHASE_PAUSED
} else if readiness.harness_ready {
PHASE_READY
} else {
PHASE_PENDING
};
let generation_to_stamp = ctx
.desired_harness_generation
.as_deref()
.filter(|_| current.harness_image_generation.is_none());
let clear_rolling = current.rolling_harness && readiness.harness_ready;
if current.pod_ip != pod_ip
|| current.harness_endpoint != harness_endpoint
|| current.harness_ready != readiness.harness_ready
|| current.phase.as_deref() != Some(phase)
|| generation_to_stamp.is_some()
|| clear_rolling
{
let (ready, progressing, reason, message) = if current.idle_reclaimed {
(
false,
false,
"IdleReclaimed",
"the execution unit's worker was reclaimed while idle; it resumes on the next turn",
)
} else if readiness.harness_ready {
(true, false, "HarnessReady", "the harness is dialable")
} else {
(
false,
true,
"WaitingForHarness",
"waiting for the harness to become dialable",
)
};
let conditions = conditions_triad(
¤t.conditions,
ready,
progressing,
false,
reason,
message,
&now_rfc3339(),
);
let mut patch = json!({ "status": {
"podIp": pod_ip,
"harnessEndpoint": harness_endpoint,
"harnessReady": readiness.harness_ready,
"phase": phase,
"conditions": conditions,
} });
if let Some(desired) = generation_to_stamp {
patch["status"]["harnessImageGeneration"] = json!(desired);
}
if clear_rolling {
patch["status"]["rollingHarness"] = json!(false);
}
convs
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
tracing::info!(
pod_ip = ?pod_ip,
harness_endpoint = ?harness_endpoint,
harness_ready = readiness.harness_ready,
generation_stamped = ?generation_to_stamp,
"synced conversation status from execution unit"
);
}
let next = if readiness.harness_ready {
ctx.backend.ready_poll_interval()
} else {
PENDING_POLL
};
Ok(Action::requeue(next))
}
ReconcileAction::RollHarness { claim_name } => {
let in_flight = ctx.count_in_flight_rolls(&ns).await?;
if !roll_allowed(in_flight, ctx.harness_roll_concurrency) {
tracing::info!(
claim = %claim_name,
in_flight,
cap = ctx.harness_roll_concurrency,
"deferring harness roll: concurrency cap reached"
);
return Ok(Action::requeue(ROLL_DEFER_POLL));
}
ctx.backend.teardown(&conv, &claim_name, &ns).await?;
let current = conv.status.clone().unwrap_or_default();
let conditions = conditions_triad(
¤t.conditions,
false,
true,
false,
"RollingHarnessImage",
"tearing the execution unit down to roll it onto a newer harness image",
&now_rfc3339(),
);
let patch = json!({ "status": {
"sandboxClaimName": null,
"podIp": null,
"harnessEndpoint": null,
"harnessReady": false,
"harnessImageGeneration": null,
"phase": PHASE_ROLLING_HARNESS,
"rollingHarness": true,
"conditions": conditions,
} });
convs
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
tracing::info!(
claim = %claim_name,
"tore down execution unit to roll it onto a new harness image"
);
Ok(Action::requeue(ROLL_REQUEUE))
}
ReconcileAction::CloseIdle => {
let current = conv.status.clone().unwrap_or_default();
let conditions = conditions_triad(
¤t.conditions,
false,
true,
false,
"ClosingIdleConversation",
"idle past the timeout; tearing the execution unit down",
&now_rfc3339(),
);
let patch = json!({ "status": {
"closed": true,
"phase": "Closing",
"conditions": conditions,
} });
convs
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
tracing::info!(conversation = %name, "closing idle conversation (idle GC)");
Ok(Action::requeue(Duration::from_secs(5)))
}
ReconcileAction::DeleteConversation => {
let dp = DeleteParams {
preconditions: Some(Preconditions {
resource_version: conv.resource_version(),
uid: conv.uid(),
}),
..DeleteParams::default()
};
match convs.delete(&name, &dp).await {
Ok(_) => {
tracing::info!(
conversation = %name,
"deleting fully-closed conversation past its retention window"
);
Ok(Action::await_change())
}
Err(kube::Error::Api(e)) if e.code == 409 => {
tracing::info!(
conversation = %name,
"conversation changed since it was planned for deletion \
(likely resumed); skipping this pass"
);
Ok(Action::requeue(Duration::from_secs(5)))
}
Err(e) => Err(Error::Kube(e)),
}
}
ReconcileAction::Noop => Ok(Action::requeue(Duration::from_mins(5))),
}
}
pub(crate) fn ignore_not_found<T>(res: Result<T, kube::Error>) -> Result<(), Error> {
match res {
Ok(_) => Ok(()),
Err(kube::Error::Api(e)) if e.code == 404 => Ok(()),
Err(e) => Err(Error::Kube(e)),
}
}
const ERROR_BACKOFF_MIN_SECS: u64 = 10;
const ERROR_BACKOFF_MAX_SECS: u64 = 60;
fn error_backoff(name: &str) -> Duration {
crate::jitter::within_window(name, ERROR_BACKOFF_MIN_SECS, ERROR_BACKOFF_MAX_SECS)
}
fn error_chain(err: &(dyn std::error::Error + 'static)) -> String {
let mut msg = err.to_string();
let mut cause = err.source();
while let Some(e) = cause {
write!(msg, "; caused by: {e}").expect("String write is infallible");
cause = e.source();
}
msg
}
#[allow(clippy::needless_pass_by_value)]
#[must_use]
pub fn error_policy(conv: Arc<Conversation>, err: &Error, _ctx: Arc<Context>) -> Action {
let delay = error_backoff(&conv.name_any());
tracing::warn!(error = %err, requeue_secs = delay.as_secs(), "reconcile failed; requeuing");
Action::requeue(delay)
}
pub async fn run(
client: Client,
watch_client: Client,
namespace: &str,
apps_namespace: &str,
) -> Result<(), Error> {
let conversations = run_conversation(client.clone(), watch_client.clone(), namespace);
let toolservices = crate::toolservice_reconcile::run_toolservice(
client.clone(),
watch_client.clone(),
namespace,
);
let routines =
crate::routine_reconcile::run_routine(client.clone(), watch_client.clone(), namespace);
let servicedefinitions = crate::servicedefinition_reconcile::run_servicedefinition(
client.clone(),
watch_client.clone(),
apps_namespace,
);
let workflows = crate::workflow_reconcile::run_workflow(client, watch_client, apps_namespace);
let (conv_res, ts_res, rtn_res, sd_res, wf_res) = tokio::join!(
conversations,
toolservices,
routines,
servicedefinitions,
workflows
);
conv_res?;
ts_res?;
rtn_res?;
sd_res?;
wf_res?;
Ok(())
}
async fn run_conversation(
client: Client,
watch_client: Client,
namespace: &str,
) -> Result<(), Error> {
let convs: Api<Conversation> = Api::namespaced(watch_client.clone(), namespace);
let desired_harness_generation = std::env::var("POLYCHROME_HARNESS_IMAGE_GENERATION")
.ok()
.filter(|v| !v.trim().is_empty());
let harness_roll_concurrency = std::env::var("POLYCHROME_HARNESS_ROLL_CONCURRENCY")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_HARNESS_ROLL_CONCURRENCY);
let closed_conversation_retention_seconds = parse_closed_conversation_retention_seconds(
std::env::var("POLYCHROME_CLOSED_CONVERSATION_RETENTION_SECONDS")
.ok()
.as_deref(),
);
tracing::info!(
desired_harness_generation = ?desired_harness_generation,
harness_roll_concurrency,
closed_conversation_retention_seconds = ?closed_conversation_retention_seconds,
"harness image-generation roll + closed-conversation GC configured"
);
let claims: Api<SandboxClaim> = Api::namespaced(watch_client, namespace);
let ctx = Arc::new(Context::new(
client.clone(),
DEFAULT_TEMPLATE.to_owned(),
Arc::new(SandboxClaimBackend::new(client)),
desired_harness_generation,
harness_roll_concurrency,
closed_conversation_retention_seconds,
));
let controller =
Controller::new(convs, watcher::Config::default()).owns(claims, watcher::Config::default());
controller
.run(reconcile, error_policy, ctx)
.for_each(|res| async move {
if let Err(e) = res {
tracing::warn!(error = %error_chain(&e), "reconcile stream item errored");
}
})
.await;
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use kube::api::ObjectMeta;
use super::*;
use crate::conversation::{ConversationSpec, ConversationStatus};
const TPL: &str = "polychrome-harness-default";
fn conv(name: &str) -> Conversation {
Conversation::new(
name,
ConversationSpec {
model: "fast-2".to_owned(),
principal_ref: "persona-test-1".to_owned(),
idle_timeout_seconds: 300,
tools_enabled: vec![],
tools_disabled: vec![],
parent_conversation_id: None,
agent_id: None,
},
)
}
#[derive(Debug, thiserror::Error)]
#[error("root cause")]
struct RootCause;
#[derive(Debug, thiserror::Error)]
#[error("middle layer")]
struct MiddleLayer(#[source] RootCause);
#[derive(Debug, thiserror::Error)]
#[error("event queue error")]
struct OuterQueueError(#[source] MiddleLayer);
#[test]
fn error_chain_surfaces_every_source_not_just_the_outer_display() {
let err = OuterQueueError(MiddleLayer(RootCause));
let rendered = error_chain(&err);
assert!(rendered.contains("event queue error"));
assert!(rendered.contains("middle layer"));
assert!(rendered.contains("root cause"));
}
#[test]
fn fresh_conversation_gets_a_finalizer_first() {
assert_eq!(
plan(&conv("c1"), TPL, 0, None, None),
ReconcileAction::AddFinalizer
);
}
#[test]
fn with_finalizer_and_no_claim_creates_one() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::CreateSandboxClaim {
claim_name: "c1".to_owned(),
template: TPL.to_owned(),
}
);
}
#[test]
fn with_claim_recorded_syncs_status() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::SyncStatus {
claim_name: "c1".to_owned(),
}
);
}
#[test]
fn desired_generation_none_never_rolls() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: Some("2026.6.0".to_owned()),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::SyncStatus {
claim_name: "c1".to_owned(),
}
);
}
#[test]
fn recorded_none_desired_some_adopts_without_rolling() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: None,
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, Some("2026.7.0"), None),
ReconcileAction::SyncStatus {
claim_name: "c1".to_owned(),
},
"a never-recorded generation must adopt via SyncStatus, never RollHarness"
);
}
#[test]
fn recorded_equals_desired_stays_steady_state() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: Some("2026.7.0".to_owned()),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, Some("2026.7.0"), None),
ReconcileAction::SyncStatus {
claim_name: "c1".to_owned(),
}
);
}
#[test]
fn recorded_differs_from_desired_rolls() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: Some("2026.6.0".to_owned()),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, Some("2026.7.0"), None),
ReconcileAction::RollHarness {
claim_name: "c1".to_owned(),
}
);
}
#[test]
fn idle_conversation_past_timeout_is_closed() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
last_activity_unix: Some(1_000),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 1_000 + 301, None, None),
ReconcileAction::CloseIdle
);
assert_eq!(
plan(&c, TPL, 1_000 + 200, None, None),
ReconcileAction::SyncStatus {
claim_name: "c1".to_owned(),
}
);
}
#[test]
fn no_activity_stamp_is_never_closed() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
last_activity_unix: None,
..Default::default()
});
assert_eq!(
plan(&c, TPL, 10_000_000_000, None, None),
ReconcileAction::SyncStatus {
claim_name: "c1".to_owned(),
}
);
}
#[test]
fn closed_with_claim_cleans_up_but_keeps_finalizer() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
closed: true,
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::Cleanup {
claim_name: Some("c1".to_owned()),
remove_finalizer: false,
}
);
}
#[test]
fn closed_after_claim_cleared_settles_to_noop() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
closed: true,
..Default::default()
});
assert_eq!(plan(&c, TPL, 0, None, None), ReconcileAction::Noop);
}
#[test]
fn closed_with_no_closed_at_stamp_never_gcs_even_with_retention_configured() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
closed: true,
..Default::default()
});
assert_eq!(
plan(&c, TPL, 10_000_000, None, Some(60)),
ReconcileAction::Noop
);
}
#[test]
fn closed_within_retention_window_stays_noop() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
closed: true,
closed_at: Some(1_000),
..Default::default()
});
assert_eq!(plan(&c, TPL, 1_050, None, Some(60)), ReconcileAction::Noop);
}
#[test]
fn closed_exactly_at_retention_boundary_stays_noop() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
closed: true,
closed_at: Some(1_000),
..Default::default()
});
assert_eq!(plan(&c, TPL, 1_060, None, Some(60)), ReconcileAction::Noop);
}
#[test]
fn closed_past_retention_window_deletes_the_conversation() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
closed: true,
closed_at: Some(1_000),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 1_061, None, Some(60)),
ReconcileAction::DeleteConversation
);
}
#[test]
fn closed_past_retention_window_stays_noop_when_gc_disabled() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
closed: true,
closed_at: Some(1_000),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 100_000_000, None, None),
ReconcileAction::Noop
);
}
#[test]
fn closed_with_claim_still_present_ignores_retention() {
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
closed: true,
closed_at: Some(1_000),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 100_000_000, None, Some(60)),
ReconcileAction::Cleanup {
claim_name: Some("c1".to_owned()),
remove_finalizer: false,
}
);
}
#[test]
fn retention_env_unset_defaults_to_enabled() {
assert_eq!(
parse_closed_conversation_retention_seconds(None),
Some(DEFAULT_CLOSED_CONVERSATION_RETENTION_SECS)
);
}
#[test]
fn retention_env_empty_or_zero_disables() {
assert_eq!(parse_closed_conversation_retention_seconds(Some("")), None);
assert_eq!(
parse_closed_conversation_retention_seconds(Some(" ")),
None
);
assert_eq!(parse_closed_conversation_retention_seconds(Some("0")), None);
}
#[test]
fn retention_env_positive_integer_is_used_verbatim() {
assert_eq!(
parse_closed_conversation_retention_seconds(Some("3600")),
Some(3_600)
);
}
#[test]
fn retention_env_negative_or_malformed_fails_safe_to_disabled() {
assert_eq!(
parse_closed_conversation_retention_seconds(Some("-1")),
None
);
assert_eq!(
parse_closed_conversation_retention_seconds(Some("off")),
None
);
assert_eq!(
parse_closed_conversation_retention_seconds(Some("disabled")),
None
);
}
#[test]
fn deleting_with_finalizer_cleans_up_and_removes_finalizer() {
let mut c = conv("c1");
c.metadata = ObjectMeta {
name: Some("c1".to_owned()),
finalizers: Some(vec![FINALIZER.to_owned()]),
deletion_timestamp: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
"2026-05-27T00:00:00Z".parse().unwrap(),
)),
..Default::default()
};
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::Cleanup {
claim_name: Some("c1".to_owned()),
remove_finalizer: true,
}
);
}
#[test]
fn child_conversation_plans_same_as_parent() {
let mut c = Conversation::new(
"child-7",
ConversationSpec {
model: "fast-2".to_owned(),
principal_ref: "persona-test-1".to_owned(),
idle_timeout_seconds: 300,
tools_enabled: vec![],
tools_disabled: vec![],
parent_conversation_id: Some("parent-1".to_owned()),
agent_id: Some("researcher".to_owned()),
},
);
assert_eq!(plan(&c, TPL, 0, None, None), ReconcileAction::AddFinalizer);
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::CreateSandboxClaim {
claim_name: "child-7".to_owned(),
template: TPL.to_owned(),
}
);
}
#[test]
fn unit_gone_after_previously_ready_triggers_reclaim() {
let status = ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
phase: Some("Ready".to_owned()),
..Default::default()
};
assert!(unit_is_gone(&status, &ClaimReadiness::default()));
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: None,
..Default::default()
});
assert_eq!(
plan(&c, TPL, 0, None, None),
ReconcileAction::CreateSandboxClaim {
claim_name: "c1".to_owned(),
template: TPL.to_owned(),
}
);
}
#[test]
fn unit_gone_after_prior_address_triggers_reclaim() {
let status = ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
pod_ip: Some("10.4.2.7".to_owned()),
..Default::default()
};
assert!(unit_is_gone(&status, &ClaimReadiness::default()));
let status = ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_endpoint: Some("http://router/c1:8080".to_owned()),
..Default::default()
};
assert!(unit_is_gone(&status, &ClaimReadiness::default()));
}
#[test]
fn still_starting_unit_is_not_treated_as_gone() {
let status = ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
phase: Some("Pending".to_owned()),
..Default::default()
};
assert!(!unit_is_gone(&status, &ClaimReadiness::default()));
}
#[test]
fn healthy_unit_read_is_not_gone() {
let status = ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
phase: Some(PHASE_READY.to_owned()),
pod_ip: Some("10.4.2.7".to_owned()),
harness_ready: true,
..Default::default()
};
let readiness = ClaimReadiness {
unit_present: true,
address: Some(DialAddress::PodIp("10.4.2.7".to_owned())),
harness_ready: true,
};
assert!(!unit_is_gone(&status, &readiness));
}
#[test]
fn present_but_transiently_unready_unit_is_not_gone() {
let status = ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
phase: Some(PHASE_READY.to_owned()),
pod_ip: Some("10.4.2.7".to_owned()),
harness_ready: true,
..Default::default()
};
let restarting = ClaimReadiness {
unit_present: true,
address: None,
harness_ready: false,
};
assert!(!unit_is_gone(&status, &restarting));
}
#[test]
fn error_backoff_is_bounded_to_window() {
for n in 0..1000 {
let d = error_backoff(&format!("conv-{n}")).as_secs();
assert!(
(ERROR_BACKOFF_MIN_SECS..=ERROR_BACKOFF_MAX_SECS).contains(&d),
"backoff {d}s out of [{ERROR_BACKOFF_MIN_SECS}, {ERROR_BACKOFF_MAX_SECS}]"
);
}
}
#[test]
fn error_backoff_is_deterministic_and_desynchronised() {
assert_eq!(error_backoff("c1"), error_backoff("c1"));
let delays: std::collections::HashSet<u64> = (0..200)
.map(|n| error_backoff(&format!("conv-{n}")).as_secs())
.collect();
assert!(delays.len() > 1, "all conversations retried in lockstep");
}
#[test]
fn deleting_without_finalizer_is_noop() {
let mut c = conv("c1");
c.metadata.deletion_timestamp = Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
"2026-05-27T00:00:00Z".parse().unwrap(),
));
assert_eq!(plan(&c, TPL, 0, None, None), ReconcileAction::Noop);
}
#[derive(Default)]
struct RecordingBackend {
ensure_calls: std::sync::atomic::AtomicUsize,
teardown_calls: std::sync::atomic::AtomicUsize,
readiness: ClaimReadiness,
}
#[async_trait::async_trait]
impl ExecutionBackend for RecordingBackend {
async fn ensure(
&self,
_conv: &Conversation,
_name: &str,
_template: &str,
_ns: &str,
) -> Result<(), Error> {
self.ensure_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
async fn readiness(&self, _name: &str, _ns: &str) -> Result<ClaimReadiness, Error> {
Ok(self.readiness.clone())
}
async fn teardown(
&self,
_owner: &Conversation,
_name: &str,
_ns: &str,
) -> Result<(), Error> {
self.teardown_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
fn kind(&self) -> &'static str {
"recording-mock"
}
}
#[tokio::test]
async fn sync_status_heals_spec_drift_by_reapplying_desired_spec() {
let backend = RecordingBackend {
readiness: ClaimReadiness {
unit_present: true,
address: None,
harness_ready: false,
},
..Default::default()
};
let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
phase: Some(PHASE_PENDING.to_owned()),
..Default::default()
});
let outcome = sync_execution_unit(&backend, &c, "c1", TPL, "ns")
.await
.expect("sync_execution_unit");
assert!(
matches!(outcome, SyncOutcome::Synced(_)),
"unit is present; must not be treated as vanished"
);
assert_eq!(
backend
.ensure_calls
.load(std::sync::atomic::Ordering::SeqCst),
1,
"SyncStatus must re-apply the desired spec (drift healing), not just read readiness"
);
}
#[tokio::test]
async fn sync_status_does_not_heal_a_vanished_unit() {
let backend = RecordingBackend::default(); let mut c = conv("c1");
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
phase: Some(PHASE_READY.to_owned()),
..Default::default()
});
let outcome = sync_execution_unit(&backend, &c, "c1", TPL, "ns")
.await
.expect("sync_execution_unit");
assert!(matches!(outcome, SyncOutcome::Vanished));
assert_eq!(
backend
.ensure_calls
.load(std::sync::atomic::Ordering::SeqCst),
0,
"a vanished unit must not be re-created within the same pass"
);
}
#[test]
fn conditions_triad_marks_ready_when_ready_true() {
let conditions = conditions_triad(&[], true, false, false, "HarnessReady", "", "now");
assert_eq!(conditions.len(), 3);
let get = |t: &str| conditions.iter().find(|c| c.type_ == t).unwrap().status;
assert_eq!(get(COND_READY), crate::conversation::ConditionStatus::True);
assert_eq!(
get(COND_PROGRESSING),
crate::conversation::ConditionStatus::False
);
assert_eq!(
get(COND_DEGRADED),
crate::conversation::ConditionStatus::False
);
}
#[test]
fn conditions_triad_preserves_transition_time_across_unchanged_passes() {
let first = conditions_triad(&[], false, true, false, "Provisioning", "", "t0");
let second = conditions_triad(&first, false, true, false, "Provisioning", "", "t1");
let progressing = |cs: &[crate::conversation::Condition]| {
cs.iter()
.find(|c| c.type_ == COND_PROGRESSING)
.unwrap()
.last_transition_time
.clone()
};
assert_eq!(progressing(&first), "t0");
assert_eq!(
progressing(&second),
"t0",
"status unchanged across passes must not bump lastTransitionTime"
);
let third = conditions_triad(&second, true, false, false, "HarnessReady", "", "t2");
let ready = third
.iter()
.find(|c| c.type_ == COND_READY)
.unwrap()
.last_transition_time
.clone();
assert_eq!(ready, "t2");
}
#[test]
fn roll_allowed_under_and_at_cap() {
assert!(roll_allowed(0, 5), "well under cap");
assert!(roll_allowed(4, 5), "one below cap");
assert!(!roll_allowed(5, 5), "at cap must defer");
assert!(!roll_allowed(6, 5), "over cap must defer");
}
mod fake_conversations {
use std::sync::{Arc, Mutex};
use http::{Method, StatusCode};
use serde_json::{Value, json};
pub(super) fn client(items: Vec<Value>, patches: Arc<Mutex<Vec<Value>>>) -> kube::Client {
let svc = tower::service_fn(move |req: http::Request<kube::client::Body>| {
let items = items.clone();
let patches = patches.clone();
async move {
let method = req.method().clone();
let path = req.uri().path().to_owned();
let body = req
.into_body()
.collect_bytes()
.await
.map(|b| b.to_vec())
.unwrap_or_default();
let (status, payload) = if method == Method::GET {
let list = json!({
"apiVersion": "polychrome.dev/v1alpha1",
"kind": "ConversationList",
"items": items,
});
(StatusCode::OK, serde_json::to_vec(&list).unwrap())
} else if method == Method::PATCH {
let patch: Value = serde_json::from_slice(&body).unwrap_or_default();
patches.lock().expect("patches lock poisoned").push(patch);
let name = path
.trim_end_matches("/status")
.rsplit('/')
.next()
.unwrap_or("c1");
let echoed = json!({
"apiVersion": "polychrome.dev/v1alpha1",
"kind": "Conversation",
"metadata": { "name": name },
"spec": { "model": "m", "idleTimeoutSeconds": 300 },
"status": {},
});
(StatusCode::OK, serde_json::to_vec(&echoed).unwrap())
} else {
(StatusCode::METHOD_NOT_ALLOWED, Vec::new())
};
let resp = http::Response::builder()
.status(status)
.header("content-type", "application/json")
.body(kube::client::Body::from(payload))
.expect("build fake response");
Ok::<_, std::convert::Infallible>(resp)
}
});
kube::Client::new(svc, "test-ns")
}
pub(super) fn item(name: &str, rolling_harness: bool) -> Value {
json!({
"apiVersion": "polychrome.dev/v1alpha1",
"kind": "Conversation",
"metadata": { "name": name },
"spec": { "model": "m", "idleTimeoutSeconds": 300 },
"status": { "rollingHarness": rolling_harness },
})
}
}
fn fake_ctx(
client: kube::Client,
backend: Arc<RecordingBackend>,
desired_harness_generation: Option<&str>,
harness_roll_concurrency: usize,
) -> Arc<Context> {
Arc::new(Context::new(
client,
TPL.to_owned(),
backend,
desired_harness_generation.map(str::to_owned),
harness_roll_concurrency,
None,
))
}
#[tokio::test]
async fn roll_harness_defers_at_cap_without_tearing_down() {
let patches = Arc::new(Mutex::new(Vec::new()));
let client = fake_conversations::client(
vec![fake_conversations::item("other-conv", true)],
patches.clone(),
);
let backend = Arc::new(RecordingBackend {
readiness: ClaimReadiness::default(),
..Default::default()
});
let ctx = fake_ctx(client, backend.clone(), Some("2026.7.0"), 1);
let mut c = conv("c1");
c.metadata.namespace = Some("test-ns".to_owned());
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: Some("2026.6.0".to_owned()),
..Default::default()
});
reconcile(Arc::new(c), ctx)
.await
.expect("reconcile succeeds even when the roll is deferred");
assert_eq!(
backend
.teardown_calls
.load(std::sync::atomic::Ordering::SeqCst),
0,
"a roll deferred by the concurrency cap must never reach the backend's teardown"
);
assert!(
patches.lock().unwrap().is_empty(),
"a deferred roll must not touch the Conversation status at all"
);
}
#[tokio::test]
async fn roll_harness_proceeds_under_cap() {
let patches = Arc::new(Mutex::new(Vec::new()));
let client = fake_conversations::client(vec![], patches.clone());
let backend = Arc::new(RecordingBackend {
readiness: ClaimReadiness::default(),
..Default::default()
});
let ctx = fake_ctx(client, backend.clone(), Some("2026.7.0"), 5);
let mut c = conv("c1");
c.metadata.namespace = Some("test-ns".to_owned());
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: Some("2026.6.0".to_owned()),
..Default::default()
});
reconcile(Arc::new(c), ctx).await.expect("reconcile");
assert_eq!(
backend
.teardown_calls
.load(std::sync::atomic::Ordering::SeqCst),
1,
"under cap, the roll must tear the claim down exactly once"
);
let recorded = patches.lock().unwrap();
assert_eq!(recorded.len(), 1, "exactly one status patch");
let status = &recorded[0]["status"];
assert_eq!(status["sandboxClaimName"], serde_json::Value::Null);
assert_eq!(status["harnessReady"], serde_json::Value::Bool(false));
assert_eq!(status["harnessImageGeneration"], serde_json::Value::Null);
assert_eq!(
status["rollingHarness"],
serde_json::Value::Bool(true),
"the concurrency cap's own signal must be set the instant the teardown fires"
);
assert_eq!(
status["phase"],
serde_json::Value::String(PHASE_ROLLING_HARNESS.to_owned())
);
}
#[tokio::test]
async fn sync_status_adopts_a_never_recorded_generation_without_rolling() {
let patches = Arc::new(Mutex::new(Vec::new()));
let client = fake_conversations::client(vec![], patches.clone());
let backend = Arc::new(RecordingBackend {
readiness: ClaimReadiness {
unit_present: true,
address: Some(DialAddress::PodIp("10.4.2.7".to_owned())),
harness_ready: true,
},
..Default::default()
});
let ctx = fake_ctx(client, backend.clone(), Some("2026.7.0"), 5);
let mut c = conv("c1");
c.metadata.namespace = Some("test-ns".to_owned());
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.status = Some(ConversationStatus {
sandbox_claim_name: Some("c1".to_owned()),
harness_image_generation: None,
pod_ip: Some("10.4.2.7".to_owned()),
harness_ready: true,
phase: Some(PHASE_READY.to_owned()),
..Default::default()
});
reconcile(Arc::new(c), ctx).await.expect("reconcile");
assert_eq!(
backend
.teardown_calls
.load(std::sync::atomic::Ordering::SeqCst),
0,
"adopting a never-recorded generation must never tear the claim down"
);
let recorded = patches.lock().unwrap();
assert_eq!(
recorded.len(),
1,
"the adopt stamp must still send a patch even though pod_ip/harness_ready/phase are unchanged"
);
assert_eq!(
recorded[0]["status"]["harnessImageGeneration"],
serde_json::Value::String("2026.7.0".to_owned())
);
}
fn fake_delete_client(
response_status: http::StatusCode,
deletes: Arc<Mutex<Vec<serde_json::Value>>>,
) -> kube::Client {
let svc = tower::service_fn(move |req: http::Request<kube::client::Body>| {
let deletes = deletes.clone();
let status = response_status;
async move {
let body = req
.into_body()
.collect_bytes()
.await
.map(|b| b.to_vec())
.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
deletes.lock().expect("deletes lock poisoned").push(parsed);
let payload = if status.is_success() {
serde_json::json!({
"apiVersion": "polychrome.dev/v1alpha1",
"kind": "Conversation",
"metadata": { "name": "c1" },
"spec": { "model": "m", "idleTimeoutSeconds": 300 },
"status": {},
})
} else {
serde_json::json!({
"kind": "Status",
"apiVersion": "v1",
"status": "Failure",
"message": "the object has been modified; please apply your changes \
to the latest version and try again",
"reason": "Conflict",
"code": status.as_u16(),
})
};
let resp = http::Response::builder()
.status(status)
.header("content-type", "application/json")
.body(kube::client::Body::from(
serde_json::to_vec(&payload).unwrap(),
))
.expect("build fake response");
Ok::<_, std::convert::Infallible>(resp)
}
});
kube::Client::new(svc, "test-ns")
}
fn conv_past_retention() -> Conversation {
let mut c = conv("c1");
c.metadata.namespace = Some("test-ns".to_owned());
c.metadata.finalizers = Some(vec![FINALIZER.to_owned()]);
c.metadata.resource_version = Some("42".to_owned());
c.metadata.uid = Some("uid-c1".to_owned());
c.status = Some(ConversationStatus {
closed: true,
closed_at: Some(1_000),
..Default::default()
});
c
}
#[tokio::test]
async fn delete_conversation_sends_a_resource_version_precondition() {
let deletes = Arc::new(Mutex::new(Vec::new()));
let client = fake_delete_client(http::StatusCode::OK, deletes.clone());
let backend = Arc::new(RecordingBackend::default());
let ctx = Arc::new(Context::new(
client,
TPL.to_owned(),
backend,
None,
DEFAULT_HARNESS_ROLL_CONCURRENCY,
Some(60),
));
reconcile(Arc::new(conv_past_retention()), ctx)
.await
.expect("a successful delete must not error");
let recorded = deletes.lock().unwrap();
assert_eq!(recorded.len(), 1, "exactly one delete call");
assert_eq!(
recorded[0]["preconditions"]["resourceVersion"],
serde_json::Value::String("42".to_owned()),
"the delete must be conditioned on the exact object `plan` observed, so a \
concurrent resume (a status patch bumping resourceVersion) makes it fail \
instead of silently discarding the resume"
);
}
#[tokio::test]
async fn delete_conversation_conflict_from_a_concurrent_change_is_not_an_error() {
let deletes = Arc::new(Mutex::new(Vec::new()));
let client = fake_delete_client(http::StatusCode::CONFLICT, deletes.clone());
let backend = Arc::new(RecordingBackend::default());
let ctx = Arc::new(Context::new(
client,
TPL.to_owned(),
backend,
None,
DEFAULT_HARNESS_ROLL_CONCURRENCY,
Some(60),
));
let action = reconcile(Arc::new(conv_past_retention()), ctx)
.await
.expect(
"a 409 from a concurrent change (e.g. a resume racing the delete) must be \
treated as benign, not propagated as a reconcile error",
);
assert_eq!(
action,
Action::requeue(Duration::from_secs(5)),
"must requeue promptly to re-evaluate fresh state, not back off as if this were \
a real failure"
);
}
}