feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic

Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 08:56:17 +02:00
parent 40982d44da
commit 287681c198
57 changed files with 1758 additions and 420 deletions

View File

@ -5,10 +5,17 @@
//! concurrent readers **or** one exclusive writer per resource; different resources
//! are independent (their locks are distinct).
//!
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
//! **before** taking any lock: a `who` that is not the orchestrator
//! ([`domain::may_write_directly`] returning `false`) is rejected with
//! [`GuardError::Forbidden`] — it must *propose* instead.
//! ## Pure lock — no authorization here (cadrage « orchestrateur du projet », Alt. A)
//!
//! This adapter is a **pure serialisation primitive**: `acquire_write` never returns
//! [`GuardError::Forbidden`]. The single-writer *authorization* of the global
//! [`GuardedResource::ProjectContext`] (« only the orchestrator may write it directly,
//! everyone else proposes ») is **not** a concurrency concern and no longer lives in
//! the guard. It is decided one layer up, in the `ProposeContext` use case, which
//! consults the project's `OrchestratorDesignation` (now that the orchestrator can be
//! a *designated agent*, not just the human, the guard — which only sees a
//! [`ConversationParty`] — could not make that call coherently anyway). The guard's
//! sole job is to make readers/writers of the same resource take turns.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
@ -32,9 +39,7 @@ use async_trait::async_trait;
use tokio::sync::RwLock;
use domain::conversation::ConversationParty;
use domain::fileguard::{
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
};
use domain::fileguard::{FileGuard, GuardError, GuardedResource, ReadLease, WriteLease};
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
///
@ -85,14 +90,12 @@ impl FileGuard for RwFileGuard {
async fn acquire_write(
&self,
who: ConversationParty,
_who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
// Single-writer rule for the global project context: refuse *before* taking
// any lock so a forbidden writer never blocks readers.
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
// Pure lock (Alt. A): no authorization here — `ProposeContext` decides who may
// write the global context. Serialise behind any in-flight readers/writer of
// the same resource, then hand back the exclusive RAII lease.
let lock = self.lock_for(&res);
let guard = lock.write_owned().await;
Ok(WriteLease::new(Box::new(guard)))
@ -170,22 +173,20 @@ mod tests {
}
#[tokio::test]
async fn agent_writing_project_context_is_forbidden() {
async fn pure_lock_grants_project_context_write_to_any_party() {
// Alt. A: the guard is a pure lock — it no longer enforces the single-writer
// *authorization* (that moved into `ProposeContext`). Both the human and an
// arbitrary agent obtain a write lease on the project context; `Forbidden` is
// never produced at this layer.
let guard = RwFileGuard::new();
let err = guard
assert!(guard
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
.await
.is_ok());
assert!(guard
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
.await
.unwrap_err();
assert_eq!(err, GuardError::Forbidden);
}
#[tokio::test]
async fn orchestrator_may_write_project_context() {
let guard = RwFileGuard::new();
let lease = guard
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
.await;
assert!(lease.is_ok());
.is_ok());
}
#[tokio::test]

View File

@ -94,6 +94,10 @@ type HeadlessSink =
/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement
/// quand le profil de la cible ne fournit pas de `submit_delay_ms`.
const DEFAULT_SUBMIT_DELAY_MS: u32 = 60;
/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless.
const DELEGATION_WRITE_CHUNK_BYTES: usize = 512;
/// Pause entre deux fragments de consigne en mode headless.
const DELEGATION_WRITE_CHUNK_DELAY_MS: u64 = 8;
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
///
@ -486,8 +490,10 @@ impl MediatedInbox {
// watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue).
// Texte d'abord, puis la séquence de soumission après le délai — comme le
// write-portal frontend — pour esquiver la détection de coller des CLI.
// La consigne est fragmentée : les TUI (Codex surtout) peuvent traiter un
// gros write unique comme un paste et garder/perdre la fin au démarrage.
std::thread::spawn(move || {
let _ = pty.write(&handle, text.as_bytes());
let _ = write_delegation_chunks(pty.as_ref(), &handle, &text);
if delay > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay));
}
@ -662,6 +668,30 @@ fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
}
fn write_delegation_chunks(
pty: &dyn PtyPort,
handle: &PtyHandle,
text: &str,
) -> Result<(), domain::ports::PtyError> {
let mut start = 0;
let mut current_len = 0;
for (idx, ch) in text.char_indices() {
let len = ch.len_utf8();
if current_len > 0 && current_len + len > DELEGATION_WRITE_CHUNK_BYTES {
pty.write(handle, text[start..idx].as_bytes())?;
std::thread::sleep(std::time::Duration::from_millis(
DELEGATION_WRITE_CHUNK_DELAY_MS,
));
start = idx;
current_len = 0;
}
current_len += len;
}
pty.write(handle, text[start..].as_bytes())
}
impl InputMediator for MediatedInbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
@ -1115,6 +1145,43 @@ mod tests {
);
}
#[test]
fn headless_agent_chunks_long_delegation_before_submit() {
let pty = Arc::new(FakePty::new());
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let task = format!("début {} fin", "x".repeat(1200));
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
inbox.enqueue(a, ticket(10, &task));
assert!(
wait_until(|| pty.writes.lock().unwrap().len() >= 4),
"long headless delivery writes multiple chunks followed by submit"
);
let writes = pty.writes.lock().unwrap().clone();
assert_eq!(writes.last().map(Vec::as_slice), Some(b"\r".as_slice()));
let delivered = writes[..writes.len() - 1]
.iter()
.flat_map(|chunk| chunk.iter().copied())
.collect::<Vec<_>>();
let delivered = String::from_utf8(delivered).unwrap();
assert!(
delivered.contains(&task),
"chunked writes reconstruct the full task"
);
assert!(
bus.delegation_ready().is_empty(),
"headless delivery does not also publish DelegationReady"
);
}
#[tokio::test]
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
let inbox = inbox_at(5);

View File

@ -57,11 +57,11 @@ pub use orchestrator::{
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter;
pub use ratelimit::RateLimitParser;
pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime;
#[cfg(target_os = "linux")]
pub use sandbox::LandlockSandbox;
pub use ratelimit::RateLimitParser;
pub use sandbox::{default_enforcer, NoopSandbox};
pub use scheduler::TokioScheduler;
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};

View File

@ -292,8 +292,14 @@ mod tests {
let deny = str_array(&json["permissions"]["deny"]);
// A Write capability fans out to both Edit(..) and Write(..) entries.
assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}");
assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}");
assert!(
deny.contains(&"Edit(.ideai/**)".to_owned()),
"deny={deny:?}"
);
assert!(
deny.contains(&"Write(.ideai/**)".to_owned()),
"deny={deny:?}"
);
let allow = str_array(&json["permissions"]["allow"]);
assert!(
@ -330,7 +336,10 @@ mod tests {
"Bash(shutdown*)",
"Bash(reboot*)",
] {
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
assert!(
deny.contains(&guard.to_owned()),
"missing guardrail {guard}; deny={deny:?}"
);
}
}

