chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -366,11 +366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_two_users() {
|
||||
assert_eq!(
|
||||
Conversation::try_new(
|
||||
conv_id(1),
|
||||
ConversationParty::User,
|
||||
ConversationParty::User
|
||||
),
|
||||
Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User),
|
||||
Err(ConversationError::TwoUsers)
|
||||
);
|
||||
}
|
||||
@ -428,10 +424,7 @@ mod tests {
|
||||
// A→B exists; B→C is fine (no path C→…→B).
|
||||
let mut g = WaitForGraph::new();
|
||||
g.add_edge(agent(1), agent(2)); // A waits B
|
||||
assert!(
|
||||
!g.would_cycle(agent(2), agent(3)),
|
||||
"A→B→C is acyclic"
|
||||
);
|
||||
assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -31,9 +31,7 @@ use crate::ports::StoreError;
|
||||
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
|
||||
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
|
||||
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
|
||||
)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TurnId(pub uuid::Uuid);
|
||||
|
||||
@ -199,11 +197,7 @@ pub struct Handoff {
|
||||
impl Handoff {
|
||||
/// Construit un handoff à partir de ses composants.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
summary_md: impl Into<String>,
|
||||
up_to: TurnId,
|
||||
objective: Option<String>,
|
||||
) -> Self {
|
||||
pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> Self {
|
||||
Self {
|
||||
summary_md: summary_md.into(),
|
||||
up_to,
|
||||
@ -235,11 +229,7 @@ pub trait HandoffStore: Send + Sync {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError>;
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Le résumeur incrémental de handoff (port driving, lot P4).
|
||||
@ -435,7 +425,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn turn_role_serializes_camel_case() {
|
||||
assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Prompt).unwrap(),
|
||||
"\"prompt\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Response).unwrap(),
|
||||
"\"response\""
|
||||
@ -581,7 +574,11 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let last2 = log.last(c, 2).await.unwrap();
|
||||
assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order");
|
||||
assert_eq!(
|
||||
texts(&last2),
|
||||
vec!["c", "d"],
|
||||
"the 2 last, in insertion order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -599,7 +596,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
// A cursor from one thread never leaks tours from another.
|
||||
assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -173,6 +174,28 @@ pub enum DomainEvent {
|
||||
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the
|
||||
/// backend stays the authority of the FIFO/busy state and **no longer writes the
|
||||
/// turn into the PTY** — the frontend cell runs the write-portal handshake and
|
||||
/// writes `text` + `submit_sequence` through the single PTY writer (the front).
|
||||
/// Discrete, low-frequency (one per delegation). Relayed to the front as
|
||||
/// `delegationReady` like every other [`DomainEvent`].
|
||||
DelegationReady {
|
||||
/// The target agent whose terminal will receive the delegation.
|
||||
agent_id: AgentId,
|
||||
/// The mailbox ticket correlating this turn (acked back via
|
||||
/// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`.
|
||||
ticket: TicketId,
|
||||
/// The task text to inject (written without a trailing `\n` by the portal).
|
||||
text: String,
|
||||
/// Target profile's submit sequence (the cell applies it after the text).
|
||||
/// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the
|
||||
/// domain.
|
||||
submit_sequence: Option<String>,
|
||||
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -97,6 +97,29 @@ impl AgentBusyState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
|
||||
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
|
||||
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
|
||||
///
|
||||
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
|
||||
/// point of use (the frontend write-portal), never hard-coded in the domain — the
|
||||
/// domain only transports the declared values.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitConfig {
|
||||
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
|
||||
pub sequence: Option<String>,
|
||||
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
|
||||
pub delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl SubmitConfig {
|
||||
/// Builds a submit config from the two optional profile fields.
|
||||
#[must_use]
|
||||
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
|
||||
Self { sequence, delay_ms }
|
||||
}
|
||||
}
|
||||
|
||||
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
|
||||
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
|
||||
/// busy state.
|
||||
@ -108,12 +131,14 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
|
||||
/// [`PendingReply`] to await (the mailbox reply type, reused).
|
||||
///
|
||||
/// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn
|
||||
/// into the agent's input stream — *this* is the single, serialized write path
|
||||
/// that replaces the orchestrator's former ad-hoc PTY write. The write target is
|
||||
/// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`];
|
||||
/// without one, the enqueue still queues the ticket (forward, never reject) and
|
||||
/// the orchestrator falls back to its own delivery.
|
||||
/// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the
|
||||
/// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode
|
||||
/// and produced a "double chat"). Instead, on the enqueue that **starts a turn**
|
||||
/// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`]
|
||||
/// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend
|
||||
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
|
||||
/// text + submit sequence through the single PTY writer. The mediator stays the
|
||||
/// authority of the FIFO/busy state and correlation only.
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
|
||||
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
|
||||
@ -134,21 +159,30 @@ pub trait InputMediator: Send + Sync {
|
||||
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
|
||||
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a
|
||||
/// mediator that does not observe the output stream). The infra adapter overrides
|
||||
/// it to arm the watcher.
|
||||
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
|
||||
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
|
||||
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
|
||||
/// The bind is the natural carrier: both the prompt pattern and the submit config
|
||||
/// are per-agent profile data the orchestrator resolves at the same time, so they
|
||||
/// travel together (no extra ticket field, no second resolve).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
|
||||
/// submit config (a mediator that does not observe the output stream). The infra
|
||||
/// adapter overrides it to arm the watcher and stash the submit config.
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
handle: PtyHandle,
|
||||
_prompt_ready_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
self.bind_handle(agent, handle);
|
||||
}
|
||||
|
||||
/// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a
|
||||
/// bound handle **and** a PTY port). When `false`, the orchestrator must deliver
|
||||
/// the turn itself. Default: `false`.
|
||||
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
|
||||
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
|
||||
/// for source compatibility; it now always returns `false`. Callers should stop
|
||||
/// branching on it (the orchestrator no longer falls back to its own PTY write).
|
||||
#[must_use]
|
||||
fn delivers_turn(&self, _agent: AgentId) -> bool {
|
||||
false
|
||||
|
||||
@ -40,16 +40,7 @@ use crate::input::InputSource;
|
||||
/// correlation is positional (head of the FIFO), the id only lets the caller name
|
||||
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TicketId(pub uuid::Uuid);
|
||||
@ -189,9 +180,7 @@ pub struct PendingReply {
|
||||
impl PendingReply {
|
||||
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
|
||||
) -> Self {
|
||||
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
@ -286,10 +275,7 @@ mod tests {
|
||||
assert_eq!(t.task, "do X");
|
||||
// Back-compat default: human source, nil conversation.
|
||||
assert_eq!(t.source, InputSource::Human);
|
||||
assert_eq!(
|
||||
t.conversation,
|
||||
ConversationId::from_uuid(uuid::Uuid::nil())
|
||||
);
|
||||
assert_eq!(t.conversation, ConversationId::from_uuid(uuid::Uuid::nil()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -322,9 +308,6 @@ mod tests {
|
||||
#[test]
|
||||
fn mailbox_errors_are_distinct_and_typed() {
|
||||
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
assert_ne!(
|
||||
MailboxError::NoPendingRequest(a),
|
||||
MailboxError::Cancelled
|
||||
);
|
||||
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,6 +325,28 @@ pub struct AgentProfile {
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||
/// la TUI), puis **cette séquence seule** après un court délai.
|
||||
///
|
||||
/// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
|
||||
/// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
|
||||
/// sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>,
|
||||
/// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
|
||||
/// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
|
||||
///
|
||||
/// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
|
||||
@ -461,6 +483,8 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -491,6 +515,23 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.submit_sequence = Some(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
|
||||
#[must_use]
|
||||
pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
|
||||
self.submit_delay_ms = Some(delay_ms);
|
||||
self
|
||||
}
|
||||
|
||||
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
|
||||
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
|
||||
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
|
||||
@ -611,10 +652,9 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn mcp_config_strategy_uses_tagged_camel_case() {
|
||||
// The `strategy` tag and camelCase rename are part of the wire contract.
|
||||
let json = serde_json::to_string(
|
||||
&McpConfigStrategy::config_file(".mcp.json").expect("valid"),
|
||||
)
|
||||
.expect("serialise");
|
||||
let json =
|
||||
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
|
||||
.expect("serialise");
|
||||
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
|
||||
}
|
||||
|
||||
@ -651,7 +691,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn config_file_accepts_safe_relative_target() {
|
||||
let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
|
||||
assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -663,7 +708,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn flag_accepts_non_empty() {
|
||||
let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
|
||||
assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::Flag {
|
||||
flag: "--mcp-config {path}".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -675,7 +725,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn env_accepts_valid_name() {
|
||||
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
|
||||
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::Env {
|
||||
var: "IDEA_MCP_SERVER".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
|
||||
@ -721,4 +776,60 @@ mod mcp_tests {
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
|
||||
}
|
||||
|
||||
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_submit_fields() {
|
||||
let p = profile_without_mcp();
|
||||
assert!(p.submit_sequence.is_none());
|
||||
assert!(p.submit_delay_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_submit_fields_omits_keys_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("submitSequence"),
|
||||
"no submitSequence key when None (zero regression); got: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("submitDelayMs"),
|
||||
"no submitDelayMs key when None (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_submit_fields_deserialises_to_none() {
|
||||
let legacy = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Dev",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(p.submit_sequence.is_none());
|
||||
assert!(p.submit_delay_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_submit_fields_set_and_round_trip_camel_case() {
|
||||
let p = profile_without_mcp()
|
||||
.with_submit_sequence("\r")
|
||||
.with_submit_delay_ms(60);
|
||||
assert_eq!(p.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(p.submit_delay_ms, Some(60));
|
||||
|
||||
let json = serde_json::to_string(&p).expect("serialise");
|
||||
assert!(json.contains("submitSequence"), "key present: {json}");
|
||||
assert!(json.contains("submitDelayMs"), "key present: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(p, back);
|
||||
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(back.submit_delay_ms, Some(60));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user