1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
//! The `Conversation` custom resource (`polychrome.dev/v1alpha1`).
//!
//! One `Conversation` is reconciled into one agent-sandbox `SandboxClaim`
//! (and thus one isolated harness pod). See [the reconciler](mod@crate::reconcile).
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// The condition shape (and `upsert_condition`) started life here, then moved
// to its own module once `RoutineStatus` (#1370) needed the identical
// `metav1.Condition` shape — re-exported so every existing
// `crate::conversation::{Condition, ConditionStatus, upsert_condition}`
// caller keeps working unchanged.
pub use crate::condition::{Condition, ConditionStatus, upsert_condition};
/// Finalizer key the controller adds so a `Conversation` deletion blocks until
/// its `SandboxClaim` has been cleaned up.
pub const FINALIZER: &str = "polychrome.dev/cleanup";
/// Desired state of a conversation.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
group = "polychrome.dev",
version = "v1alpha1",
kind = "Conversation",
namespaced,
status = "ConversationStatus",
shortname = "conv",
category = "polychrome",
derive = "PartialEq",
printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
printcolumn = r#"{"name":"Model","type":"string","jsonPath":".spec.model"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ConversationSpec {
/// Model identifier the planner should use (e.g. `"fast-2"`).
pub model: String,
/// The durable principal this conversation runs under: the `PersonaId`
/// (`UUIDv7`) the caller's edge identity resolved to. Personas — not raw
/// `(provider, id)` strings — are the principal (personas design §1). Empty
/// for turns with no attributable caller, which run under the system
/// principal.
// `#[serde(default)]`: a missing `principalRef` deserializes to the empty
// (system) principal instead of failing to parse, so the control plane can
// read a CR that does not carry the field. The API server does not populate
// a field a stored object was written without, so the reader tolerates its
// absence. (Plain `//` so this implementation note stays out of the CRD's
// public field description.)
#[serde(default)]
pub principal_ref: String,
/// Idle timeout, in seconds, after which the conversation (and its pod)
/// is torn down.
pub idle_timeout_seconds: u32,
/// Subagent/tool names enabled for this conversation
/// (e.g. `["slacksearch", "websearch", "py"]`).
#[serde(default)]
pub tools_enabled: Vec<String>,
/// Connector names withdrawn from this conversation. The deny wins over
/// both the operator's `defaultEnabled` broadcast and `toolsEnabled`, so
/// a misbehaving broadcast connector can be pulled from one conversation
/// without un-broadcasting it for everyone. Empty denies nothing.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools_disabled: Vec<String>,
/// Identifies the parent conversation for a handoff-created child. The
/// value forms the `conv-{parent_conversation_id}` partition suffix. The
/// controller still gives the child its own `SandboxClaim`. This metadata
/// adds the `polychrome.dev/parent-conversation` label to that claim. The
/// control plane writes the parent journal event through a separate path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_conversation_id: Option<String>,
/// Optional agent identifier this conversation should run (e.g.
/// `"researcher"`). For a top-level conversation this is the planner the
/// adapter selected; for a child it carries the parent's
/// `handoff_to(child_agent_id, …)` argument across the controller
/// boundary so the child harness knows which planner to load on its
/// first turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
}
/// Observed state, written back to the `status` subresource by the controller.
///
/// `struct_excessive_bools`: each bool is an independently-toggled wire field
/// on a Kubernetes CR status (`closed`/`harnessReady`/`idleReclaimed`/
/// `rollingHarness`), not a set of caller-supplied flags a builder API would
/// confuse for each other — a state-machine enum would still need to project
/// back onto these same independent JSON keys for `kubectl`/status-patch
/// compatibility, so it would only add an indirection layer here.
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ConversationStatus {
/// Name of the `SandboxClaim` created for this conversation, once it exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_claim_name: Option<String>,
/// IP of the harness pod, once the `SandboxClaim` reports `Ready`. Set by
/// the agent-sandbox backend; the control plane dials
/// `http://{podIp}:{harnessPort}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pod_ip: Option<String>,
/// A complete dial URL (`http://host:port`) for the harness, for a backend
/// that doesn't address the harness by a raw pod IP. No current backend
/// sets this — agent-sandbox always populates [`Self::pod_ip`] instead —
/// kept on the status schema (additive-only) for a future backend that
/// needs it. Would take precedence over [`Self::pod_ip`] on the
/// control-plane dial path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness_endpoint: Option<String>,
/// `true` once the harness is dialable.
#[serde(default)]
pub harness_ready: bool,
/// Set by the planner/adapter when the conversation should be torn down.
#[serde(default)]
pub closed: bool,
/// Coarse lifecycle phase for humans/dashboards
/// (`"Pending"`, `"Ready"`, `"Closing"`, `"Paused"`). Display only — the idle
/// reaper keys on `idle_reclaimed`, not this string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
/// Unix-seconds timestamp of the last turn, stamped by the control plane on
/// every turn. The reconciler closes a conversation idle longer than
/// `spec.idleTimeoutSeconds` so its harness pod is reaped — `ttlSecondsAfterFinished`
/// can't, because a long-running harness never "finishes" (issue #265).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity_unix: Option<i64>,
/// Whether the backend has reclaimed this idle conversation's resources
/// while keeping it resumable. No code path sets this true anymore — the
/// backend hook that used to (a resource-reclaiming idle disposition) was
/// removed along with its only implementation. Kept on the schema
/// (CRD status fields are additive-only) and still consulted defensively
/// by the idle reaper and the phase display, in case an existing object
/// already carries it true. The control plane's per-turn activity stamp
/// still clears it on every turn regardless, so a resumed conversation can
/// never get stuck showing `Paused`.
#[serde(default)]
pub idle_reclaimed: bool,
/// Standard `status.conditions`, wire-compatible with `metav1.Condition`
/// (`Ready`/`Progressing`/`Degraded`), so `kubectl get conversation` and
/// generic condition-aware tooling can reason about readiness without
/// polychrome-specific knowledge of [`Self::phase`]'s free-form strings.
/// Populated by the reconciler at every point it patches `phase` (see
/// `reconcile::conditions_triad`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditions: Vec<Condition>,
/// The harness image generation this conversation's execution unit was
/// last created or rolled onto — an operator-supplied opaque string (see
/// `POLYCHROME_HARNESS_IMAGE_GENERATION`), not something compared
/// semantically. `None` until either the roll-on-image-change feature has
/// never been enabled, or the claim predates this field. Stamped by the
/// `SyncStatus` reconcile arm once a desired generation is known (see
/// `reconcile::ReconcileAction::RollHarness`'s doc comment for the
/// adopt-without-rolling semantics), and cleared back to `None` by a
/// `RollHarness` teardown so the post-roll `SyncStatus` pass re-stamps the
/// new value through the same path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness_image_generation: Option<String>,
/// `true` from the instant a `RollHarness` teardown fires until the
/// replacement execution unit resyncs back to `Ready` — the
/// reconciler-visible "currently mid-roll" signal
/// `reconcile::Context::count_in_flight_rolls` counts to enforce the
/// harness-roll concurrency cap. Distinct from [`Self::phase`] (which is
/// display-only and gets overwritten to `"Pending"` by the very next
/// `CreateSandboxClaim` pass, well before the pod is `Ready`): this field
/// is the one the cap actually reads, so it has to survive that
/// overwrite. Cleared by the `SyncStatus` reconcile arm once the
/// replacement claim reports `harness_ready`.
#[serde(default)]
pub rolling_harness: bool,
/// Unix-seconds timestamp of the moment this conversation's execution
/// unit finished tearing down while `closed` — i.e. when
/// `sandboxClaimName` was cleared with `closed` already `true`. Stamped
/// by the `Cleanup` reconcile arm's non-finalizer-removal branch.
/// Deliberately NOT [`Self::last_activity_unix`] (the last real turn,
/// which predates closing and stays frozen through dormancy): the
/// closed-conversation GC measures retention from this stamp so an
/// operator can tune how long a fully-torn-down CR lingers independently
/// of how long the conversation took to go idle. `None` for a CR that
/// predates this field or has never fully closed — either way, GC never
/// fires without a real stamp (mirrors the idle reaper's own "no stamp ⇒
/// never closed" rule).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::ConversationSpec;
/// A Conversation spec that does not carry `principalRef` must still
/// deserialize — to the empty (system) principal — rather than failing to
/// parse. Locks the `#[serde(default)]` so the control plane can read a CR
/// written without the field.
#[test]
fn spec_without_principal_ref_deserializes_to_empty() {
let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
"model": "gemini-3-flash-preview",
"idleTimeoutSeconds": 600,
"toolsEnabled": [],
}))
.expect("a pre-principalRef spec must still deserialize");
assert!(
spec.principal_ref.is_empty(),
"a missing principalRef defaults to the empty (system) principal"
);
}
/// The per-conversation deny list is optional on the wire (a CR written
/// before the field existed still parses) and round-trips when present.
#[test]
fn tools_disabled_defaults_to_empty_and_round_trips() {
let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
"model": "m",
"idleTimeoutSeconds": 600,
}))
.expect("a spec without toolsDisabled must still deserialize");
assert!(
spec.tools_disabled.is_empty(),
"missing deny list means deny nothing"
);
let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
"model": "m",
"idleTimeoutSeconds": 600,
"toolsDisabled": ["standup"],
}))
.expect("deserialize");
assert_eq!(spec.tools_disabled, vec!["standup".to_owned()]);
}
/// A present `principalRef` still round-trips.
#[test]
fn spec_with_principal_ref_round_trips() {
let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
"model": "m",
"principalRef": "019eb8da-a39f-7dc0-af61-49c0dbbca45f",
"idleTimeoutSeconds": 600,
}))
.expect("deserialize");
assert_eq!(spec.principal_ref, "019eb8da-a39f-7dc0-af61-49c0dbbca45f");
}
}