View File

@ -2,9 +2,11 @@
//!
//! Produces the **permission-relevant** part of Codex's `config.toml`
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted
//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` /
//! `apply_codex_cli_permission_args` in `lifecycle.rs`.
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
//! project root as an additional writable directory (`--add-dir`). The
//! posture→mode derivation is extracted verbatim from the former
//! `codex_sandbox_mode` / `codex_approval_policy` / `apply_codex_cli_permission_args`
//! in `lifecycle.rs`.
//!
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
@ -44,7 +46,7 @@ impl PermissionProjector for CodexPermissionProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_ctx: &ProjectionContext,
ctx: &ProjectionContext,
) -> PermissionProjection {
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
// native sandbox/approval defaults (no args, no managed keys written).
@ -63,6 +65,17 @@ impl PermissionProjector for CodexPermissionProjector {
toml_string(approval),
);
let mut args = vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
];
if sandbox == "workspace-write" && !ctx.project_root.is_empty() {
args.push("--add-dir".to_owned());
args.push(ctx.project_root.to_owned());
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
@ -70,12 +83,7 @@ impl PermissionProjector for CodexPermissionProjector {
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
contents,
}],
args: vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
],
args,
env: Vec::new(),
}
}
@ -135,7 +143,7 @@ mod tests {
}
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
// coherence + MergeToml shape -------------------------------
// coherence + add-dir + MergeToml shape ---------------------
#[test]
fn posture_maps_sandbox_and_approval_in_file_and_args() {
@ -176,7 +184,7 @@ mod tests {
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
assert_eq!(
proj.args,
&proj.args[..4],
vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
@ -185,6 +193,21 @@ mod tests {
],
"posture {posture:?}: args must mirror the TOML values"
);
if sandbox == "workspace-write" {
assert!(
proj.args
.windows(2)
.any(|w| w == ["--add-dir".to_owned(), "/proj".to_owned()]),
"workspace-write posture must add the project root as writable: {:?}",
proj.args
);
} else {
assert!(
!proj.args.contains(&"--add-dir".to_owned()),
"read-only posture must not add writable dirs: {:?}",
proj.args
);
}
}
}
}

