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:
@ -42,12 +42,14 @@ impl FakeContexts {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: vec![ManifestEntry::from_agent(agent)],
|
||||
orchestrator: None,
|
||||
})))
|
||||
}
|
||||
fn empty() -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,25 +23,25 @@ use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
||||
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
|
||||
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::{PermissionSet, ProjectPermissions};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||
use domain::{PermissionSet, ProjectPermissions};
|
||||
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -84,6 +84,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
@ -1351,7 +1352,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
"first memory line exact format: {doc}"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"),
|
||||
doc.contains(
|
||||
"- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"
|
||||
),
|
||||
"second memory line exact format: {doc}"
|
||||
);
|
||||
// Recalled order preserved; section after the persona.
|
||||
@ -2611,7 +2614,9 @@ impl PermissionProjector for FakeClaudeProjector {
|
||||
|
||||
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
|
||||
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
|
||||
/// from the posture exactly like the real projector. `eff == None` ⇒ empty.
|
||||
/// from the posture exactly like the real projector. Workspace-write postures also
|
||||
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
|
||||
/// empty.
|
||||
struct FakeCodexProjector;
|
||||
|
||||
impl FakeCodexProjector {
|
||||
@ -2631,14 +2636,23 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
let Some(eff) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let (sandbox, approval) = Self::modes(eff.fallback());
|
||||
let contents =
|
||||
format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||
let mut args = vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
];
|
||||
if sandbox == "workspace-write" {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(ctx.project_root.to_owned());
|
||||
}
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: ".codex/config.toml".to_owned(),
|
||||
@ -2646,12 +2660,7 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
args,
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
@ -2741,8 +2750,11 @@ fn convention_file_plan() -> Option<ContextInjectionPlan> {
|
||||
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
|
||||
#[tokio::test]
|
||||
async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -2752,7 +2764,10 @@ async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
|
||||
@ -2766,8 +2781,14 @@ async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
/// Claude projector is selected.
|
||||
#[tokio::test]
|
||||
async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
|
||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
);
|
||||
assert!(
|
||||
profile.projector.is_none(),
|
||||
"no explicit projector (legacy)"
|
||||
);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -2775,7 +2796,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
|
||||
@ -2789,7 +2813,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
#[tokio::test]
|
||||
async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
||||
assert!(
|
||||
profile.projector.is_none(),
|
||||
"no explicit projector (legacy)"
|
||||
);
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -2799,7 +2826,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
|
||||
@ -2821,7 +2851,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
/// no projection at all, even with a full registry and a posed policy.
|
||||
#[tokio::test]
|
||||
async fn projection_noop_for_unprojectable_profile() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap());
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
||||
);
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -2831,7 +2864,10 @@ async fn projection_noop_for_unprojectable_profile() {
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
|
||||
assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
|
||||
@ -2849,8 +2885,11 @@ async fn projection_noop_for_unprojectable_profile() {
|
||||
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
|
||||
#[tokio::test]
|
||||
async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -2864,9 +2903,15 @@ async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
||||
fs.mark_existing(&seed_path);
|
||||
|
||||
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 1");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 1");
|
||||
sessions.remove(&sid(777));
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 2");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 2");
|
||||
|
||||
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||
assert_eq!(
|
||||
@ -2906,7 +2951,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
);
|
||||
|
||||
// First projection.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 1");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 1");
|
||||
let first = String::from_utf8(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.last()
|
||||
@ -2915,8 +2963,14 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
|
||||
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
|
||||
assert!(
|
||||
first.contains("user_key = \"keep-me\""),
|
||||
"unmanaged key preserved: {first}"
|
||||
);
|
||||
assert!(
|
||||
first.contains("[mcp_servers.idea]"),
|
||||
"unmanaged table preserved: {first}"
|
||||
);
|
||||
assert!(
|
||||
first.contains("sandbox_mode = \"workspace-write\""),
|
||||
"managed sandbox_mode upserted: {first}"
|
||||
@ -2928,7 +2982,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
|
||||
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
|
||||
sessions.remove(&sid(777));
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 2");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch 2");
|
||||
let second = String::from_utf8(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.last()
|
||||
@ -2947,7 +3004,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
1,
|
||||
"idempotent: no duplicate approval_policy: {second}"
|
||||
);
|
||||
assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved");
|
||||
assert!(
|
||||
second.contains("user_key = \"keep-me\""),
|
||||
"unmanaged key still preserved"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (4) args/env fold into the spawned spec --------------------------------
|
||||
@ -2966,18 +3026,34 @@ async fn codex_projection_folds_args_into_spawn_spec() {
|
||||
Some(perm_doc(Posture::Ask)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
// Ask ⇒ workspace-write / on-request, in CLI order.
|
||||
let pos = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
|
||||
assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}");
|
||||
assert!(
|
||||
pos.is_some(),
|
||||
"expected --sandbox workspace-write in {args:?}"
|
||||
);
|
||||
let pos2 = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
|
||||
assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}");
|
||||
assert!(
|
||||
pos2.is_some(),
|
||||
"expected --ask-for-approval on-request in {args:?}"
|
||||
);
|
||||
let add_dir = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]);
|
||||
assert!(
|
||||
add_dir.is_some(),
|
||||
"expected --add-dir project root in {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
@ -2998,13 +3074,19 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
// The sandbox config WAS written despite the absent MCP capability.
|
||||
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
|
||||
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
|
||||
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
|
||||
assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}");
|
||||
assert!(
|
||||
toml.contains("sandbox_mode = \"read-only\""),
|
||||
"Deny ⇒ read-only: {toml}"
|
||||
);
|
||||
// And the args were folded too.
|
||||
assert!(
|
||||
pty.spawns()[0]
|
||||
@ -3022,8 +3104,11 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
|
||||
/// even with a posed policy.
|
||||
#[tokio::test]
|
||||
async fn no_registry_means_no_projection() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -3031,7 +3116,10 @@ async fn no_registry_means_no_projection() {
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
@ -3043,8 +3131,11 @@ async fn no_registry_means_no_projection() {
|
||||
/// written, even though the registry IS wired.
|
||||
#[tokio::test]
|
||||
async fn no_policy_posed_means_empty_projection() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -3052,7 +3143,10 @@ async fn no_policy_posed_means_empty_projection() {
|
||||
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
@ -3066,8 +3160,11 @@ async fn no_policy_posed_means_empty_projection() {
|
||||
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
|
||||
#[tokio::test]
|
||||
async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
@ -3075,7 +3172,10 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");
|
||||
|
||||
@ -76,6 +76,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
saves: 0,
|
||||
@ -1058,7 +1059,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
||||
"the orphan .claude seed must be removed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state");
|
||||
assert!(
|
||||
!f.fs.has_file(&seed_path),
|
||||
"the seed is gone from the FS state"
|
||||
);
|
||||
|
||||
// The relaunch projected the Codex sandbox config + args.
|
||||
assert!(
|
||||
@ -1066,7 +1070,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
||||
"the relaunch must project the Codex config"
|
||||
);
|
||||
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
|
||||
assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}");
|
||||
assert!(
|
||||
toml.contains("sandbox_mode = \"workspace-write\""),
|
||||
"{toml}"
|
||||
);
|
||||
assert!(
|
||||
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
|
||||
"sandbox args folded into the relaunch spawn: {:?}",
|
||||
@ -1102,7 +1109,10 @@ async fn swap_claude_to_claude_does_not_remove_seed() {
|
||||
f.fs.removed()
|
||||
);
|
||||
// It is still present (re-clobbered by the relaunch's projection).
|
||||
assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap");
|
||||
assert!(
|
||||
f.fs.has_file(&seed_path),
|
||||
"the seed survives the same-family swap"
|
||||
);
|
||||
}
|
||||
|
||||
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
|
||||
@ -1138,13 +1148,19 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
|
||||
f.fs.removed()
|
||||
);
|
||||
// The co-owned Codex config is untouched by cleanup…
|
||||
assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted");
|
||||
assert!(
|
||||
f.fs.has_file(&codex_path),
|
||||
"the .codex/config.toml is never deleted"
|
||||
);
|
||||
assert!(
|
||||
!f.fs.removed().iter().any(|p| p == &codex_path),
|
||||
"the .codex/config.toml is not in the removed list"
|
||||
);
|
||||
// …and the relaunch projected the new Claude seed.
|
||||
assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed");
|
||||
assert!(
|
||||
f.fs.has_file(&seed_path),
|
||||
"the relaunch writes the Claude seed"
|
||||
);
|
||||
}
|
||||
|
||||
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
|
||||
@ -1153,7 +1169,8 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
|
||||
async fn swap_without_registry_removes_nothing() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
// The default fixture wires NO projector registry on the swap.
|
||||
let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
|
||||
let f =
|
||||
fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
@ -1251,7 +1268,8 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
|
||||
let pair = pair_uuid();
|
||||
f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
|
||||
f.handoffs
|
||||
.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
|
||||
|
||||
let host = nid(1);
|
||||
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
|
||||
@ -1273,7 +1291,10 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
|
||||
// …and the pair id was preserved on the persisted leaf (P8d invariant).
|
||||
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
|
||||
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
|
||||
assert!(!running, "engine running flag reset by invalidate_engine_link");
|
||||
assert!(
|
||||
!running,
|
||||
"engine running flag reset by invalidate_engine_link"
|
||||
);
|
||||
|
||||
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
|
||||
// convention file — proving the relaunch threaded the preserved pair id. (The
|
||||
|
||||
@ -258,8 +258,9 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
||||
#[tokio::test]
|
||||
async fn send_error_is_propagated_and_no_mark_idle() {
|
||||
let agent = aid(5);
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
|
||||
"bad json".to_owned(),
|
||||
)));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
@ -322,8 +323,14 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
||||
};
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out =
|
||||
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
||||
let out = drain_with_readiness(
|
||||
&session,
|
||||
"x",
|
||||
Some(Duration::from_millis(20)),
|
||||
&mediator,
|
||||
agent,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
|
||||
@ -143,6 +143,7 @@ impl FakeContexts {
|
||||
manifest: Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
orchestrator: None,
|
||||
})),
|
||||
fail: false,
|
||||
}
|
||||
@ -152,6 +153,7 @@ impl FakeContexts {
|
||||
manifest: Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
})),
|
||||
fail: true,
|
||||
}
|
||||
|
||||
@ -61,6 +61,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
|
||||
@ -21,7 +21,7 @@ use domain::project::ProjectPath;
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -366,6 +366,11 @@ fn catalogue_has_expected_commands_and_injection() {
|
||||
target: "AGENTS.md".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["codex"].submit_delay_ms,
|
||||
Some(CODEX_SUBMIT_DELAY_MS),
|
||||
"Codex TUI needs a conservative text→submit delay for delegated prompts"
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["gemini"].context_injection,
|
||||
ContextInjection::ConventionFile {
|
||||
|
||||
@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer {
|
||||
conversation_id: Option<String>,
|
||||
resume_prompt: &str,
|
||||
) -> Result<(), AppError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
|
||||
self.calls.lock().unwrap().push((
|
||||
agent_id,
|
||||
node_id,
|
||||
conversation_id,
|
||||
resume_prompt.to_owned(),
|
||||
));
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
|
||||
} else {
|
||||
@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
|
||||
|
||||
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
|
||||
assert!(
|
||||
env.scheduler.armed().is_empty(),
|
||||
"aucun arm sans heure de reset"
|
||||
);
|
||||
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
||||
|
||||
// Deux arms (un par signal), ids distincts.
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
|
||||
assert_eq!(
|
||||
issued.len(),
|
||||
2,
|
||||
"deux arms (rafraîchissement, pas empilement)"
|
||||
);
|
||||
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
@ -316,8 +325,12 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
||||
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||
let env = env_at(NOW);
|
||||
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
|
||||
let task = ScheduledTask::ResumeAgent {
|
||||
agent_id: aid(1),
|
||||
@ -329,7 +342,12 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
|
||||
assert_eq!(
|
||||
env.resumer.calls(),
|
||||
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
|
||||
vec![(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
RESUME_PROMPT.to_owned()
|
||||
)]
|
||||
);
|
||||
|
||||
// AgentResumed publié.
|
||||
@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
|
||||
.execute_resume(task)
|
||||
.await
|
||||
.expect_err("la reprise doit échouer");
|
||||
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
|
||||
assert!(
|
||||
matches!(err, AppError::Internal(_)),
|
||||
"erreur propagée: {err:?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
|
||||
@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
|
||||
#[test]
|
||||
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
let issued = env.scheduler.issued();
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"cancel d'un réveil armé ⇒ true"
|
||||
);
|
||||
// Le bon ScheduleId a été passé au Scheduler.
|
||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||
// AgentResumeCancelled publié.
|
||||
@ -408,7 +435,10 @@ fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||
fn cancel_resume_without_arm_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
assert!(!env.service.cancel_resume(aid(1)));
|
||||
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
|
||||
assert!(
|
||||
env.scheduler.cancels().is_empty(),
|
||||
"Scheduler non sollicité"
|
||||
);
|
||||
assert!(env.bus.events().is_empty(), "aucun event");
|
||||
}
|
||||
|
||||
@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() {
|
||||
#[test]
|
||||
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
// Simule un réveil déjà tiré : cancel renvoie false.
|
||||
env.scheduler.set_cancel_result(false);
|
||||
|
||||
@ -430,8 +464,7 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||
assert_eq!(env.scheduler.cancels().len(), 1);
|
||||
// AUCUN AgentResumeCancelled (pas d'event trompeur).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
@ -491,8 +524,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
|
||||
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||
let env = env_at(NOW);
|
||||
let past = NOW - 30_000;
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), None, past);
|
||||
env.service.confirm_human_resume(aid(1), nid(2), None, past);
|
||||
|
||||
let armed = env.scheduler.armed();
|
||||
assert_eq!(armed.len(), 1);
|
||||
@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||
#[test]
|
||||
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 60_000),
|
||||
);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
|
||||
assert_eq!(
|
||||
issued.len(),
|
||||
2,
|
||||
"deux arms (auto puis humain), pas d'empilement"
|
||||
);
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
|
||||
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
@ -545,7 +584,10 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||
);
|
||||
|
||||
// Unicité de l'armement actif : un seul cancel_resume aboutit.
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"un armement actif unique ⇒ true"
|
||||
);
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
@ -560,11 +602,19 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
||||
let env = env_at(NOW);
|
||||
env.service
|
||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||
env.service
|
||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
|
||||
env.service.on_rate_limited(
|
||||
aid(1),
|
||||
nid(2),
|
||||
Some("conv-1".to_owned()),
|
||||
Some(NOW + 120_000),
|
||||
);
|
||||
|
||||
let issued = env.scheduler.issued();
|
||||
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
|
||||
assert_eq!(
|
||||
issued.len(),
|
||||
2,
|
||||
"deux arms (humain puis auto), pas d'empilement"
|
||||
);
|
||||
assert_eq!(
|
||||
env.scheduler.cancels(),
|
||||
vec![issued[0]],
|
||||
@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
!env
|
||||
.bus
|
||||
!env.bus
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
|
||||
);
|
||||
|
||||
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
|
||||
assert!(
|
||||
env.service.cancel_resume(aid(1)),
|
||||
"un armement actif unique ⇒ true"
|
||||
);
|
||||
assert!(
|
||||
!env.service.cancel_resume(aid(1)),
|
||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
||||
|
||||
@ -91,6 +91,7 @@ impl FakeContexts {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
orchestrator: None,
|
||||
})))
|
||||
}
|
||||
fn manifest(&self) -> AgentManifest {
|
||||
|
||||
@ -73,6 +73,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})));
|
||||
@ -1111,6 +1112,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# persona"),
|
||||
relative_path: "agents/backend.md".to_owned(),
|
||||
project_root: ROOT.to_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||
let session = f
|
||||
|
||||
@ -72,6 +72,7 @@ impl FakeContexts {
|
||||
AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
orchestrator: None,
|
||||
},
|
||||
HashMap::new(),
|
||||
))))
|
||||
|
||||
Reference in New Issue
Block a user