View File

@ -520,8 +520,10 @@ mod sandbox_e2e_tests {
#[tokio::test]
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
if !landlock_is_enforced() {
eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
Landlock not available/enforced on this kernel");
eprintln!(
"skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
Landlock not available/enforced on this kernel"
);
return;
}

View File

@ -92,10 +92,7 @@ impl ResetTimeFormat {
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
Self::EpochMillis => c.parse::<i64>().ok(),
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
Self::RelativeSeconds => c
.parse::<f64>()
.ok()
.map(|d| now_ms + (d * 1000.0) as i64),
Self::RelativeSeconds => c.parse::<f64>().ok().map(|d| now_ms + (d * 1000.0) as i64),
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
Self::WallClock => {
let (h, m, s) = timeparse::parse_wall_clock(c)?;
@ -153,7 +150,11 @@ impl RateLimitParser {
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
self.time_format.resolve(raw.as_str(), now_ms)
});
Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern))
Some(SessionLimit::new(
resets_at_ms,
now_ms,
RateLimitSource::Pattern,
))
}
}
@ -204,7 +205,9 @@ mod tests {
#[test]
fn detect_match_without_reset_capture_has_no_time() {
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté");
let limit = parser
.detect("oops: rate limit reached!", NOW)
.expect("détecté");
assert_eq!(limit.resets_at_ms, None);
assert_eq!(limit.detected_at_ms, NOW);
assert_eq!(limit.source, RateLimitSource::Pattern);
@ -340,9 +343,13 @@ mod tests {
Some("epoch_s"),
))
.unwrap();
let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté");
let a = parser
.detect("resets at 1700000000", NOW)
.expect("1er détecté");
assert!(parser.detect("rien ici", NOW).is_none());
let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté");
let b = parser
.detect("resets at 1700000000", NOW)
.expect("2e détecté");
assert_eq!(a.resets_at_ms, b.resets_at_ms);
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
}
@ -368,7 +375,10 @@ mod tests {
let profile = base_profile()
.with_structured_adapter(StructuredAdapter::Claude)
.with_rate_limit_pattern(pattern("rate limit", None, None));
assert!(!applies(&profile), "un agent structuré détecte par le niveau 1");
assert!(
!applies(&profile),
"un agent structuré détecte par le niveau 1"
);
}
#[test]

View File

@ -324,8 +324,7 @@ mod tests {
.enforce(&plan)
.expect("an Allow fallback never fails closed");
let read = std::fs::read(outside_t.join("pre.txt"));
let write =
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
(status, read, write)
})
.join()

View File

@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use domain::ids::ScheduleId;
use domain::ports::{Clock, Scheduler, ScheduledTask};
use domain::ports::{Clock, ScheduledTask, Scheduler};
/// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`].
///
@ -111,7 +111,7 @@ mod tests {
use uuid::Uuid;
use domain::ids::{AgentId, NodeId, ScheduleId};
use domain::ports::{Clock, Scheduler, ScheduledTask};
use domain::ports::{Clock, ScheduledTask, Scheduler};
use super::TokioScheduler;
use crate::SystemClock;
@ -126,7 +126,11 @@ mod tests {
/// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle
/// (partagée, pour calculer des échéances cohérentes avec celle qu'`arm` lira).
fn make() -> (TokioScheduler, UnboundedReceiver<ScheduledTask>, Arc<dyn Clock>) {
fn make() -> (
TokioScheduler,
UnboundedReceiver<ScheduledTask>,
Arc<dyn Clock>,
) {
let (tx, rx) = unbounded_channel();
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
(TokioScheduler::new(tx, clock.clone()), rx, clock)

View File

@ -122,6 +122,8 @@ pub struct CodexExecSession {
command: String,
/// Répertoire de travail (run dir isolé §14.1).
cwd: String,
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
writable_roots: Vec<String>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
conversation_id: Mutex<Option<String>>,
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
@ -143,6 +145,7 @@ impl CodexExecSession {
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
writable_roots: Vec<String>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
@ -150,6 +153,7 @@ impl CodexExecSession {
id,
command: command.into(),
cwd: cwd.into(),
writable_roots,
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
sandbox_enforcer,
@ -160,18 +164,18 @@ impl CodexExecSession {
///
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
/// --sandbox workspace-write <prompt>`.
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
/// --skip-git-repo-check --sandbox workspace-write <prompt>`.
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
/// <prompt>`.
///
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
/// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Défaut
/// raisonnable, aligné sur l'autonomie projet (CLAUDE.md §12) ; à terme **piloté par
/// les permissions de l'agent** (`.ideai/permissions.json` + sandbox OS) — non
/// implémenté ici.
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent
/// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
let mut args = vec!["exec".to_owned()];
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
@ -182,6 +186,10 @@ impl CodexExecSession {
args.push("--skip-git-repo-check".to_owned());
args.push("--sandbox".to_owned());
args.push("workspace-write".to_owned());
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
args.push("--add-dir".to_owned());
args.push(root.clone());
}
args.push(prompt.to_owned());
SpawnLine {
command: self.command.clone(),

View File

@ -79,7 +79,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
async fn start(
&self,
profile: &AgentProfile,
_ctx: &PreparedContext,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&SandboxPlan>,
@ -109,12 +109,18 @@ impl AgentSessionFactory for StructuredSessionFactory {
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
let session: Arc<dyn AgentSession> = match adapter {
StructuredAdapter::Claude => {
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
}
StructuredAdapter::Codex => {
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
}
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
id, command, cwd, seed, plan, enforcer,
)),
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
id,
command,
cwd,
seed,
vec![ctx.project_root.clone()],
plan,
enforcer,
)),
};
Ok(session)
}

View File

@ -62,6 +62,7 @@ mod tests {
PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "CLAUDE.md".to_owned(),
project_root: "/project".to_owned(),
}
}
@ -240,8 +241,7 @@ mod tests {
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
let completed =
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
let msg = codex::parse_event(
@ -310,6 +310,7 @@ mod tests {
fake.command(),
"/",
None,
Vec::new(),
None,
None,
));
@ -321,7 +322,14 @@ mod tests {
#[tokio::test]
async fn stream_is_closed_after_final() {
let fake = FakeCli::printing(&claude_script());
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let stream = session.send("x").await.expect("send ok");
let events: Vec<_> = stream.collect();
let after_final = events
@ -339,7 +347,14 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
"{ ceci n'est pas du json",
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
match session.send("x").await {
Err(AgentSessionError::Decode(_)) => {}
Err(other) => panic!("attendu Decode, vu: {other:?}"),
@ -437,6 +452,42 @@ mod tests {
assert_eq!(content_cx, "réponse Codex");
}
#[tokio::test]
async fn factory_passes_project_root_to_codex_add_dir() {
let factory = StructuredSessionFactory::new();
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let codex = structured_profile(StructuredAdapter::Codex, &cmd);
let ctx = PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "AGENTS.md".to_owned(),
project_root: "/project/root".to_owned(),
};
let session = factory
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None)
.await
.expect("start Codex ok");
let content = drain_final(session.as_ref()).await;
assert_eq!(content, "ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert!(
args.windows(2).any(|w| w == ["--add-dir", "/project/root"]),
"factory must relay PreparedContext.project_root to Codex --add-dir, got: {args:?}"
);
assert!(
!args.contains(&"--ask-for-approval"),
"codex exec must not receive unsupported approval flags, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn factory_resume_seeds_conversation_id() {
let factory = StructuredSessionFactory::new();
@ -564,10 +615,14 @@ mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut bin = std::env::temp_dir();
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
let mut argv = std::env::temp_dir();
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
let dir = std::env::current_dir()
.expect("cwd")
.join("target")
.join("test-fakes")
.join("session");
std::fs::create_dir_all(&dir).expect("create rec fake dir");
let bin = dir.join(format!("idea-rec-cli-{}-{n}", std::process::id()));
let argv = dir.join(format!("idea-rec-argv-{}-{n}", std::process::id()));
let mut s = String::from("#!/bin/sh\n");
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
@ -775,7 +830,15 @@ mod tests {
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
r#"{"type":"turn.completed","usage":{}}"#,
]);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let s = CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
Vec::new(),
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let finals = events
.iter()
@ -789,7 +852,10 @@ mod tests {
.skip(1)
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
assert_eq!(
after_final_terminals, 0,
"aucun second Final après le premier"
);
}
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
@ -803,7 +869,14 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
]);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let s = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
let finals = events
.iter()
@ -959,6 +1032,7 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
Vec::new(),
None,
None,
);
@ -983,7 +1057,8 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session =
ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
assert_eq!(session.conversation_id(), None);
let _ = session.send("t1").await.expect("t1");
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
@ -1093,6 +1168,7 @@ mod tests {
fake.command(),
"/",
None,
Vec::new(),
None,
None,
));
@ -1123,7 +1199,14 @@ mod tests {
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,
@ -1160,7 +1243,8 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session =
ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("bonjour").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1192,7 +1276,15 @@ mod tests {
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session = CodexExecSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
None,
Vec::new(),
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1217,7 +1309,8 @@ mod tests {
// =====================================================================
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
// d'écriture Codex : la commande générée porte EXACTEMENT
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, <prompt>]
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
// --add-dir, <project-root>, <prompt>]
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
@ -1226,15 +1319,23 @@ mod tests {
// =====================================================================
/// Conversation NEUVE : argv EXACT `[exec, --json, --skip-git-repo-check,
/// --sandbox, workspace-write, <prompt>]`. Pas de sous-commande `resume`,
/// pas de `--ask-for-approval`.
/// --sandbox, workspace-write, --add-dir, <project-root>, <prompt>]`.
/// Pas de sous-commande `resume`, pas de `--ask-for-approval`.
#[tokio::test]
async fn codex_new_conversation_command_carries_exact_args() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session = CodexExecSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
None,
vec!["/project/root".to_owned()],
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1247,6 +1348,8 @@ mod tests {
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"salut",
],
"argv neuf doit être exact (sans resume, sans --ask-for-approval), vu: {args:?}"
@ -1256,8 +1359,8 @@ mod tests {
}
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
/// --skip-git-repo-check, --sandbox, workspace-write, <prompt>]`. Toujours
/// pas de `--ask-for-approval`.
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
#[tokio::test]
async fn codex_resume_command_carries_exact_args() {
let (cmd, argv) = make_recording_fake(&[
@ -1268,6 +1371,7 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
vec!["/project/root".to_owned()],
None,
None,
);
@ -1285,6 +1389,8 @@ mod tests {
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"vas-y",
],
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
@ -1339,13 +1445,19 @@ mod tests {
#[test]
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
// < 10^12 ⇒ secondes ⇒ ×1000.
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })),
Some(1_700_000_000_000)
);
}
#[test]
fn parse_reset_ms_integer_millis_are_kept_as_is() {
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })),
Some(1_700_000_000_000)
);
}
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
@ -1388,12 +1500,18 @@ mod tests {
#[test]
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
assert_eq!(
parse_reset_ms(&json!({ "reset": "1700000000" })),
Some(1_700_000_000_000)
);
}
#[test]
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
assert_eq!(
parse_reset_ms(&json!({ "reset": "1700000000.5" })),
Some(1_700_000_000_500)
);
}
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
@ -1487,11 +1605,20 @@ mod tests {
// Pas de séparateur de date/heure.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
// Année non numérique.
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
assert_eq!(
parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })),
None
);
// Composante de date manquante (pas de jour).
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })),
None
);
// Trop de composantes de date.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })),
None
);
// Minute manquante dans l'heure.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
}
@ -1614,7 +1741,14 @@ mod tests {
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,

View File

@ -158,8 +158,7 @@ async fn run_turn_sandboxed(
// Thread JETABLE : sa restriction Landlock meurt avec lui.
std::thread::spawn(move || {
let result =
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
let result = drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
let _ = done_tx.send(result);
});

View File

@ -187,7 +187,10 @@ async fn structured_run_turn_without_plan_does_not_sandbox() {
.expect("run_turn natif réussit");
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
assert!(
allowed_marker.exists(),
"écriture in-grant réussit (sans plan)"
);
assert!(
denied_marker.exists(),
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
@ -278,7 +281,10 @@ async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
async fn structured_run_turn_none_plan_is_native_path() {
let dir = fresh_dir("p4");
let marker = dir.join("native.txt");
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
let script = format!(
"printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'",
marker.display()
);
let spec = SpawnLine {
command: "sh".to_owned(),
args: vec!["-c".to_owned(), script],
@ -327,7 +333,10 @@ async fn structured_two_turns_disjoint_grants_are_confined() {
run_turn(&spec_a, None, Some(&enforcer))
.await
.expect("tour A ok");
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
assert!(
a_in.exists(),
"tour A: écriture dans son grant (A) doit réussir"
);
assert!(
!a_into_b.exists(),
"tour A: écriture dans B (hors grant A) doit être bloquée"
@ -419,7 +428,9 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
use super::factory::StructuredSessionFactory;
if !landlock_is_enforced() {
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
eprintln!(
"skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible"
);
return;
}
@ -448,6 +459,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
let ctx = PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "CLAUDE.md".to_owned(),
project_root: run_dir.to_string_lossy().into_owned(),
};
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
@ -466,7 +478,10 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
assert_eq!(
finals, 1,
"un Final attendu malgré le sandbox, vu: {events:?}"
);
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
assert_eq!(

View File

@ -152,6 +152,7 @@ impl AgentContextStore for IdeaiContextStore {
Err(FsError::NotFound(_)) => Ok(AgentManifest {
version: MANIFEST_VERSION,
entries: Vec::new(),
orchestrator: None,
}),
Err(e) => Err(StoreError::Io(e.to_string())),
}

View File

@ -186,7 +186,11 @@ fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
return Some((s.parse::<i64>().ok()?, 0));
};
let sec = sec.parse::<i64>().ok()?;
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
let mut d3: String = frac
.chars()
.take_while(char::is_ascii_digit)
.take(3)
.collect();
while d3.len() < 3 {
d3.push('0');
}
@ -257,7 +261,10 @@ mod tests {
fn wall_clock_to_ms_same_day_when_future() {
let now_10h = DAY_START + 10 * 3_600_000;
// 15:00 est dans le futur ⇒ même jour.
assert_eq!(wall_clock_to_ms(now_10h, 15, 0, 0), DAY_START + 15 * 3_600_000);
assert_eq!(
wall_clock_to_ms(now_10h, 15, 0, 0),
DAY_START + 15 * 3_600_000
);
}
#[test]
@ -282,7 +289,8 @@ mod tests {
#[test]
fn parse_absolute_ms_epoch_seconds_and_millis() {
assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); // ms tel quel
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000));
// ms tel quel
}
#[test]

View File

@ -45,6 +45,7 @@ fn ctx() -> PreparedContext {
PreparedContext {
content: MarkdownDoc::new("# hi"),
relative_path: ".ideai/agent.md".to_owned(),
project_root: "/repo".to_owned(),
}
}

View File

@ -151,3 +151,63 @@ async fn manifest_file_is_camelcase_json_under_ideai() {
);
assert!(entry.get("md_path").is_none(), "no snake_case leak");
}
#[tokio::test]
async fn legacy_manifest_without_orchestrator_loads_as_default() {
// A pre-feature `agents.json` has no `orchestrator` key. It must deserialise with
// `orchestrator == None` (serde default), so `effective_orchestrator()` falls back
// to the oldest entry (free backward compatibility).
let tmp = TempDir::new();
let p = project(&tmp.root());
let fs = LocalFileSystem::new();
fs.create_dir_all(&tmp.child(".ideai")).await.unwrap();
let legacy = r#"{
"version": 1,
"agents": [
{ "agentId": "00000000-0000-0000-0000-000000000001", "name": "Oldest",
"mdPath": "agents/oldest.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
"synchronized": false },
{ "agentId": "00000000-0000-0000-0000-000000000002", "name": "Newer",
"mdPath": "agents/newer.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
"synchronized": false }
]
}"#;
fs.write(&tmp.child(".ideai/agents.json"), legacy.as_bytes())
.await
.unwrap();
let manifest = store().load_manifest(&p).await.unwrap();
assert_eq!(manifest.orchestrator, None);
assert_eq!(manifest.effective_orchestrator(), Some(aid(1)));
}
#[tokio::test]
async fn manifest_with_orchestrator_roundtrips_and_emits_camelcase_key() {
let tmp = TempDir::new();
let store = store();
let p = project(&tmp.root());
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
let b = agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let mut manifest = AgentManifest::new(
1,
vec![ManifestEntry::from_agent(&a), ManifestEntry::from_agent(&b)],
)
.unwrap();
manifest.designate(aid(2)).unwrap();
store.save_manifest(&p, &manifest).await.unwrap();
// Round-trips byte-for-byte through the store…
let back = store.load_manifest(&p).await.unwrap();
assert_eq!(back, manifest);
assert_eq!(back.effective_orchestrator(), Some(aid(2)));
// …and the on-disk JSON carries the camelCase `orchestrator` key.
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json.get("orchestrator").and_then(|v| v.as_str()),
Some("00000000-0000-0000-0000-000000000002")
);
}

View File

@ -78,6 +78,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))
@ -1227,21 +1228,18 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
agent_id,
SessionId::from_uuid(Uuid::from_u128(910)),
);
let server = server(service)
.with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
let server = server(service).with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
let raw = tools_call(
1,
"idea_ask_agent",
json!({ "target": "architect", "task": "never answered" }),
);
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
server.handle_raw(&raw),
)
.await
.expect("must not hang past the injected timeout")
.expect("reply owed");
let response =
tokio::time::timeout(std::time::Duration::from_secs(10), server.handle_raw(&raw))
.await
.expect("must not hang past the injected timeout")
.expect("reply owed");
let error = response.error.expect("a JSON-RPC error on timeout");
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");

View File

@ -74,6 +74,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))