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:
@ -37,8 +37,7 @@ use crate::dto::{
|
|||||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
GitCommitDto,
|
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
|
||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
@ -1217,8 +1216,8 @@ pub async fn launch_agent(
|
|||||||
// est un raté best-effort connu pour LS7.
|
// est un raté best-effort connu pour LS7.
|
||||||
if let Some(parser) = &rate_limit_parser {
|
if let Some(parser) = &rate_limit_parser {
|
||||||
let text = String::from_utf8_lossy(&chunk);
|
let text = String::from_utf8_lossy(&chunk);
|
||||||
if let Some(limit) =
|
if let Some(limit) = parser
|
||||||
parser.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
|
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
|
||||||
{
|
{
|
||||||
service.on_rate_limited(
|
service.on_rate_limited(
|
||||||
agent_id,
|
agent_id,
|
||||||
@ -1378,10 +1377,7 @@ pub async fn agent_send(
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
|
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cancel_resume(
|
pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> {
|
||||||
agent_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, ErrorDto> {
|
|
||||||
let id = parse_agent_id(&agent_id)?;
|
let id = parse_agent_id(&agent_id)?;
|
||||||
Ok(state.session_limit_service.cancel_resume(id))
|
Ok(state.session_limit_service.cancel_resume(id))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -172,6 +172,16 @@ pub enum DomainEventDto {
|
|||||||
/// badge the source.
|
/// badge the source.
|
||||||
source: OrchestrationSourceDto,
|
source: OrchestrationSourceDto,
|
||||||
},
|
},
|
||||||
|
/// The project's orchestrator designation changed (T1).
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
OrchestratorChanged {
|
||||||
|
/// The project whose designation changed.
|
||||||
|
project_id: String,
|
||||||
|
/// The newly designated orchestrator agent, or `None` for the default
|
||||||
|
/// (the oldest agent orchestrates).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
orchestrator: Option<String>,
|
||||||
|
},
|
||||||
/// A memory note was created or updated.
|
/// A memory note was created or updated.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
MemorySaved {
|
MemorySaved {
|
||||||
@ -401,6 +411,13 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
ok: *ok,
|
ok: *ok,
|
||||||
source: (*source).into(),
|
source: (*source).into(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::OrchestratorChanged {
|
||||||
|
project_id,
|
||||||
|
orchestrator,
|
||||||
|
} => Self::OrchestratorChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
|
||||||
|
},
|
||||||
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||||
slug: slug.as_str().to_string(),
|
slug: slug.as_str().to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -75,6 +75,12 @@ pub fn run() {
|
|||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.expect("failed to resolve the app data directory");
|
.expect("failed to resolve the app data directory");
|
||||||
|
// Point the orchestrator's best-effort diagnostics at a persistent file
|
||||||
|
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
|
||||||
|
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
|
||||||
|
// if the file can't be opened the beacons simply stay on stderr.
|
||||||
|
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
|
||||||
|
application::diag!("[startup] IdeA launched; diagnostics log armed");
|
||||||
let app_state = AppState::build(app_data_dir);
|
let app_state = AppState::build(app_data_dir);
|
||||||
|
|
||||||
// Wire the domain event bus → Tauri events relay.
|
// Wire the domain event bus → Tauri events relay.
|
||||||
|
|||||||
@ -508,8 +508,14 @@ mod tests {
|
|||||||
|
|
||||||
let out = String::from_utf8(cli_out).unwrap();
|
let out = String::from_utf8(cli_out).unwrap();
|
||||||
// Both request responses arrived; the notification produced none.
|
// Both request responses arrived; the notification produced none.
|
||||||
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
|
assert!(
|
||||||
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
|
out.contains("\"id\":1"),
|
||||||
|
"missing initialize response: {out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("\"id\":2"),
|
||||||
|
"missing tools/list response: {out}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||||
|
|||||||
@ -13,26 +13,25 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
|
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||||
CloseProject, CloseTab,
|
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, ContextGuardUseCases,
|
||||||
CloseTerminal, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject,
|
||||||
CreateAgentFromTemplate,
|
CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
|
||||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||||
LaunchAgentInput, ProposeContext, ReadContext, ReadMemory, SessionLimitService, WriteMemory,
|
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions,
|
||||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||||
FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph,
|
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation,
|
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
|
||||||
LaunchAgent, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
|
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
|
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||||
LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts,
|
||||||
PermissionProjectorRegistry,
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal,
|
||||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
|
||||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||||
SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, SuggestedThisSession,
|
UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext,
|
||||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
|
||||||
UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions,
|
AGENT_MEMORY_RECALL_BUDGET,
|
||||||
UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||||
@ -50,16 +49,16 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector,
|
embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector,
|
||||||
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector,
|
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe,
|
||||||
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||||
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock,
|
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||||
SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall,
|
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
|
||||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
VECTOR_ONNX_ENABLED,
|
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::chat::ChatBridge;
|
use crate::chat::ChatBridge;
|
||||||
@ -773,10 +772,8 @@ impl AppState {
|
|||||||
// l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`.
|
// l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`.
|
||||||
let permission_projectors = Arc::new(
|
let permission_projectors = Arc::new(
|
||||||
PermissionProjectorRegistry::new()
|
PermissionProjectorRegistry::new()
|
||||||
.with(Arc::new(ClaudePermissionProjector)
|
.with(Arc::new(ClaudePermissionProjector) as Arc<dyn domain::PermissionProjector>)
|
||||||
as Arc<dyn domain::PermissionProjector>)
|
.with(Arc::new(CodexPermissionProjector) as Arc<dyn domain::PermissionProjector>),
|
||||||
.with(Arc::new(CodexPermissionProjector)
|
|
||||||
as Arc<dyn domain::PermissionProjector>),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let launch_agent = Arc::new(
|
let launch_agent = Arc::new(
|
||||||
@ -1520,9 +1517,10 @@ fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
|
|||||||
/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé
|
/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé
|
||||||
/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP
|
/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP
|
||||||
/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage.
|
/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage.
|
||||||
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global), donc
|
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global).
|
||||||
/// ce fichier appartient entièrement à IdeA : il est régénéré (clobber) dès qu'un
|
/// Le fichier reste co-géré : la migration répare la partie MCP/trust sans effacer
|
||||||
/// runtime réel est disponible. Best-effort, idempotent.
|
/// les clés de permission (`approval_policy`, `sandbox_mode`) écrites par le
|
||||||
|
/// projecteur Codex. Best-effort, idempotent.
|
||||||
async fn migrate_codex_run_dir(
|
async fn migrate_codex_run_dir(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
agent_id: &AgentId,
|
agent_id: &AgentId,
|
||||||
@ -1547,11 +1545,18 @@ async fn migrate_codex_run_dir(
|
|||||||
project_id: project.id.as_uuid().simple().to_string(),
|
project_id: project.id.as_uuid().simple().to_string(),
|
||||||
requester: agent_id.to_string(),
|
requester: agent_id.to_string(),
|
||||||
});
|
});
|
||||||
migrate_codex_mcp_config(run_dir_path, profile, runtime.as_ref()).await
|
migrate_codex_mcp_config(
|
||||||
|
run_dir_path,
|
||||||
|
project.root.as_str(),
|
||||||
|
profile,
|
||||||
|
runtime.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn migrate_codex_mcp_config(
|
async fn migrate_codex_mcp_config(
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
|
project_root: &str,
|
||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
) -> Result<(), std::io::Error> {
|
) -> Result<(), std::io::Error> {
|
||||||
@ -1564,15 +1569,23 @@ async fn migrate_codex_mcp_config(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let toml_path = run_dir.join(target);
|
let toml_path = run_dir.join(target);
|
||||||
let desired = mcp_server_entry_toml(profile, Some(runtime));
|
let declaration = mcp_server_entry_toml(profile, Some(runtime));
|
||||||
|
|
||||||
// `config.toml` isolé = entièrement géré par IdeA ⇒ régénération (clobber) ;
|
// Ne jamais clobber le fichier complet ici : il porte aussi la projection de
|
||||||
// idempotent (no-op si le contenu est déjà à jour).
|
// permissions Codex. On répare seulement la table MCP et les entrées trust.
|
||||||
match tokio::fs::read_to_string(&toml_path).await {
|
let existing = match tokio::fs::read_to_string(&toml_path).await {
|
||||||
Ok(existing) if existing == desired => return Ok(()),
|
Ok(existing) => Some(existing),
|
||||||
Ok(_) => {}
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
let desired = codex_config_toml_for_migration(
|
||||||
|
existing.as_deref(),
|
||||||
|
&declaration,
|
||||||
|
run_dir.to_string_lossy().as_ref(),
|
||||||
|
project_root,
|
||||||
|
);
|
||||||
|
if existing.as_deref() == Some(desired.as_str()) {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
if let Some(parent) = toml_path.parent() {
|
if let Some(parent) = toml_path.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
@ -1629,6 +1642,98 @@ fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -
|
|||||||
domain::McpServerWiring::new(command, args, transport).to_config_toml()
|
domain::McpServerWiring::new(command, args, transport).to_config_toml()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_config_toml_for_migration(
|
||||||
|
existing: Option<&str>,
|
||||||
|
mcp_declaration: &str,
|
||||||
|
run_dir: &str,
|
||||||
|
project_root: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut text = replace_toml_table_block(
|
||||||
|
existing.unwrap_or_default(),
|
||||||
|
"mcp_servers.idea",
|
||||||
|
mcp_declaration.trim_end(),
|
||||||
|
);
|
||||||
|
text = ensure_codex_project_trust(&text, run_dir);
|
||||||
|
text = ensure_codex_project_trust(&text, project_root);
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
|
||||||
|
let header = format!("[{table}]");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut skipping = false;
|
||||||
|
let mut inserted = false;
|
||||||
|
|
||||||
|
for line in existing.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed == header {
|
||||||
|
if !inserted {
|
||||||
|
push_toml_block(&mut out, replacement);
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
skipping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
skipping = false;
|
||||||
|
}
|
||||||
|
if !skipping {
|
||||||
|
out.push(line.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inserted {
|
||||||
|
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
|
||||||
|
out.push(String::new());
|
||||||
|
}
|
||||||
|
push_toml_block(&mut out, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_toml_block(out: &mut Vec<String>, block: &str) {
|
||||||
|
out.extend(block.lines().map(ToOwned::to_owned));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
|
||||||
|
if path.is_empty() {
|
||||||
|
return existing.to_owned();
|
||||||
|
}
|
||||||
|
let header = format!(r#"[projects.{}]"#, toml_quoted(path));
|
||||||
|
if existing.lines().any(|line| line.trim() == header) {
|
||||||
|
return existing.to_owned();
|
||||||
|
}
|
||||||
|
let mut text = existing.trim_end().to_owned();
|
||||||
|
if !text.is_empty() {
|
||||||
|
text.push_str("\n\n");
|
||||||
|
}
|
||||||
|
text.push_str(&header);
|
||||||
|
text.push_str("\ntrust_level = \"trusted\"\n");
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toml_quoted(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
||||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||||
Ok(Value::Object(map)) => Value::Object(map),
|
Ok(Value::Object(map)) => Value::Object(map),
|
||||||
@ -1846,7 +1951,7 @@ fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
|
|||||||
mod run_dir_migration_tests {
|
mod run_dir_migration_tests {
|
||||||
use super::{
|
use super::{
|
||||||
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
|
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
|
||||||
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir,
|
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, migrate_codex_run_dir,
|
||||||
};
|
};
|
||||||
use application::McpRuntimeProvider;
|
use application::McpRuntimeProvider;
|
||||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||||
@ -1876,6 +1981,25 @@ mod run_dir_migration_tests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_profile() -> AgentProfile {
|
||||||
|
AgentProfile::new(
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(10)),
|
||||||
|
"OpenAI Codex CLI",
|
||||||
|
"codex",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||||
|
None,
|
||||||
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime(agent_id: AgentId) -> application::McpRuntime {
|
fn runtime(agent_id: AgentId) -> application::McpRuntime {
|
||||||
application::McpRuntime {
|
application::McpRuntime {
|
||||||
exe: "/opt/IdeA.AppImage".to_owned(),
|
exe: "/opt/IdeA.AppImage".to_owned(),
|
||||||
@ -2012,6 +2136,58 @@ mod run_dir_migration_tests {
|
|||||||
let _ = std::fs::remove_dir_all(temp);
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconcile_codex_run_dir_preserves_permissions_and_repairs_mcp_trust() {
|
||||||
|
let agent_id = AgentId::from_uuid(Uuid::from_u128(89));
|
||||||
|
let temp = std::env::temp_dir().join(format!("idea-codex-run-migrate-{}", Uuid::new_v4()));
|
||||||
|
let project_root = temp.join("project");
|
||||||
|
let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
|
||||||
|
std::fs::create_dir_all(run_dir.join(".codex")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
run_dir.join(".codex/config.toml"),
|
||||||
|
r#"approval_policy = "never"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
|
||||||
|
[mcp_servers.idea]
|
||||||
|
command = "stale"
|
||||||
|
args = ["mcp-server"]
|
||||||
|
transport = "stdio"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let project = domain::Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(1234)),
|
||||||
|
"demo",
|
||||||
|
domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||||
|
domain::remote::RemoteRef::local(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let profile = codex_profile();
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(async {
|
||||||
|
migrate_codex_run_dir(&project, &agent_id, &profile)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap();
|
||||||
|
assert!(config.contains(r#"approval_policy = "never""#));
|
||||||
|
assert!(config.contains(r#"sandbox_mode = "workspace-write""#));
|
||||||
|
assert!(config.contains("[mcp_servers.idea]"));
|
||||||
|
assert!(config.contains(r#"default_tools_approval_mode = "approve""#));
|
||||||
|
assert!(config.contains("tool_timeout_sec = 86400"));
|
||||||
|
assert!(!config.contains(r#"command = "stale""#));
|
||||||
|
assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy())));
|
||||||
|
assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str())));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
|
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
|
||||||
assert!(is_claude_mcp_profile(&claude_profile()));
|
assert!(is_claude_mcp_profile(&claude_profile()));
|
||||||
@ -2361,6 +2537,7 @@ mod mcp_serve_peer_tests {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})))
|
})))
|
||||||
@ -3123,11 +3300,11 @@ mod mcp_serve_peer_tests {
|
|||||||
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
|
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use application::{
|
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
|
||||||
ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory,
|
|
||||||
};
|
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
|
use domain::memory::{
|
||||||
|
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
||||||
|
};
|
||||||
use domain::ports::{Clock, MemoryError, MemoryStore};
|
use domain::ports::{Clock, MemoryError, MemoryStore};
|
||||||
use domain::OrchestratorCommand;
|
use domain::OrchestratorCommand;
|
||||||
use infrastructure::RwFileGuard;
|
use infrastructure::RwFileGuard;
|
||||||
@ -3204,8 +3381,7 @@ mod mcp_serve_peer_tests {
|
|||||||
contexts: FakeContexts,
|
contexts: FakeContexts,
|
||||||
) -> (Arc<OrchestratorService>, Arc<FakeMemory>) {
|
) -> (Arc<OrchestratorService>, Arc<FakeMemory>) {
|
||||||
let memory = Arc::new(FakeMemory::default());
|
let memory = Arc::new(FakeMemory::default());
|
||||||
let file_guard =
|
let file_guard = Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
|
||||||
Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
|
|
||||||
let context_guard = Arc::new(ContextGuardUseCases {
|
let context_guard = Arc::new(ContextGuardUseCases {
|
||||||
read_context: Arc::new(ReadContext::new(
|
read_context: Arc::new(ReadContext::new(
|
||||||
Arc::clone(&file_guard),
|
Arc::clone(&file_guard),
|
||||||
@ -3571,6 +3747,7 @@ mod mcp_e2e_loopback_tests {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})))
|
})))
|
||||||
|
|||||||
@ -16,9 +16,9 @@ use std::time::Duration;
|
|||||||
use app_tauri_lib::state::AppState;
|
use app_tauri_lib::state::AppState;
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::ids::NodeId;
|
use domain::ids::NodeId;
|
||||||
|
use domain::ports::IdGenerator;
|
||||||
use domain::AgentId;
|
use domain::AgentId;
|
||||||
use infrastructure::UuidGenerator;
|
use infrastructure::UuidGenerator;
|
||||||
use domain::ports::IdGenerator;
|
|
||||||
|
|
||||||
fn temp_path(tag: &str) -> PathBuf {
|
fn temp_path(tag: &str) -> PathBuf {
|
||||||
let ids = UuidGenerator::new();
|
let ids = UuidGenerator::new();
|
||||||
@ -124,7 +124,10 @@ async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
|
|||||||
.structured_sessions
|
.structured_sessions
|
||||||
.node_for_agent(&unknown)
|
.node_for_agent(&unknown)
|
||||||
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
|
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
|
||||||
assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)");
|
assert!(
|
||||||
|
resolved.is_none(),
|
||||||
|
"set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
|
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
|
||||||
@ -156,8 +159,14 @@ async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
|
|||||||
_ => break,
|
_ => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)");
|
assert!(
|
||||||
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)");
|
saw_rate_limited,
|
||||||
|
"AgentRateLimited relayed on the bus (humain)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
saw_scheduled,
|
||||||
|
"AgentResumeScheduled relayed on the bus (humain)"
|
||||||
|
);
|
||||||
|
|
||||||
// Annulable par la même voie que l'auto.
|
// Annulable par la même voie que l'auto.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@ -24,6 +24,11 @@ use domain::profile::{
|
|||||||
StructuredAdapter,
|
StructuredAdapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Codex's interactive TUI is sensitive to receiving a large pasted block and the
|
||||||
|
/// submit key too close together. Keep the default conservative so delegated
|
||||||
|
/// prompts are actually submitted instead of remaining in the input editor.
|
||||||
|
pub const CODEX_SUBMIT_DELAY_MS: u32 = 350;
|
||||||
|
|
||||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||||
/// (Random-looking but constant; only its stability matters.)
|
/// (Random-looking but constant; only its stability matters.)
|
||||||
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
|
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
|
||||||
@ -82,6 +87,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect("codex reference profile is valid")
|
.expect("codex reference profile is valid")
|
||||||
.with_structured_adapter(StructuredAdapter::Codex)
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
.with_projector(ProjectorKey::Codex)
|
.with_projector(ProjectorKey::Codex)
|
||||||
|
.with_submit_delay_ms(CODEX_SUBMIT_DELAY_MS)
|
||||||
.with_mcp(McpCapability::new(
|
.with_mcp(McpCapability::new(
|
||||||
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||||
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
||||||
|
|||||||
@ -21,14 +21,14 @@ use domain::ports::{
|
|||||||
StoreError,
|
StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||||
|
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
||||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
|
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project,
|
||||||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
||||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
@ -1446,6 +1446,7 @@ impl LaunchAgent {
|
|||||||
let prepared = PreparedContext {
|
let prepared = PreparedContext {
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
relative_path: agent.context_path.clone(),
|
relative_path: agent.context_path.clone(),
|
||||||
|
project_root: input.project.root.as_str().to_owned(),
|
||||||
};
|
};
|
||||||
// 4a. Resolve the session intention (T4). The conversation id is a property
|
// 4a. Resolve the session intention (T4). The conversation id is a property
|
||||||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
||||||
|
|||||||
@ -22,7 +22,9 @@ pub use structured::{
|
|||||||
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
|
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
pub use catalogue::{
|
||||||
|
reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
|
||||||
|
};
|
||||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||||
|
|||||||
@ -35,8 +35,7 @@ use crate::error::AppError;
|
|||||||
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
|
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
|
||||||
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
|
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
|
||||||
/// contexte. Volontairement neutre et model-agnostique.
|
/// contexte. Volontairement neutre et model-agnostique.
|
||||||
pub const RESUME_PROMPT: &str =
|
pub const RESUME_PROMPT: &str = "La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
||||||
"La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
|
||||||
|
|
||||||
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
|
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
|
||||||
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
|
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
|
||||||
@ -161,7 +160,13 @@ impl SessionLimitService {
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
} = plan_resume(now, &limit, conversation_id)
|
} = plan_resume(now, &limit, conversation_id)
|
||||||
{
|
{
|
||||||
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
|
self.arm_scheduled(
|
||||||
|
agent_id,
|
||||||
|
fire_at_ms,
|
||||||
|
node_id,
|
||||||
|
conversation_id,
|
||||||
|
Some(resets_at_ms),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,9 +204,14 @@ impl SessionLimitService {
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
|
self.armed
|
||||||
self.events
|
.lock()
|
||||||
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
|
.expect("session-limit mutex sain")
|
||||||
|
.insert(agent_id, id);
|
||||||
|
self.events.publish(DomainEvent::AgentResumeScheduled {
|
||||||
|
agent_id,
|
||||||
|
fire_at_ms,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
|
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
|
||||||
@ -255,7 +265,10 @@ impl SessionLimitService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if self.scheduler.cancel(id) {
|
if self.scheduler.cancel(id) {
|
||||||
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
|
self.armed
|
||||||
|
.lock()
|
||||||
|
.expect("session-limit mutex sain")
|
||||||
|
.remove(&agent_id);
|
||||||
self.events
|
self.events
|
||||||
.publish(DomainEvent::AgentResumeCancelled { agent_id });
|
.publish(DomainEvent::AgentResumeCancelled { agent_id });
|
||||||
true
|
true
|
||||||
|
|||||||
@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use domain::input::InputMediator;
|
|
||||||
use domain::ids::AgentId;
|
use domain::ids::AgentId;
|
||||||
|
use domain::input::InputMediator;
|
||||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||||
|
|
||||||
@ -178,16 +178,15 @@ async fn drain_bounded_events(
|
|||||||
on_signal: impl FnMut(ReadinessSignal),
|
on_signal: impl FnMut(ReadinessSignal),
|
||||||
) -> Result<TurnOutcome, AgentSessionError> {
|
) -> Result<TurnOutcome, AgentSessionError> {
|
||||||
match timeout {
|
match timeout {
|
||||||
Some(dur) => match tokio::time::timeout(
|
Some(dur) => {
|
||||||
dur,
|
match tokio::time::timeout(dur, drain_to_final(session, prompt, on_event, on_signal))
|
||||||
drain_to_final(session, prompt, on_event, on_signal),
|
.await
|
||||||
)
|
{
|
||||||
.await
|
Ok(result) => result,
|
||||||
{
|
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||||
Ok(result) => result,
|
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
}
|
||||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
}
|
||||||
},
|
|
||||||
None => drain_to_final(session, prompt, on_event, on_signal).await,
|
None => drain_to_final(session, prompt, on_event, on_signal).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -317,7 +316,9 @@ mod tests {
|
|||||||
let session = FakeSession {
|
let session = FakeSession {
|
||||||
events: vec![
|
events: vec![
|
||||||
ReplyEvent::TextDelta { text: "a".into() },
|
ReplyEvent::TextDelta { text: "a".into() },
|
||||||
ReplyEvent::ToolActivity { label: "lit".into() },
|
ReplyEvent::ToolActivity {
|
||||||
|
label: "lit".into(),
|
||||||
|
},
|
||||||
ReplyEvent::Heartbeat,
|
ReplyEvent::Heartbeat,
|
||||||
ReplyEvent::Final {
|
ReplyEvent::Final {
|
||||||
content: "fini".into(),
|
content: "fini".into(),
|
||||||
|
|||||||
74
crates/application/src/diag.rs
Normal file
74
crates/application/src/diag.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
//! Lightweight, dependency-free diagnostics sink for the orchestrator rendezvous.
|
||||||
|
//!
|
||||||
|
//! The orchestrator already emits best-effort traces through `eprintln!` (see
|
||||||
|
//! [`crate::orchestrator`]). When IdeA is launched by **clicking the AppImage**,
|
||||||
|
//! that stderr is discarded — so an inter-agent block (an `ask` that never gets
|
||||||
|
//! its `idea_reply`) leaves no retrievable trace. This module mirrors those
|
||||||
|
//! traces to a **persistent log file** the user can hand back for diagnosis,
|
||||||
|
//! while keeping the exact same "zero new dependency, never breaks the
|
||||||
|
//! rendezvous" discipline: every write is best-effort and infallible.
|
||||||
|
//!
|
||||||
|
//! - [`set_log_path`] is called once by the composition root (`app-tauri`) with
|
||||||
|
//! `<app-data>/logs/idea.log`. Until then (and in tests) writes go to stderr
|
||||||
|
//! only.
|
||||||
|
//! - [`diag`] (and the [`diag!`] macro) timestamps a line with epoch-millis — the
|
||||||
|
//! same clock as the conversation logs' `atMs` — and appends it to the file,
|
||||||
|
//! always also echoing to stderr so a terminal launch and the test suite still
|
||||||
|
//! see it.
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// The resolved log file path, set once at startup. `None` ⇒ stderr-only.
|
||||||
|
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Serialises concurrent appends so interleaved rendezvous beacons stay on their
|
||||||
|
/// own lines (the orchestrator handles many connections in parallel).
|
||||||
|
static WRITE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Points the diagnostics sink at `path`, creating its parent directory.
|
||||||
|
///
|
||||||
|
/// Idempotent and infallible: a second call (or a failure to create the
|
||||||
|
/// directory) is ignored — diagnostics must never break startup. Called by the
|
||||||
|
/// composition root once the app-data directory is known.
|
||||||
|
pub fn set_log_path(path: PathBuf) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let _ = LOG_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current epoch-millis (same clock as the conversation logs' `atMs`).
|
||||||
|
fn now_ms() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one timestamped diagnostic line. Best-effort and infallible: a missing
|
||||||
|
/// path or an I/O error is swallowed (the line still reaches stderr). Never call
|
||||||
|
/// in a hot loop — these are lifecycle beacons, not a metrics stream.
|
||||||
|
pub fn diag(msg: impl std::fmt::Display) {
|
||||||
|
let line = format!("{} {msg}", now_ms());
|
||||||
|
// Always echo to stderr: keeps a terminal launch and the test suite working,
|
||||||
|
// and preserves the pre-existing `eprintln!` behaviour for these beacons.
|
||||||
|
eprintln!("{line}");
|
||||||
|
if let Some(path) = LOG_PATH.get() {
|
||||||
|
let _guard = WRITE_LOCK.lock();
|
||||||
|
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
|
||||||
|
let _ = writeln!(f, "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `diag!("...", ...)` — formats then forwards to [`diag`]. Mirrors `eprintln!`.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! diag {
|
||||||
|
($($arg:tt)*) => {
|
||||||
|
$crate::diag::diag(format!($($arg)*))
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
|
pub mod diag;
|
||||||
pub mod embedder;
|
pub mod embedder;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
@ -31,19 +32,19 @@ pub mod window;
|
|||||||
pub use agent::{
|
pub use agent::{
|
||||||
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
||||||
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
||||||
ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||||
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||||
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
|
||||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
|
ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
||||||
ProfileAvailability,
|
|
||||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
|
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||||
|
RESUME_PROMPT,
|
||||||
};
|
};
|
||||||
pub use conversation::RecordTurn;
|
pub use conversation::RecordTurn;
|
||||||
pub use embedder::{
|
pub use embedder::{
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||||
@ -135,9 +135,10 @@ impl ReadContext {
|
|||||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||||
///
|
///
|
||||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||||
/// **global** project context by a non-orchestrator: the guard returns
|
/// **global** project context by a non-orchestrator: the use case asks the domain
|
||||||
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
|
/// policy whether the requester may write directly; otherwise it materialises a
|
||||||
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
|
/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live
|
||||||
|
/// context.
|
||||||
pub struct ProposeContext {
|
pub struct ProposeContext {
|
||||||
guard: Arc<dyn FileGuard>,
|
guard: Arc<dyn FileGuard>,
|
||||||
contexts: Arc<dyn AgentContextStore>,
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
@ -214,24 +215,23 @@ impl ProposeContext {
|
|||||||
Ok(ProposeOutcome::Written)
|
Ok(ProposeOutcome::Written)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Global project context: single-writer. Try to acquire the write
|
// Global project context: single-writer. Authorization stays in the
|
||||||
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
|
// domain policy; the guard only serialises the eventual direct write.
|
||||||
match self
|
let manifest = self.contexts.load_manifest(&project).await?;
|
||||||
.guard
|
let designation = manifest.orchestrator_designation();
|
||||||
.acquire_write(requester, GuardedResource::ProjectContext)
|
let resource = GuardedResource::ProjectContext;
|
||||||
.await
|
if !may_write_directly(requester, &resource, &designation) {
|
||||||
{
|
let path = self.file_proposal(&project, requester, &content).await?;
|
||||||
Ok(_lease) => {
|
return Ok(ProposeOutcome::Proposed { path });
|
||||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
|
||||||
self.fs.write(&path, content.as_bytes()).await?;
|
|
||||||
Ok(ProposeOutcome::Written)
|
|
||||||
}
|
|
||||||
Err(GuardError::Forbidden) => {
|
|
||||||
let path = self.file_proposal(&project, requester, &content).await?;
|
|
||||||
Ok(ProposeOutcome::Proposed { path })
|
|
||||||
}
|
|
||||||
Err(other) => Err(map_guard_err(other)),
|
|
||||||
}
|
}
|
||||||
|
let _lease = self
|
||||||
|
.guard
|
||||||
|
.acquire_write(requester, resource)
|
||||||
|
.await
|
||||||
|
.map_err(map_guard_err)?;
|
||||||
|
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||||
|
self.fs.write(&path, content.as_bytes()).await?;
|
||||||
|
Ok(ProposeOutcome::Written)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -392,7 +392,7 @@ mod tests {
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::agent::{AgentManifest, ManifestEntry};
|
use domain::agent::{AgentManifest, ManifestEntry};
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
|
use domain::fileguard::{ReadLease, WriteLease};
|
||||||
use domain::ports::{FsError, MemoryError, StoreError};
|
use domain::ports::{FsError, MemoryError, StoreError};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||||
@ -443,12 +443,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
async fn acquire_write(
|
async fn acquire_write(
|
||||||
&self,
|
&self,
|
||||||
who: ConversationParty,
|
_who: ConversationParty,
|
||||||
res: GuardedResource,
|
res: GuardedResource,
|
||||||
) -> Result<WriteLease, GuardError> {
|
) -> Result<WriteLease, GuardError> {
|
||||||
if !may_write_directly(who, &res) {
|
|
||||||
return Err(GuardError::Forbidden);
|
|
||||||
}
|
|
||||||
let lock = self.lock_for(&res);
|
let lock = self.lock_for(&res);
|
||||||
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
|
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
|
||||||
}
|
}
|
||||||
@ -612,6 +609,7 @@ mod tests {
|
|||||||
Arc::new(FakeContexts {
|
Arc::new(FakeContexts {
|
||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
orchestrator: None,
|
||||||
entries: vec![ManifestEntry {
|
entries: vec![ManifestEntry {
|
||||||
agent_id: agent,
|
agent_id: agent,
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
|
||||||
@ -26,7 +26,8 @@ use domain::mailbox::{Ticket, TicketId};
|
|||||||
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
|
AgentId, AgentProfile, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId,
|
||||||
|
Project,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::conversation::RecordTurn;
|
use crate::conversation::RecordTurn;
|
||||||
@ -34,7 +35,7 @@ use crate::conversation::RecordTurn;
|
|||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||||
UpdateAgentContextInput,
|
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::orchestrator::{
|
use crate::orchestrator::{
|
||||||
@ -51,6 +52,19 @@ const DEFAULT_ROWS: u16 = 24;
|
|||||||
/// See [`DEFAULT_ROWS`].
|
/// See [`DEFAULT_ROWS`].
|
||||||
const DEFAULT_COLS: u16 = 80;
|
const DEFAULT_COLS: u16 = 80;
|
||||||
|
|
||||||
|
/// Submit defaults for delegated prompts, after applying profile-specific
|
||||||
|
/// compatibility fallbacks for existing saved profiles.
|
||||||
|
fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
|
||||||
|
let delay_ms = profile.submit_delay_ms.or_else(|| {
|
||||||
|
matches!(
|
||||||
|
profile.structured_adapter,
|
||||||
|
Some(domain::profile::StructuredAdapter::Codex)
|
||||||
|
)
|
||||||
|
.then_some(CODEX_SUBMIT_DELAY_MS)
|
||||||
|
});
|
||||||
|
SubmitConfig::new(profile.submit_sequence.clone(), delay_ms)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
|
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
|
||||||
///
|
///
|
||||||
/// A target agent's turn can be long (reasoning + tool use), so the cap is
|
/// A target agent's turn can be long (reasoning + tool use), so the cap is
|
||||||
@ -152,6 +166,15 @@ impl Drop for BusyTurnGuard {
|
|||||||
// soit le chemin (erreur, timeout, drop).
|
// soit le chemin (erreur, timeout, drop).
|
||||||
self.mailbox.cancel_head(self.agent, self.ticket);
|
self.mailbox.cancel_head(self.agent, self.ticket);
|
||||||
self.input.mark_idle(self.agent);
|
self.input.mark_idle(self.agent);
|
||||||
|
// Rendezvous beacon (diagnostics) : le garde a libéré une cible restée Busy
|
||||||
|
// (chemin erreur / timeout / futur ask abandonné). Si ce beacon apparaît
|
||||||
|
// sans « ask resolved », la cible n'a jamais répondu — c'est le scénario de
|
||||||
|
// blocage à diagnostiquer.
|
||||||
|
crate::diag!(
|
||||||
|
"[rendezvous] busy-guard freed target agent {} (ticket {})",
|
||||||
|
self.agent,
|
||||||
|
self.ticket,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -939,16 +962,25 @@ impl OrchestratorService {
|
|||||||
// le médiateur AVANT l'enqueue (consommé au start_turn).
|
// le médiateur AVANT l'enqueue (consommé au start_turn).
|
||||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||||
let pending = input.enqueue(agent_id, ticket);
|
let pending = input.enqueue(agent_id, ticket);
|
||||||
|
// Rendezvous beacon (diagnostics) : l'ask est désormais en attente du
|
||||||
|
// `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en
|
||||||
|
// texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de
|
||||||
|
// « ask resolved » correspondant avant l'expiration du `turn_timeout` — la
|
||||||
|
// signature exacte du blocage Main→cible.
|
||||||
|
let started = Instant::now();
|
||||||
|
crate::diag!(
|
||||||
|
"[rendezvous] ask started: requester={} -> target={target} (agent {agent_id}) \
|
||||||
|
ticket={ticket_id} cold_launch={cold_launch} gate_cold_start={gate_cold_start} \
|
||||||
|
turn_timeout_ms={}",
|
||||||
|
requester.map_or_else(|| "user".to_owned(), |a| a.to_string()),
|
||||||
|
turn_timeout.as_millis(),
|
||||||
|
);
|
||||||
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant
|
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant
|
||||||
// `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur
|
// `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur
|
||||||
// abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket
|
// abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket
|
||||||
// fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]).
|
// fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]).
|
||||||
let busy_guard = BusyTurnGuard::new(
|
let busy_guard =
|
||||||
Arc::clone(input),
|
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
|
||||||
Arc::clone(mailbox),
|
|
||||||
agent_id,
|
|
||||||
ticket_id,
|
|
||||||
);
|
|
||||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||||
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
||||||
@ -973,14 +1005,35 @@ impl OrchestratorService {
|
|||||||
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
|
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
|
||||||
// déjà résolu, ni libérer un busy state qui ne nous appartient plus.
|
// déjà résolu, ni libérer un busy state qui ne nous appartient plus.
|
||||||
busy_guard.disarm();
|
busy_guard.disarm();
|
||||||
|
crate::diag!(
|
||||||
|
"[rendezvous] ask resolved: target={target} (agent {agent_id}) \
|
||||||
|
ticket={ticket_id} after_ms={} reply_len={}",
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
result.len(),
|
||||||
|
);
|
||||||
Ok(self.reply_outcome(agent_id, &target, result))
|
Ok(self.reply_outcome(agent_id, &target, result))
|
||||||
}
|
}
|
||||||
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
|
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
|
||||||
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
|
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
|
||||||
Ok(Err(_cancelled)) => Err(AppError::Process(format!(
|
Ok(Err(_cancelled)) => {
|
||||||
"agent {target} : canal de réponse fermé avant un résultat"
|
crate::diag!(
|
||||||
))),
|
"[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \
|
||||||
Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)),
|
ticket={ticket_id} after_ms={}",
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
);
|
||||||
|
Err(AppError::Process(format!(
|
||||||
|
"agent {target} : canal de réponse fermé avant un résultat"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(_elapsed) => {
|
||||||
|
crate::diag!(
|
||||||
|
"[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \
|
||||||
|
ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \
|
||||||
|
ni atteint son prompt-ready dans le turn_timeout)",
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
);
|
||||||
|
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1053,12 +1106,8 @@ impl OrchestratorService {
|
|||||||
// toutes les sorties — `return Err` des bras du select, OU **futur abandonné
|
// toutes les sorties — `return Err` des bras du select, OU **futur abandonné
|
||||||
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
|
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
|
||||||
// le fix de la cause racine du blocage `Busy` à vie.
|
// le fix de la cause racine du blocage `Busy` à vie.
|
||||||
let busy_guard = BusyTurnGuard::new(
|
let busy_guard =
|
||||||
Arc::clone(input),
|
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
|
||||||
Arc::clone(mailbox),
|
|
||||||
agent_id,
|
|
||||||
ticket_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
|
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
|
||||||
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
|
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
|
||||||
@ -1317,11 +1366,24 @@ impl OrchestratorService {
|
|||||||
})?;
|
})?;
|
||||||
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
|
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
|
||||||
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
|
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
|
||||||
match ticket {
|
let correlation = match ticket {
|
||||||
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
|
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
|
||||||
None => mailbox.resolve(from, result),
|
None => mailbox.resolve(from, result),
|
||||||
|
};
|
||||||
|
// Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a
|
||||||
|
// corrélé à un ask en vol — un échec ici (« no matching ask ») signe une
|
||||||
|
// délégation déjà expirée/abandonnée ou un ticket erroné côté cible.
|
||||||
|
match &correlation {
|
||||||
|
Ok(()) => crate::diag!(
|
||||||
|
"[rendezvous] idea_reply correlated: from agent {from} ticket={}",
|
||||||
|
ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => crate::diag!(
|
||||||
|
"[rendezvous] idea_reply UNMATCHED: from agent {from} ticket={} err={e}",
|
||||||
|
ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
correlation.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
|
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
|
||||||
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
|
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
|
||||||
// advances to the next queued ticket. This is the deterministic OR signal that
|
// advances to the next queued ticket. This is the deterministic OR signal that
|
||||||
@ -1773,7 +1835,7 @@ impl OrchestratorService {
|
|||||||
else {
|
else {
|
||||||
return (None, SubmitConfig::default(), false);
|
return (None, SubmitConfig::default(), false);
|
||||||
};
|
};
|
||||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
let submit = submit_config_for_profile(&profile);
|
||||||
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
||||||
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
|
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
|
||||||
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
|
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
|
||||||
@ -1901,7 +1963,7 @@ fn normalise(s: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use domain::profile::{AgentProfile, ContextInjection};
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||||
use domain::ProfileId;
|
use domain::ProfileId;
|
||||||
|
|
||||||
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
||||||
@ -1949,6 +2011,28 @@ mod tests {
|
|||||||
assert_eq!(p.id.to_string(), p.id.to_string());
|
assert_eq!(p.id.to_string(), p.id.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_submit_config_gets_conservative_delay_when_profile_omits_it() {
|
||||||
|
let p = profile(2, "OpenAI Codex CLI", "codex")
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex);
|
||||||
|
|
||||||
|
let submit = submit_config_for_profile(&p);
|
||||||
|
|
||||||
|
assert_eq!(submit.sequence, None);
|
||||||
|
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_profile_submit_delay_is_preserved() {
|
||||||
|
let p = profile(3, "OpenAI Codex CLI", "codex")
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
|
.with_submit_delay_ms(900);
|
||||||
|
|
||||||
|
let submit = submit_config_for_profile(&p);
|
||||||
|
|
||||||
|
assert_eq!(submit.delay_ms, Some(900));
|
||||||
|
}
|
||||||
|
|
||||||
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
|
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
|
||||||
//
|
//
|
||||||
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
|
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
|
||||||
@ -2026,7 +2110,11 @@ mod tests {
|
|||||||
tid(7),
|
tid(7),
|
||||||
);
|
);
|
||||||
} // Drop ici.
|
} // Drop ici.
|
||||||
assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop");
|
assert_eq!(
|
||||||
|
*med.idled.lock().unwrap(),
|
||||||
|
vec![aid(1)],
|
||||||
|
"mark_idle au Drop"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*mb.cancelled.lock().unwrap(),
|
*mb.cancelled.lock().unwrap(),
|
||||||
vec![(aid(1), tid(7))],
|
vec![(aid(1), tid(7))],
|
||||||
@ -2047,7 +2135,10 @@ mod tests {
|
|||||||
tid(7),
|
tid(7),
|
||||||
);
|
);
|
||||||
g.disarm();
|
g.disarm();
|
||||||
assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm");
|
assert!(
|
||||||
|
med.idled.lock().unwrap().is_empty(),
|
||||||
|
"pas de mark_idle après disarm"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
mb.cancelled.lock().unwrap().is_empty(),
|
mb.cancelled.lock().unwrap().is_empty(),
|
||||||
"pas de cancel_head après disarm"
|
"pas de cancel_head après disarm"
|
||||||
|
|||||||
@ -42,12 +42,14 @@ impl FakeContexts {
|
|||||||
Self(Arc::new(Mutex::new(AgentManifest {
|
Self(Arc::new(Mutex::new(AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: vec![ManifestEntry::from_agent(agent)],
|
entries: vec![ManifestEntry::from_agent(agent)],
|
||||||
|
orchestrator: None,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
fn empty() -> Self {
|
fn empty() -> Self {
|
||||||
Self(Arc::new(Mutex::new(AgentManifest {
|
Self(Arc::new(Mutex::new(AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,25 +23,25 @@ use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::permission::{
|
||||||
|
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||||
|
ProjectionContext, ProjectorKey,
|
||||||
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
||||||
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
|
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
|
||||||
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::permission::{
|
|
||||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
|
||||||
ProjectionContext, ProjectorKey,
|
|
||||||
};
|
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||||
SessionStrategy, StructuredAdapter,
|
SessionStrategy, StructuredAdapter,
|
||||||
};
|
};
|
||||||
use domain::{PermissionSet, ProjectPermissions};
|
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::skill::{Skill, SkillScope};
|
use domain::skill::{Skill, SkillScope};
|
||||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||||
|
use domain::{PermissionSet, ProjectPermissions};
|
||||||
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -84,6 +84,7 @@ impl FakeContexts {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})))
|
})))
|
||||||
@ -1351,7 +1352,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
|||||||
"first memory line exact format: {doc}"
|
"first memory line exact format: {doc}"
|
||||||
);
|
);
|
||||||
assert!(
|
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}"
|
"second memory line exact format: {doc}"
|
||||||
);
|
);
|
||||||
// Recalled order preserved; section after the persona.
|
// 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
|
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
|
||||||
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
|
/// 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;
|
struct FakeCodexProjector;
|
||||||
|
|
||||||
impl FakeCodexProjector {
|
impl FakeCodexProjector {
|
||||||
@ -2631,14 +2636,23 @@ impl PermissionProjector for FakeCodexProjector {
|
|||||||
fn project(
|
fn project(
|
||||||
&self,
|
&self,
|
||||||
eff: Option<&EffectivePermissions>,
|
eff: Option<&EffectivePermissions>,
|
||||||
_ctx: &ProjectionContext,
|
ctx: &ProjectionContext,
|
||||||
) -> PermissionProjection {
|
) -> PermissionProjection {
|
||||||
let Some(eff) = eff else {
|
let Some(eff) = eff else {
|
||||||
return PermissionProjection::empty();
|
return PermissionProjection::empty();
|
||||||
};
|
};
|
||||||
let (sandbox, approval) = Self::modes(eff.fallback());
|
let (sandbox, approval) = Self::modes(eff.fallback());
|
||||||
let contents =
|
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||||
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 {
|
PermissionProjection {
|
||||||
files: vec![ProjectedFile::MergeToml {
|
files: vec![ProjectedFile::MergeToml {
|
||||||
rel_path: ".codex/config.toml".to_owned(),
|
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()],
|
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||||
contents,
|
contents,
|
||||||
}],
|
}],
|
||||||
args: vec![
|
args,
|
||||||
"--sandbox".to_owned(),
|
|
||||||
sandbox.to_owned(),
|
|
||||||
"--ask-for-approval".to_owned(),
|
|
||||||
approval.to_owned(),
|
|
||||||
],
|
|
||||||
env: Vec::new(),
|
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.
|
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn projection_selects_claude_from_explicit_projector_field() {
|
async fn projection_selects_claude_from_explicit_projector_field() {
|
||||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
|
let profile = profile(
|
||||||
.with_projector(ProjectorKey::Claude);
|
pid(9),
|
||||||
|
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
||||||
|
)
|
||||||
|
.with_projector(ProjectorKey::Claude);
|
||||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
Some(ContextInjectionPlan::File {
|
Some(ContextInjectionPlan::File {
|
||||||
@ -2752,7 +2764,10 @@ async fn projection_selects_claude_from_explicit_projector_field() {
|
|||||||
Some(perm_doc(Posture::Allow)),
|
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);
|
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||||
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
|
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.
|
/// Claude projector is selected.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn projection_falls_back_to_claude_from_convention_file() {
|
async fn projection_falls_back_to_claude_from_convention_file() {
|
||||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
|
let profile = profile(
|
||||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
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(
|
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
convention_file_plan(),
|
convention_file_plan(),
|
||||||
@ -2775,7 +2796,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
|
|||||||
Some(perm_doc(Posture::Allow)),
|
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!(
|
assert_eq!(
|
||||||
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
|
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
|
||||||
@ -2789,7 +2813,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn projection_falls_back_to_codex_from_structured_adapter() {
|
async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||||
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
|
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(
|
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
Some(ContextInjectionPlan::File {
|
Some(ContextInjectionPlan::File {
|
||||||
@ -2799,7 +2826,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
|
|||||||
Some(perm_doc(Posture::Allow)),
|
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!(
|
assert_eq!(
|
||||||
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
|
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.
|
/// no projection at all, even with a full registry and a posed policy.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn projection_noop_for_unprojectable_profile() {
|
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(
|
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
Some(ContextInjectionPlan::File {
|
Some(ContextInjectionPlan::File {
|
||||||
@ -2831,7 +2864,10 @@ async fn projection_noop_for_unprojectable_profile() {
|
|||||||
Some(perm_doc(Posture::Allow)),
|
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(CLAUDE_SEED_REL).is_empty());
|
||||||
assert!(fs.writes_ending_with(CODEX_CONFIG_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).
|
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
||||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
let profile = profile(
|
||||||
.with_projector(ProjectorKey::Claude);
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)
|
||||||
|
.with_projector(ProjectorKey::Claude);
|
||||||
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
|
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
convention_file_plan(),
|
convention_file_plan(),
|
||||||
@ -2864,9 +2903,15 @@ async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
|||||||
fs.mark_existing(&seed_path);
|
fs.mark_existing(&seed_path);
|
||||||
|
|
||||||
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
|
// 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));
|
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);
|
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -2906,7 +2951,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// First projection.
|
// 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(
|
let first = String::from_utf8(
|
||||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||||
.last()
|
.last()
|
||||||
@ -2915,8 +2963,14 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
.clone(),
|
.clone(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
|
assert!(
|
||||||
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
|
first.contains("user_key = \"keep-me\""),
|
||||||
|
"unmanaged key preserved: {first}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
first.contains("[mcp_servers.idea]"),
|
||||||
|
"unmanaged table preserved: {first}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
first.contains("sandbox_mode = \"workspace-write\""),
|
first.contains("sandbox_mode = \"workspace-write\""),
|
||||||
"managed sandbox_mode upserted: {first}"
|
"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.
|
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
|
||||||
sessions.remove(&sid(777));
|
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(
|
let second = String::from_utf8(
|
||||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||||
.last()
|
.last()
|
||||||
@ -2947,7 +3004,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
|||||||
1,
|
1,
|
||||||
"idempotent: no duplicate approval_policy: {second}"
|
"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 --------------------------------
|
// ---- (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)),
|
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;
|
let args = &pty.spawns()[0].args;
|
||||||
// Ask ⇒ workspace-write / on-request, in CLI order.
|
// Ask ⇒ workspace-write / on-request, in CLI order.
|
||||||
let pos = args
|
let pos = args
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
|
.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
|
let pos2 = args
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
|
.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 ---------------------------
|
// ---- (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)),
|
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.
|
// The sandbox config WAS written despite the absent MCP capability.
|
||||||
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
|
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
|
||||||
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
|
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
|
||||||
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
|
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.
|
// And the args were folded too.
|
||||||
assert!(
|
assert!(
|
||||||
pty.spawns()[0]
|
pty.spawns()[0]
|
||||||
@ -3022,8 +3104,11 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
|
|||||||
/// even with a posed policy.
|
/// even with a posed policy.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn no_registry_means_no_projection() {
|
async fn no_registry_means_no_projection() {
|
||||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
let profile = profile(
|
||||||
.with_projector(ProjectorKey::Claude);
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)
|
||||||
|
.with_projector(ProjectorKey::Claude);
|
||||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
convention_file_plan(),
|
convention_file_plan(),
|
||||||
@ -3031,7 +3116,10 @@ async fn no_registry_means_no_projection() {
|
|||||||
Some(perm_doc(Posture::Deny)),
|
Some(perm_doc(Posture::Deny)),
|
||||||
);
|
);
|
||||||
|
|
||||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
launch
|
||||||
|
.execute(launch_input(agent.id))
|
||||||
|
.await
|
||||||
|
.expect("launch");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
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.
|
/// written, even though the registry IS wired.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn no_policy_posed_means_empty_projection() {
|
async fn no_policy_posed_means_empty_projection() {
|
||||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
let profile = profile(
|
||||||
.with_projector(ProjectorKey::Claude);
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)
|
||||||
|
.with_projector(ProjectorKey::Claude);
|
||||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
convention_file_plan(),
|
convention_file_plan(),
|
||||||
@ -3052,7 +3143,10 @@ async fn no_policy_posed_means_empty_projection() {
|
|||||||
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
|
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!(
|
assert!(
|
||||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
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`).
|
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn resolved_deny_posture_reflected_as_plan_mode() {
|
async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
let profile = profile(
|
||||||
.with_projector(ProjectorKey::Claude);
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)
|
||||||
|
.with_projector(ProjectorKey::Claude);
|
||||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||||
profile,
|
profile,
|
||||||
convention_file_plan(),
|
convention_file_plan(),
|
||||||
@ -3075,7 +3172,10 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
|
|||||||
Some(perm_doc(Posture::Deny)),
|
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 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");
|
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");
|
||||||
|
|||||||
@ -76,6 +76,7 @@ impl FakeContexts {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
saves: 0,
|
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={:?}",
|
"the orphan .claude seed must be removed; removed={:?}",
|
||||||
f.fs.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.
|
// The relaunch projected the Codex sandbox config + args.
|
||||||
assert!(
|
assert!(
|
||||||
@ -1066,7 +1070,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
|||||||
"the relaunch must project the Codex config"
|
"the relaunch must project the Codex config"
|
||||||
);
|
);
|
||||||
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
|
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!(
|
assert!(
|
||||||
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
|
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
|
||||||
"sandbox args folded into the relaunch spawn: {:?}",
|
"sandbox args folded into the relaunch spawn: {:?}",
|
||||||
@ -1102,7 +1109,10 @@ async fn swap_claude_to_claude_does_not_remove_seed() {
|
|||||||
f.fs.removed()
|
f.fs.removed()
|
||||||
);
|
);
|
||||||
// It is still present (re-clobbered by the relaunch's projection).
|
// 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
|
/// (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()
|
f.fs.removed()
|
||||||
);
|
);
|
||||||
// The co-owned Codex config is untouched by cleanup…
|
// 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!(
|
assert!(
|
||||||
!f.fs.removed().iter().any(|p| p == &codex_path),
|
!f.fs.removed().iter().any(|p| p == &codex_path),
|
||||||
"the .codex/config.toml is not in the removed list"
|
"the .codex/config.toml is not in the removed list"
|
||||||
);
|
);
|
||||||
// …and the relaunch projected the new Claude seed.
|
// …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,
|
/// (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() {
|
async fn swap_without_registry_removes_nothing() {
|
||||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||||
// The default fixture wires NO projector registry on the swap.
|
// 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));
|
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
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);
|
let run_dir = run_dir_of(&agent.id);
|
||||||
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
|
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
|
||||||
let pair = pair_uuid();
|
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);
|
let host = nid(1);
|
||||||
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
|
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).
|
// …and the pair id was preserved on the persisted leaf (P8d invariant).
|
||||||
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
|
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
|
||||||
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
|
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
|
// …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
|
// 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]
|
#[tokio::test]
|
||||||
async fn send_error_is_propagated_and_no_mark_idle() {
|
async fn send_error_is_propagated_and_no_mark_idle() {
|
||||||
let agent = aid(5);
|
let agent = aid(5);
|
||||||
let session =
|
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
|
||||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
"bad json".to_owned(),
|
||||||
|
)));
|
||||||
let mediator = RecordingMediator::new();
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
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 mediator = RecordingMediator::new();
|
||||||
|
|
||||||
let out =
|
let out = drain_with_readiness(
|
||||||
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
&session,
|
||||||
|
"x",
|
||||||
|
Some(Duration::from_millis(20)),
|
||||||
|
&mediator,
|
||||||
|
agent,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(out, Err(AgentSessionError::Timeout));
|
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mediator.mark_idle_count(agent),
|
mediator.mark_idle_count(agent),
|
||||||
|
|||||||
@ -143,6 +143,7 @@ impl FakeContexts {
|
|||||||
manifest: Arc::new(Mutex::new(AgentManifest {
|
manifest: Arc::new(Mutex::new(AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries,
|
entries,
|
||||||
|
orchestrator: None,
|
||||||
})),
|
})),
|
||||||
fail: false,
|
fail: false,
|
||||||
}
|
}
|
||||||
@ -152,6 +153,7 @@ impl FakeContexts {
|
|||||||
manifest: Arc::new(Mutex::new(AgentManifest {
|
manifest: Arc::new(Mutex::new(AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
})),
|
})),
|
||||||
fail: true,
|
fail: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ impl FakeContexts {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})))
|
})))
|
||||||
|
|||||||
@ -21,7 +21,7 @@ use domain::project::ProjectPath;
|
|||||||
use application::{
|
use application::{
|
||||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
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()
|
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!(
|
assert_eq!(
|
||||||
by_command["gemini"].context_injection,
|
by_command["gemini"].context_injection,
|
||||||
ContextInjection::ConventionFile {
|
ContextInjection::ConventionFile {
|
||||||
|
|||||||
@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer {
|
|||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
resume_prompt: &str,
|
resume_prompt: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
self.calls
|
self.calls.lock().unwrap().push((
|
||||||
.lock()
|
agent_id,
|
||||||
.unwrap()
|
node_id,
|
||||||
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
|
conversation_id,
|
||||||
|
resume_prompt.to_owned(),
|
||||||
|
));
|
||||||
if self.fail.load(Ordering::SeqCst) {
|
if self.fail.load(Ordering::SeqCst) {
|
||||||
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
|
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
|
||||||
} else {
|
} else {
|
||||||
@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
|
|||||||
env.service
|
env.service
|
||||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
|
.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!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
env.bus.events(),
|
env.bus.events(),
|
||||||
@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
|
|||||||
|
|
||||||
// Deux arms (un par signal), ids distincts.
|
// Deux arms (un par signal), ids distincts.
|
||||||
let issued = env.scheduler.issued();
|
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.
|
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
env.scheduler.cancels(),
|
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() {
|
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
|
||||||
let env = env_at(NOW);
|
let env = env_at(NOW);
|
||||||
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
|
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
|
||||||
env.service
|
env.service.on_rate_limited(
|
||||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
aid(1),
|
||||||
|
nid(2),
|
||||||
|
Some("conv-1".to_owned()),
|
||||||
|
Some(NOW + 60_000),
|
||||||
|
);
|
||||||
|
|
||||||
let task = ScheduledTask::ResumeAgent {
|
let task = ScheduledTask::ResumeAgent {
|
||||||
agent_id: aid(1),
|
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.
|
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
env.resumer.calls(),
|
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é.
|
// AgentResumed publié.
|
||||||
@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
|
|||||||
.execute_resume(task)
|
.execute_resume(task)
|
||||||
.await
|
.await
|
||||||
.expect_err("la reprise doit échouer");
|
.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!(
|
assert!(
|
||||||
!env
|
!env.bus
|
||||||
.bus
|
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
|
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
|
||||||
@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
|
||||||
let env = env_at(NOW);
|
let env = env_at(NOW);
|
||||||
env.service
|
env.service.on_rate_limited(
|
||||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
aid(1),
|
||||||
|
nid(2),
|
||||||
|
Some("conv-1".to_owned()),
|
||||||
|
Some(NOW + 60_000),
|
||||||
|
);
|
||||||
let issued = env.scheduler.issued();
|
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.
|
// Le bon ScheduleId a été passé au Scheduler.
|
||||||
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
|
||||||
// AgentResumeCancelled publié.
|
// 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() {
|
fn cancel_resume_without_arm_is_false_no_event() {
|
||||||
let env = env_at(NOW);
|
let env = env_at(NOW);
|
||||||
assert!(!env.service.cancel_resume(aid(1)));
|
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");
|
assert!(env.bus.events().is_empty(), "aucun event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
|
||||||
let env = env_at(NOW);
|
let env = env_at(NOW);
|
||||||
env.service
|
env.service.on_rate_limited(
|
||||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
aid(1),
|
||||||
|
nid(2),
|
||||||
|
Some("conv-1".to_owned()),
|
||||||
|
Some(NOW + 60_000),
|
||||||
|
);
|
||||||
// Simule un réveil déjà tiré : cancel renvoie false.
|
// Simule un réveil déjà tiré : cancel renvoie false.
|
||||||
env.scheduler.set_cancel_result(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);
|
assert_eq!(env.scheduler.cancels().len(), 1);
|
||||||
// AUCUN AgentResumeCancelled (pas d'event trompeur).
|
// AUCUN AgentResumeCancelled (pas d'event trompeur).
|
||||||
assert!(
|
assert!(
|
||||||
!env
|
!env.bus
|
||||||
.bus
|
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
.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() {
|
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
||||||
let env = env_at(NOW);
|
let env = env_at(NOW);
|
||||||
let past = NOW - 30_000;
|
let past = NOW - 30_000;
|
||||||
env.service
|
env.service.confirm_human_resume(aid(1), nid(2), None, past);
|
||||||
.confirm_human_resume(aid(1), nid(2), None, past);
|
|
||||||
|
|
||||||
let armed = env.scheduler.armed();
|
let armed = env.scheduler.armed();
|
||||||
assert_eq!(armed.len(), 1);
|
assert_eq!(armed.len(), 1);
|
||||||
@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
||||||
let env = env_at(NOW);
|
let env = env_at(NOW);
|
||||||
env.service
|
env.service.on_rate_limited(
|
||||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
|
aid(1),
|
||||||
|
nid(2),
|
||||||
|
Some("conv-1".to_owned()),
|
||||||
|
Some(NOW + 60_000),
|
||||||
|
);
|
||||||
env.service
|
env.service
|
||||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
|
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
|
||||||
|
|
||||||
let issued = env.scheduler.issued();
|
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!(
|
assert_eq!(
|
||||||
env.scheduler.cancels(),
|
env.scheduler.cancels(),
|
||||||
vec![issued[0]],
|
vec![issued[0]],
|
||||||
@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
|
|||||||
|
|
||||||
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
|
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
|
||||||
assert!(
|
assert!(
|
||||||
!env
|
!env.bus
|
||||||
.bus
|
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
.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.
|
// 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!(
|
assert!(
|
||||||
!env.service.cancel_resume(aid(1)),
|
!env.service.cancel_resume(aid(1)),
|
||||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
"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);
|
let env = env_at(NOW);
|
||||||
env.service
|
env.service
|
||||||
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
|
||||||
env.service
|
env.service.on_rate_limited(
|
||||||
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
|
aid(1),
|
||||||
|
nid(2),
|
||||||
|
Some("conv-1".to_owned()),
|
||||||
|
Some(NOW + 120_000),
|
||||||
|
);
|
||||||
|
|
||||||
let issued = env.scheduler.issued();
|
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!(
|
assert_eq!(
|
||||||
env.scheduler.cancels(),
|
env.scheduler.cancels(),
|
||||||
vec![issued[0]],
|
vec![issued[0]],
|
||||||
@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!env
|
!env.bus
|
||||||
.bus
|
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
|
||||||
"le dédoublonnage croisé n'émet PAS 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!(
|
assert!(
|
||||||
!env.service.cancel_resume(aid(1)),
|
!env.service.cancel_resume(aid(1)),
|
||||||
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
|
"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 {
|
Self(Arc::new(Mutex::new(AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries,
|
entries,
|
||||||
|
orchestrator: None,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
fn manifest(&self) -> AgentManifest {
|
fn manifest(&self) -> AgentManifest {
|
||||||
|
|||||||
@ -73,6 +73,7 @@ impl FakeContexts {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})));
|
})));
|
||||||
@ -1111,6 +1112,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
|||||||
let ctx = PreparedContext {
|
let ctx = PreparedContext {
|
||||||
content: MarkdownDoc::new("# persona"),
|
content: MarkdownDoc::new("# persona"),
|
||||||
relative_path: "agents/backend.md".to_owned(),
|
relative_path: "agents/backend.md".to_owned(),
|
||||||
|
project_root: ROOT.to_owned(),
|
||||||
};
|
};
|
||||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||||
let session = f
|
let session = f
|
||||||
|
|||||||
@ -72,6 +72,7 @@ impl FakeContexts {
|
|||||||
AgentManifest {
|
AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries,
|
entries,
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
))))
|
))))
|
||||||
|
|||||||
@ -277,24 +277,59 @@ impl ManifestEntry {
|
|||||||
|
|
||||||
/// In-memory image of `.ideai/agents.json`.
|
/// In-memory image of `.ideai/agents.json`.
|
||||||
///
|
///
|
||||||
/// Invariant enforced here: `md_path` values are unique across entries.
|
/// Invariants enforced here:
|
||||||
|
/// - `md_path` values are unique across entries;
|
||||||
|
/// - `orchestrator == Some(id)` ⇒ `id` is present in `entries` (referential).
|
||||||
|
///
|
||||||
|
/// **Ordering invariant**: `entries` are kept in **creation order**, so
|
||||||
|
/// `entries.first()` is the **oldest** agent. There is no per-entry timestamp; the
|
||||||
|
/// vector position *is* the chronology, which is why the default orchestrator (when
|
||||||
|
/// none is designated) is `entries.first()`.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AgentManifest {
|
pub struct AgentManifest {
|
||||||
/// Schema version of the manifest file.
|
/// Schema version of the manifest file.
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
/// Entries (one per project agent).
|
/// Entries (one per project agent), in **creation order** (oldest first).
|
||||||
#[serde(rename = "agents")]
|
#[serde(rename = "agents")]
|
||||||
pub entries: Vec<ManifestEntry>,
|
pub entries: Vec<ManifestEntry>,
|
||||||
|
/// Explicitly designated orchestrator agent, if any.
|
||||||
|
///
|
||||||
|
/// We persist only the **deviation** from the default: `None` (the common case)
|
||||||
|
/// means « the oldest agent orchestrates » (see [`effective_orchestrator`]);
|
||||||
|
/// `Some(id)` is an explicit designation. Skipped on serialisation when `None`,
|
||||||
|
/// so pre-feature manifests round-trip unchanged (free backward compatibility).
|
||||||
|
///
|
||||||
|
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub orchestrator: Option<AgentId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentManifest {
|
impl AgentManifest {
|
||||||
/// Builds a validated manifest.
|
/// Builds a validated manifest with **no** explicit orchestrator (the default:
|
||||||
|
/// the oldest agent orchestrates — see [`effective_orchestrator`]).
|
||||||
|
///
|
||||||
|
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
|
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
|
||||||
/// same `md_path`.
|
/// same `md_path`.
|
||||||
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
|
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
|
||||||
|
Self::with_orchestrator(version, entries, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a validated manifest carrying an explicit `orchestrator` designation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`DomainError::InconsistentManifest`] if two entries share the same
|
||||||
|
/// `md_path`;
|
||||||
|
/// - [`DomainError::InconsistentManifest`] if `orchestrator == Some(id)` while
|
||||||
|
/// `id` is **not** present in `entries` (referential integrity).
|
||||||
|
pub fn with_orchestrator(
|
||||||
|
version: u32,
|
||||||
|
entries: Vec<ManifestEntry>,
|
||||||
|
orchestrator: Option<AgentId>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
if !seen.insert(entry.md_path.as_str()) {
|
if !seen.insert(entry.md_path.as_str()) {
|
||||||
@ -303,6 +338,196 @@ impl AgentManifest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Self { version, entries })
|
if let Some(id) = orchestrator {
|
||||||
|
if !entries.iter().any(|e| e.agent_id == id) {
|
||||||
|
return Err(DomainError::InconsistentManifest {
|
||||||
|
reason: format!("designated orchestrator `{id}` is not a known agent"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
version,
|
||||||
|
entries,
|
||||||
|
orchestrator,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The **effective** orchestrator agent: the explicit designation if any,
|
||||||
|
/// otherwise the **oldest** agent (`entries.first()`, by the creation-order
|
||||||
|
/// invariant). `None` only when the manifest has no entries at all.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_orchestrator(&self) -> Option<AgentId> {
|
||||||
|
self.orchestrator
|
||||||
|
.or_else(|| self.entries.first().map(|e| e.agent_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds the [`effective_orchestrator`](Self::effective_orchestrator) into the
|
||||||
|
/// [`OrchestratorDesignation`] value object consumed by the FileGuard policy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn orchestrator_designation(&self) -> crate::fileguard::OrchestratorDesignation {
|
||||||
|
match self.effective_orchestrator() {
|
||||||
|
Some(id) => crate::fileguard::OrchestratorDesignation::of(id),
|
||||||
|
None => crate::fileguard::OrchestratorDesignation::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Designates `id` as the project's orchestrator (radio semantics: overwrites any
|
||||||
|
/// previous designation).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`DomainError::InconsistentManifest`] if `id` is not a known agent of this
|
||||||
|
/// manifest.
|
||||||
|
pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError> {
|
||||||
|
if !self.entries.iter().any(|e| e.agent_id == id) {
|
||||||
|
return Err(DomainError::InconsistentManifest {
|
||||||
|
reason: format!("cannot designate unknown agent `{id}` as orchestrator"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.orchestrator = Some(id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to the removal of an agent: if it was the **explicitly designated**
|
||||||
|
/// orchestrator, clears the designation so the manifest falls back to the default
|
||||||
|
/// (lazy succession — the next-oldest agent becomes effective). A no-op when the
|
||||||
|
/// removed agent was not the designated one.
|
||||||
|
///
|
||||||
|
/// Pure bookkeeping: it does **not** remove the entry itself (the caller owns
|
||||||
|
/// `entries`); it only keeps the `orchestrator` deviation consistent.
|
||||||
|
pub fn on_agent_deleted(&mut self, removed: AgentId) {
|
||||||
|
if self.orchestrator == Some(removed) {
|
||||||
|
self.orchestrator = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod orchestrator_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::conversation::ConversationParty;
|
||||||
|
use crate::fileguard::{may_write_directly, GuardedResource, OrchestratorDesignation};
|
||||||
|
|
||||||
|
fn agent_id(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a manifest entry for `id`, with a `md_path` derived from `n` so entries
|
||||||
|
/// stay unique.
|
||||||
|
fn entry(n: u128) -> ManifestEntry {
|
||||||
|
ManifestEntry::new(
|
||||||
|
agent_id(n),
|
||||||
|
format!("agent-{n}"),
|
||||||
|
format!("agents/agent-{n}.md"),
|
||||||
|
ProfileId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||||
|
// entries are in creation order: first is the oldest → the effective default.
|
||||||
|
let m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
|
||||||
|
assert_eq!(m.orchestrator, None);
|
||||||
|
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
|
||||||
|
assert_eq!(
|
||||||
|
m.orchestrator_designation(),
|
||||||
|
OrchestratorDesignation::of(agent_id(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_manifest_has_no_effective_orchestrator() {
|
||||||
|
let m = AgentManifest::new(1, vec![]).unwrap();
|
||||||
|
assert_eq!(m.effective_orchestrator(), None);
|
||||||
|
assert_eq!(
|
||||||
|
m.orchestrator_designation(),
|
||||||
|
OrchestratorDesignation::none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_designation_overrides_oldest() {
|
||||||
|
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||||
|
m.designate(agent_id(2)).unwrap();
|
||||||
|
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||||
|
assert_eq!(m.effective_orchestrator(), Some(agent_id(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn designate_is_radio_and_rejects_unknown_agent() {
|
||||||
|
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||||
|
m.designate(agent_id(1)).unwrap();
|
||||||
|
// Radio: a second designation overwrites the first (never accumulates).
|
||||||
|
m.designate(agent_id(2)).unwrap();
|
||||||
|
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||||
|
// Referential: designating an agent absent from `entries` is rejected.
|
||||||
|
let err = m.designate(agent_id(99)).unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||||
|
// …and the previous valid designation is untouched.
|
||||||
|
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lazy_succession_falls_back_to_oldest_when_designated_is_deleted() {
|
||||||
|
let mut m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
|
||||||
|
m.designate(agent_id(3)).unwrap();
|
||||||
|
// The designated agent is removed → designation cleared, default takes over.
|
||||||
|
m.entries.retain(|e| e.agent_id != agent_id(3));
|
||||||
|
m.on_agent_deleted(agent_id(3));
|
||||||
|
assert_eq!(m.orchestrator, None);
|
||||||
|
// Falls back to the (now) oldest remaining agent.
|
||||||
|
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_agent_deleted_is_noop_for_non_designated_agent() {
|
||||||
|
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||||
|
m.designate(agent_id(2)).unwrap();
|
||||||
|
m.on_agent_deleted(agent_id(1));
|
||||||
|
// Deleting a non-designated agent leaves the designation intact.
|
||||||
|
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructor_enforces_referential_integrity_of_orchestrator() {
|
||||||
|
// Some(id) with id present in entries → ok.
|
||||||
|
assert!(
|
||||||
|
AgentManifest::with_orchestrator(1, vec![entry(1), entry(2)], Some(agent_id(2)),)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
// Some(id) with id absent → rejected.
|
||||||
|
let err =
|
||||||
|
AgentManifest::with_orchestrator(1, vec![entry(1)], Some(agent_id(42))).unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||||
|
// None is always valid (even with an empty manifest).
|
||||||
|
assert!(AgentManifest::with_orchestrator(1, vec![], None).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn designated_agent_may_write_project_context_via_manifest_designation() {
|
||||||
|
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||||
|
m.designate(agent_id(1)).unwrap();
|
||||||
|
let d = m.orchestrator_designation();
|
||||||
|
// The designated agent writes the project context directly…
|
||||||
|
assert!(may_write_directly(
|
||||||
|
ConversationParty::agent(agent_id(1)),
|
||||||
|
&GuardedResource::ProjectContext,
|
||||||
|
&d,
|
||||||
|
));
|
||||||
|
// …another agent is refused (single-writer preserved)…
|
||||||
|
assert!(!may_write_directly(
|
||||||
|
ConversationParty::agent(agent_id(2)),
|
||||||
|
&GuardedResource::ProjectContext,
|
||||||
|
&d,
|
||||||
|
));
|
||||||
|
// …and the human is always allowed.
|
||||||
|
assert!(may_write_directly(
|
||||||
|
ConversationParty::User,
|
||||||
|
&GuardedResource::ProjectContext,
|
||||||
|
&d,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -258,6 +258,19 @@ pub enum DomainEvent {
|
|||||||
/// sinon `None` (l'utilisateur fournira l'heure).
|
/// sinon `None` (l'utilisateur fournira l'heure).
|
||||||
resets_at_ms: Option<i64>,
|
resets_at_ms: Option<i64>,
|
||||||
},
|
},
|
||||||
|
/// The project's **orchestrator designation** changed (cadrage « orchestrateur
|
||||||
|
/// du projet », T1). Emitted when an agent is designated (radio selection) or the
|
||||||
|
/// designation is cleared back to the default (e.g. the designated agent was
|
||||||
|
/// deleted — lazy succession to the oldest agent). Relayed to the front so the UI
|
||||||
|
/// can move the radio / refresh the orchestrator badge. Model-agnostic: carries
|
||||||
|
/// only the neutral fact « who orchestrates now ».
|
||||||
|
OrchestratorChanged {
|
||||||
|
/// The project whose designation changed.
|
||||||
|
project_id: ProjectId,
|
||||||
|
/// The newly designated orchestrator agent, or `None` for the default
|
||||||
|
/// (the oldest agent orchestrates).
|
||||||
|
orchestrator: Option<AgentId>,
|
||||||
|
},
|
||||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||||
PtyOutput {
|
PtyOutput {
|
||||||
/// The session.
|
/// The session.
|
||||||
@ -358,6 +371,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestrator_changed_constructs_and_compares() {
|
||||||
|
let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100));
|
||||||
|
let ev = DomainEvent::OrchestratorChanged {
|
||||||
|
project_id: project,
|
||||||
|
orchestrator: Some(agent(1)),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::OrchestratorChanged {
|
||||||
|
project_id: project,
|
||||||
|
orchestrator: Some(agent(1)),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Clearing to the default (None) is a distinct event.
|
||||||
|
assert_ne!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::OrchestratorChanged {
|
||||||
|
project_id: project,
|
||||||
|
orchestrator: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn distinct_session_limit_variants_are_not_equal() {
|
fn distinct_session_limit_variants_are_not_equal() {
|
||||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||||
|
|||||||
@ -159,30 +159,71 @@ pub trait FileGuard: Send + Sync {
|
|||||||
) -> Result<WriteLease, GuardError>;
|
) -> Result<WriteLease, GuardError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Which agent (if any) the project designates as its orchestrator.**
|
||||||
|
///
|
||||||
|
/// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du
|
||||||
|
/// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator
|
||||||
|
/// and is never represented here.
|
||||||
|
/// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator.
|
||||||
|
/// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio
|
||||||
|
/// selection, single agent by construction — the illegal "two orchestrators" state
|
||||||
|
/// is unrepresentable).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct OrchestratorDesignation(Option<AgentId>);
|
||||||
|
|
||||||
|
impl OrchestratorDesignation {
|
||||||
|
/// No agent designated: only the human ([`ConversationParty::User`]) orchestrates.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn none() -> Self {
|
||||||
|
Self(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Designates `agent` as the project's orchestrator (radio selection).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn of(agent: AgentId) -> Self {
|
||||||
|
Self(Some(agent))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The designated agent, if any. `None` ⇒ default (human-only orchestrator).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn designated(&self) -> Option<AgentId> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
|
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
|
||||||
///
|
///
|
||||||
/// Pure policy, shared by the port's documented contract and the adapter: only the
|
/// Pure policy, shared by the port's documented contract and the adapter: only an
|
||||||
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
|
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
|
||||||
/// may write the per-agent context and memory. The orchestrator is modelled as
|
/// may write the per-agent context and memory. Orchestrator identity is resolved
|
||||||
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
|
/// against the project's [`OrchestratorDesignation`] (the human is always one; a
|
||||||
/// human-driven orchestrator), never a project [`AgentId`].
|
/// project agent only when explicitly designated).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool {
|
pub fn may_write_directly(
|
||||||
|
who: ConversationParty,
|
||||||
|
res: &GuardedResource,
|
||||||
|
d: &OrchestratorDesignation,
|
||||||
|
) -> bool {
|
||||||
if res.is_project_context() {
|
if res.is_project_context() {
|
||||||
is_orchestrator(who)
|
is_orchestrator(who, d)
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `who` is the orchestrator identity for the single-writer rule.
|
/// Whether `who` is an orchestrator identity for the single-writer rule, given the
|
||||||
|
/// project's [`OrchestratorDesignation`] `d`.
|
||||||
///
|
///
|
||||||
/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a
|
/// The human-driven operator ([`ConversationParty::User`]) is **always** an
|
||||||
/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must
|
/// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator
|
||||||
/// propose changes to the global context.
|
/// **only** when it is the designated one; otherwise it must propose changes to the
|
||||||
|
/// global context.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn is_orchestrator(who: ConversationParty) -> bool {
|
pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool {
|
||||||
matches!(who, ConversationParty::User)
|
match who {
|
||||||
|
ConversationParty::User => true,
|
||||||
|
ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -195,13 +236,41 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_context_is_single_writer_to_orchestrator_only() {
|
fn project_context_is_single_writer_to_orchestrator_only() {
|
||||||
|
// Single-writer rule preserved with the default (no designated agent):
|
||||||
|
// the human writes, every agent is refused.
|
||||||
|
let default = OrchestratorDesignation::none();
|
||||||
assert!(may_write_directly(
|
assert!(may_write_directly(
|
||||||
ConversationParty::User,
|
ConversationParty::User,
|
||||||
&GuardedResource::ProjectContext
|
&GuardedResource::ProjectContext,
|
||||||
|
&default,
|
||||||
));
|
));
|
||||||
assert!(!may_write_directly(
|
assert!(!may_write_directly(
|
||||||
agent_party(1),
|
agent_party(1),
|
||||||
&GuardedResource::ProjectContext
|
&GuardedResource::ProjectContext,
|
||||||
|
&default,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn designated_agent_may_write_project_context() {
|
||||||
|
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||||
|
let designation = OrchestratorDesignation::of(id);
|
||||||
|
// The designated agent now writes the global context directly…
|
||||||
|
assert!(may_write_directly(
|
||||||
|
ConversationParty::agent(id),
|
||||||
|
&GuardedResource::ProjectContext,
|
||||||
|
&designation,
|
||||||
|
));
|
||||||
|
// …while another agent stays refused, and the human stays allowed.
|
||||||
|
assert!(!may_write_directly(
|
||||||
|
agent_party(2),
|
||||||
|
&GuardedResource::ProjectContext,
|
||||||
|
&designation,
|
||||||
|
));
|
||||||
|
assert!(may_write_directly(
|
||||||
|
ConversationParty::User,
|
||||||
|
&GuardedResource::ProjectContext,
|
||||||
|
&designation,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,16 +278,34 @@ mod tests {
|
|||||||
fn agent_context_and_memory_are_writable_by_anyone() {
|
fn agent_context_and_memory_are_writable_by_anyone() {
|
||||||
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
|
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
|
||||||
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
|
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
|
||||||
|
let default = OrchestratorDesignation::none();
|
||||||
for who in [ConversationParty::User, agent_party(1)] {
|
for who in [ConversationParty::User, agent_party(1)] {
|
||||||
assert!(may_write_directly(who, &mem));
|
assert!(may_write_directly(who, &mem, &default));
|
||||||
assert!(may_write_directly(who, &ctx));
|
assert!(may_write_directly(who, &ctx, &default));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_orchestrator_only_for_user() {
|
fn is_orchestrator_for_user_and_designated_agent() {
|
||||||
assert!(is_orchestrator(ConversationParty::User));
|
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||||
assert!(!is_orchestrator(agent_party(1)));
|
// Human is always an orchestrator, designation or not.
|
||||||
|
assert!(is_orchestrator(
|
||||||
|
ConversationParty::User,
|
||||||
|
&OrchestratorDesignation::none()
|
||||||
|
));
|
||||||
|
// An agent is an orchestrator only when it is the designated one.
|
||||||
|
assert!(!is_orchestrator(
|
||||||
|
ConversationParty::agent(id),
|
||||||
|
&OrchestratorDesignation::none()
|
||||||
|
));
|
||||||
|
assert!(is_orchestrator(
|
||||||
|
ConversationParty::agent(id),
|
||||||
|
&OrchestratorDesignation::of(id)
|
||||||
|
));
|
||||||
|
assert!(!is_orchestrator(
|
||||||
|
agent_party(2),
|
||||||
|
&OrchestratorDesignation::of(id)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -49,9 +49,9 @@ pub mod ports;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod readiness;
|
pub mod readiness;
|
||||||
|
pub mod remote;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod session_limit;
|
pub mod session_limit;
|
||||||
pub mod remote;
|
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
@ -93,7 +93,7 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
|||||||
|
|
||||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||||
|
|
||||||
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
|
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||||
|
|
||||||
pub use conversation_log::{
|
pub use conversation_log::{
|
||||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||||
@ -101,8 +101,8 @@ pub use conversation_log::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use fileguard::{
|
pub use fileguard::{
|
||||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
|
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
|
||||||
WriteLease,
|
OrchestratorDesignation, ReadLease, WriteLease,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use markdown::MarkdownDoc;
|
pub use markdown::MarkdownDoc;
|
||||||
@ -126,7 +126,7 @@ pub use permission::{
|
|||||||
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
|
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
|
||||||
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
|
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
|
||||||
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
|
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
|
||||||
ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
|
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use sandbox::{
|
pub use sandbox::{
|
||||||
@ -145,6 +145,6 @@ pub use ports::{
|
|||||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
|
||||||
StoreError, TemplateStore,
|
SpawnSpec, StoreError, TemplateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -894,8 +894,11 @@ pub trait PermissionProjector: Send + Sync {
|
|||||||
///
|
///
|
||||||
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
|
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
|
||||||
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
|
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
|
||||||
fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext)
|
fn project(
|
||||||
-> PermissionProjection;
|
&self,
|
||||||
|
eff: Option<&EffectivePermissions>,
|
||||||
|
ctx: &ProjectionContext,
|
||||||
|
) -> PermissionProjection;
|
||||||
|
|
||||||
/// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
|
/// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
|
||||||
/// projector owns, so the swap path can clean them up when an agent moves
|
/// projector owns, so the swap path can clean them up when an agent moves
|
||||||
@ -1448,7 +1451,10 @@ mod tests {
|
|||||||
let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");
|
let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");
|
||||||
|
|
||||||
// Files: OS-enforced / Landlock when supported.
|
// Files: OS-enforced / Landlock when supported.
|
||||||
assert!(md.contains("OS-enforced"), "files block must say OS-enforced");
|
assert!(
|
||||||
|
md.contains("OS-enforced"),
|
||||||
|
"files block must say OS-enforced"
|
||||||
|
);
|
||||||
assert!(md.contains("Landlock"), "files block must name Landlock");
|
assert!(md.contains("Landlock"), "files block must name Landlock");
|
||||||
// Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
|
// Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
|
||||||
assert!(md.contains("advisory"), "commands must be called advisory");
|
assert!(md.contains("advisory"), "commands must be called advisory");
|
||||||
@ -1462,7 +1468,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// The actual rules surface in their respective sections.
|
// The actual rules surface in their respective sections.
|
||||||
assert!(md.contains("`src/**`"), "the file scope is shown");
|
assert!(md.contains("`src/**`"), "the file scope is shown");
|
||||||
assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown");
|
assert!(
|
||||||
|
md.contains("`rm *` (prefix)"),
|
||||||
|
"the command matcher is shown"
|
||||||
|
);
|
||||||
// Resolved posture is surfaced.
|
// Resolved posture is surfaced.
|
||||||
assert!(md.contains("**Default posture:** Ask"));
|
assert!(md.contains("**Default posture:** Ask"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,6 +112,8 @@ pub struct PreparedContext {
|
|||||||
pub content: MarkdownDoc,
|
pub content: MarkdownDoc,
|
||||||
/// Relative path of the `.md` inside the project.
|
/// Relative path of the `.md` inside the project.
|
||||||
pub relative_path: String,
|
pub relative_path: String,
|
||||||
|
/// Absolute root of the project this context belongs to.
|
||||||
|
pub project_root: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
||||||
|
|||||||
@ -422,6 +422,12 @@ pub struct McpServerWiring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl McpServerWiring {
|
impl McpServerWiring {
|
||||||
|
/// Codex's MCP client defaults tool calls to a short interactive timeout (120s on
|
||||||
|
/// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous
|
||||||
|
/// that can legitimately span a full delegated dev/test turn, so the generated
|
||||||
|
/// server config must widen the per-tool timeout.
|
||||||
|
pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60;
|
||||||
|
|
||||||
/// Construit le wiring depuis ses parties.
|
/// Construit le wiring depuis ses parties.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||||
@ -475,9 +481,9 @@ impl McpServerWiring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
||||||
/// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en
|
/// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique
|
||||||
/// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes
|
/// des outils IdeA. Chaque chaîne est échappée en chaîne basique TOML
|
||||||
/// restent valides).
|
/// (équivalent du `json_string` : espaces/backslash/quotes restent valides).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn to_config_toml(&self) -> String {
|
pub fn to_config_toml(&self) -> String {
|
||||||
let command = toml_string(&self.command);
|
let command = toml_string(&self.command);
|
||||||
@ -488,8 +494,9 @@ impl McpServerWiring {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let transport = self.transport_label();
|
let transport = self.transport_label();
|
||||||
|
let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
|
||||||
format!(
|
format!(
|
||||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -910,11 +917,16 @@ impl AgentProfile {
|
|||||||
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn materializes_idea_bridge(&self) -> bool {
|
pub fn materializes_idea_bridge(&self) -> bool {
|
||||||
match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) {
|
match (
|
||||||
|
self.structured_adapter,
|
||||||
|
self.mcp.as_ref().map(|c| &c.config),
|
||||||
|
) {
|
||||||
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
||||||
target == ".mcp.json"
|
target == ".mcp.json"
|
||||||
}
|
}
|
||||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true,
|
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
||||||
|
true
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1262,7 +1274,10 @@ mod mcp_tests {
|
|||||||
!json.contains("stallAfterMs"),
|
!json.contains("stallAfterMs"),
|
||||||
"an unset stall threshold must be omitted; got: {json}"
|
"an unset stall threshold must be omitted; got: {json}"
|
||||||
);
|
);
|
||||||
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
assert!(
|
||||||
|
json.contains("turnTimeoutMs"),
|
||||||
|
"set threshold present: {json}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1351,8 +1366,7 @@ mod mcp_tests {
|
|||||||
assert!(codex.materializes_idea_bridge());
|
assert!(codex.materializes_idea_bridge());
|
||||||
|
|
||||||
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||||
let codex_no_mcp =
|
let codex_no_mcp = profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||||
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
|
||||||
assert!(!codex_no_mcp.materializes_idea_bridge());
|
assert!(!codex_no_mcp.materializes_idea_bridge());
|
||||||
|
|
||||||
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
||||||
@ -1448,6 +1462,16 @@ mod mcp_tests {
|
|||||||
toml.contains("transport = \"stdio\""),
|
toml.contains("transport = \"stdio\""),
|
||||||
"transport expected; got: {toml}"
|
"transport expected; got: {toml}"
|
||||||
);
|
);
|
||||||
|
// IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting
|
||||||
|
// `.mcp.json` path.
|
||||||
|
assert!(
|
||||||
|
toml.contains("default_tools_approval_mode = \"approve\""),
|
||||||
|
"IdeA MCP tools should not prompt for approval; got: {toml}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
toml.contains("tool_timeout_sec = 86400"),
|
||||||
|
"IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||||
@ -1519,8 +1543,14 @@ mod mcp_tests {
|
|||||||
let json = serde_json::to_string(&profile).expect("serialise");
|
let json = serde_json::to_string(&profile).expect("serialise");
|
||||||
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
||||||
// camelCase respecté sur les champs de RateLimitPattern.
|
// camelCase respecté sur les champs de RateLimitPattern.
|
||||||
assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}");
|
assert!(
|
||||||
assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}");
|
json.contains("resetCapture"),
|
||||||
|
"camelCase field resetCapture: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("timeFormat"),
|
||||||
|
"camelCase field timeFormat: {json}"
|
||||||
|
);
|
||||||
|
|
||||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||||
assert_eq!(profile, back);
|
assert_eq!(profile, back);
|
||||||
@ -1531,7 +1561,10 @@ mod mcp_tests {
|
|||||||
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
||||||
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
||||||
let json = serde_json::to_string(&pattern).expect("serialise");
|
let json = serde_json::to_string(&pattern).expect("serialise");
|
||||||
assert!(json.contains("\"pattern\""), "pattern field present: {json}");
|
assert!(
|
||||||
|
json.contains("\"pattern\""),
|
||||||
|
"pattern field present: {json}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!json.contains("resetCapture"),
|
!json.contains("resetCapture"),
|
||||||
"an unset resetCapture must be omitted; got: {json}"
|
"an unset resetCapture must be omitted; got: {json}"
|
||||||
|
|||||||
@ -89,11 +89,9 @@ impl ReadinessPolicy {
|
|||||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||||
match event {
|
match event {
|
||||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||||
ReplyEvent::RateLimited { resets_at_ms } => {
|
ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited {
|
||||||
Some(ReadinessSignal::RateLimited {
|
resets_at_ms: *resets_at_ms,
|
||||||
resets_at_ms: *resets_at_ms,
|
}),
|
||||||
})
|
|
||||||
}
|
|
||||||
ReplyEvent::TextDelta { .. }
|
ReplyEvent::TextDelta { .. }
|
||||||
| ReplyEvent::ToolActivity { .. }
|
| ReplyEvent::ToolActivity { .. }
|
||||||
| ReplyEvent::Heartbeat => None,
|
| ReplyEvent::Heartbeat => None,
|
||||||
@ -123,7 +121,9 @@ mod tests {
|
|||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
ReadinessPolicy::classify(&ReplyEvent::ToolActivity {
|
||||||
|
label: "lit".into()
|
||||||
|
}),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -392,7 +392,7 @@ fn join_root(base: &str, rel: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve};
|
use crate::permission::{resolve, PathScope, PermissionRule, PermissionSet};
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------
|
||||||
|
|
||||||
@ -668,7 +668,10 @@ mod tests {
|
|||||||
PathAccess::RO,
|
PathAccess::RO,
|
||||||
"RW class fenced out, RO class preserved on the same root"
|
"RW class fenced out, RO class preserved on the same root"
|
||||||
);
|
);
|
||||||
assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped");
|
assert!(
|
||||||
|
!g.access.contains(PathAccess::RW),
|
||||||
|
"the RW grant was dropped"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -163,7 +163,10 @@ mod tests {
|
|||||||
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
||||||
match plan {
|
match plan {
|
||||||
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
||||||
assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)");
|
assert_eq!(
|
||||||
|
fire_at_ms, NOW,
|
||||||
|
"un reset déjà passé ⇒ reprise immédiate (now)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -392,6 +392,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
|||||||
let ctx = PreparedContext {
|
let ctx = PreparedContext {
|
||||||
content: MarkdownDoc::new("# ctx"),
|
content: MarkdownDoc::new("# ctx"),
|
||||||
relative_path: "CLAUDE.md".to_owned(),
|
relative_path: "CLAUDE.md".to_owned(),
|
||||||
|
project_root: "/srv/project".to_owned(),
|
||||||
};
|
};
|
||||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||||
let session = factory
|
let session = factory
|
||||||
|
|||||||
@ -5,10 +5,17 @@
|
|||||||
//! concurrent readers **or** one exclusive writer per resource; different resources
|
//! concurrent readers **or** one exclusive writer per resource; different resources
|
||||||
//! are independent (their locks are distinct).
|
//! are independent (their locks are distinct).
|
||||||
//!
|
//!
|
||||||
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
|
//! ## Pure lock — no authorization here (cadrage « orchestrateur du projet », Alt. A)
|
||||||
//! **before** taking any lock: a `who` that is not the orchestrator
|
//!
|
||||||
//! ([`domain::may_write_directly`] returning `false`) is rejected with
|
//! This adapter is a **pure serialisation primitive**: `acquire_write` never returns
|
||||||
//! [`GuardError::Forbidden`] — it must *propose* instead.
|
//! [`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)
|
//! ## Cooperative scope (cadrage §9.5)
|
||||||
//!
|
//!
|
||||||
@ -32,9 +39,7 @@ use async_trait::async_trait;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::fileguard::{
|
use domain::fileguard::{FileGuard, GuardError, GuardedResource, ReadLease, WriteLease};
|
||||||
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
|
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
|
||||||
///
|
///
|
||||||
@ -85,14 +90,12 @@ impl FileGuard for RwFileGuard {
|
|||||||
|
|
||||||
async fn acquire_write(
|
async fn acquire_write(
|
||||||
&self,
|
&self,
|
||||||
who: ConversationParty,
|
_who: ConversationParty,
|
||||||
res: GuardedResource,
|
res: GuardedResource,
|
||||||
) -> Result<WriteLease, GuardError> {
|
) -> Result<WriteLease, GuardError> {
|
||||||
// Single-writer rule for the global project context: refuse *before* taking
|
// Pure lock (Alt. A): no authorization here — `ProposeContext` decides who may
|
||||||
// any lock so a forbidden writer never blocks readers.
|
// write the global context. Serialise behind any in-flight readers/writer of
|
||||||
if !may_write_directly(who, &res) {
|
// the same resource, then hand back the exclusive RAII lease.
|
||||||
return Err(GuardError::Forbidden);
|
|
||||||
}
|
|
||||||
let lock = self.lock_for(&res);
|
let lock = self.lock_for(&res);
|
||||||
let guard = lock.write_owned().await;
|
let guard = lock.write_owned().await;
|
||||||
Ok(WriteLease::new(Box::new(guard)))
|
Ok(WriteLease::new(Box::new(guard)))
|
||||||
@ -170,22 +173,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 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)
|
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.is_ok());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -94,6 +94,10 @@ type HeadlessSink =
|
|||||||
/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement
|
/// 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`.
|
/// quand le profil de la cible ne fournit pas de `submit_delay_ms`.
|
||||||
const DEFAULT_SUBMIT_DELAY_MS: u32 = 60;
|
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).
|
/// É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).
|
// 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
|
// 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.
|
// 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 || {
|
std::thread::spawn(move || {
|
||||||
let _ = pty.write(&handle, text.as_bytes());
|
let _ = write_delegation_chunks(pty.as_ref(), &handle, &text);
|
||||||
if delay > 0 {
|
if delay > 0 {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
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}")
|
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 {
|
impl InputMediator for MediatedInbox {
|
||||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||||
let ticket_id = ticket.id;
|
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]
|
#[tokio::test]
|
||||||
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
|
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
|
||||||
let inbox = inbox_at(5);
|
let inbox = inbox_at(5);
|
||||||
|
|||||||
@ -57,11 +57,11 @@ pub use orchestrator::{
|
|||||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||||
pub use process::LocalProcessSpawner;
|
pub use process::LocalProcessSpawner;
|
||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
|
pub use ratelimit::RateLimitParser;
|
||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use sandbox::LandlockSandbox;
|
pub use sandbox::LandlockSandbox;
|
||||||
pub use ratelimit::RateLimitParser;
|
|
||||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||||
pub use scheduler::TokioScheduler;
|
pub use scheduler::TokioScheduler;
|
||||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||||
|
|||||||
@ -292,8 +292,14 @@ mod tests {
|
|||||||
|
|
||||||
let deny = str_array(&json["permissions"]["deny"]);
|
let deny = str_array(&json["permissions"]["deny"]);
|
||||||
// A Write capability fans out to both Edit(..) and Write(..) entries.
|
// A Write capability fans out to both Edit(..) and Write(..) entries.
|
||||||
assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}");
|
assert!(
|
||||||
assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}");
|
deny.contains(&"Edit(.ideai/**)".to_owned()),
|
||||||
|
"deny={deny:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
deny.contains(&"Write(.ideai/**)".to_owned()),
|
||||||
|
"deny={deny:?}"
|
||||||
|
);
|
||||||
|
|
||||||
let allow = str_array(&json["permissions"]["allow"]);
|
let allow = str_array(&json["permissions"]["allow"]);
|
||||||
assert!(
|
assert!(
|
||||||
@ -330,7 +336,10 @@ mod tests {
|
|||||||
"Bash(shutdown*)",
|
"Bash(shutdown*)",
|
||||||
"Bash(reboot*)",
|
"Bash(reboot*)",
|
||||||
] {
|
] {
|
||||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
assert!(
|
||||||
|
deny.contains(&guard.to_owned()),
|
||||||
|
"missing guardrail {guard}; deny={deny:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||||
//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted
|
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
|
||||||
//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` /
|
//! project root as an additional writable directory (`--add-dir`). The
|
||||||
//! `apply_codex_cli_permission_args` in `lifecycle.rs`.
|
//! 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
|
//! 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
|
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
|
||||||
@ -44,7 +46,7 @@ impl PermissionProjector for CodexPermissionProjector {
|
|||||||
fn project(
|
fn project(
|
||||||
&self,
|
&self,
|
||||||
eff: Option<&EffectivePermissions>,
|
eff: Option<&EffectivePermissions>,
|
||||||
_ctx: &ProjectionContext,
|
ctx: &ProjectionContext,
|
||||||
) -> PermissionProjection {
|
) -> PermissionProjection {
|
||||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||||
// native sandbox/approval defaults (no args, no managed keys written).
|
// native sandbox/approval defaults (no args, no managed keys written).
|
||||||
@ -63,6 +65,17 @@ impl PermissionProjector for CodexPermissionProjector {
|
|||||||
toml_string(approval),
|
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 {
|
PermissionProjection {
|
||||||
files: vec![ProjectedFile::MergeToml {
|
files: vec![ProjectedFile::MergeToml {
|
||||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
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(),
|
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||||
contents,
|
contents,
|
||||||
}],
|
}],
|
||||||
args: vec![
|
args,
|
||||||
"--sandbox".to_owned(),
|
|
||||||
sandbox.to_owned(),
|
|
||||||
"--ask-for-approval".to_owned(),
|
|
||||||
approval.to_owned(),
|
|
||||||
],
|
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +143,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||||
// coherence + MergeToml shape -------------------------------
|
// coherence + add-dir + MergeToml shape ---------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
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.
|
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
proj.args,
|
&proj.args[..4],
|
||||||
vec![
|
vec![
|
||||||
"--sandbox".to_owned(),
|
"--sandbox".to_owned(),
|
||||||
sandbox.to_owned(),
|
sandbox.to_owned(),
|
||||||
@ -185,6 +193,21 @@ mod tests {
|
|||||||
],
|
],
|
||||||
"posture {posture:?}: args must mirror the TOML values"
|
"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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -520,8 +520,10 @@ mod sandbox_e2e_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
|
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
|
||||||
if !landlock_is_enforced() {
|
if !landlock_is_enforced() {
|
||||||
eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
eprintln!(
|
||||||
Landlock not available/enforced on this kernel");
|
"skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
||||||
|
Landlock not available/enforced on this kernel"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -92,10 +92,7 @@ impl ResetTimeFormat {
|
|||||||
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||||
Self::EpochMillis => c.parse::<i64>().ok(),
|
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||||
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||||
Self::RelativeSeconds => c
|
Self::RelativeSeconds => c.parse::<f64>().ok().map(|d| now_ms + (d * 1000.0) as i64),
|
||||||
.parse::<f64>()
|
|
||||||
.ok()
|
|
||||||
.map(|d| now_ms + (d * 1000.0) as i64),
|
|
||||||
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
|
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
|
||||||
Self::WallClock => {
|
Self::WallClock => {
|
||||||
let (h, m, s) = timeparse::parse_wall_clock(c)?;
|
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)))?;
|
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
|
||||||
self.time_format.resolve(raw.as_str(), now_ms)
|
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]
|
#[test]
|
||||||
fn detect_match_without_reset_capture_has_no_time() {
|
fn detect_match_without_reset_capture_has_no_time() {
|
||||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
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.resets_at_ms, None);
|
||||||
assert_eq!(limit.detected_at_ms, NOW);
|
assert_eq!(limit.detected_at_ms, NOW);
|
||||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||||
@ -340,9 +343,13 @@ mod tests {
|
|||||||
Some("epoch_s"),
|
Some("epoch_s"),
|
||||||
))
|
))
|
||||||
.unwrap();
|
.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());
|
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, b.resets_at_ms);
|
||||||
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
|
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
|
||||||
}
|
}
|
||||||
@ -368,7 +375,10 @@ mod tests {
|
|||||||
let profile = base_profile()
|
let profile = base_profile()
|
||||||
.with_structured_adapter(StructuredAdapter::Claude)
|
.with_structured_adapter(StructuredAdapter::Claude)
|
||||||
.with_rate_limit_pattern(pattern("rate limit", None, None));
|
.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]
|
#[test]
|
||||||
|
|||||||
@ -324,8 +324,7 @@ mod tests {
|
|||||||
.enforce(&plan)
|
.enforce(&plan)
|
||||||
.expect("an Allow fallback never fails closed");
|
.expect("an Allow fallback never fails closed");
|
||||||
let read = std::fs::read(outside_t.join("pre.txt"));
|
let read = std::fs::read(outside_t.join("pre.txt"));
|
||||||
let write =
|
let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
||||||
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
|
||||||
(status, read, write)
|
(status, read, write)
|
||||||
})
|
})
|
||||||
.join()
|
.join()
|
||||||
|
|||||||
@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedSender;
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use domain::ids::ScheduleId;
|
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`].
|
/// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`].
|
||||||
///
|
///
|
||||||
@ -111,7 +111,7 @@ mod tests {
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
use domain::ports::{Clock, ScheduledTask, Scheduler};
|
||||||
|
|
||||||
use super::TokioScheduler;
|
use super::TokioScheduler;
|
||||||
use crate::SystemClock;
|
use crate::SystemClock;
|
||||||
@ -126,7 +126,11 @@ mod tests {
|
|||||||
|
|
||||||
/// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle
|
/// 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).
|
/// (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 (tx, rx) = unbounded_channel();
|
||||||
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
|
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
|
||||||
(TokioScheduler::new(tx, clock.clone()), rx, clock)
|
(TokioScheduler::new(tx, clock.clone()), rx, clock)
|
||||||
|
|||||||
@ -122,6 +122,8 @@ pub struct CodexExecSession {
|
|||||||
command: String,
|
command: String,
|
||||||
/// Répertoire de travail (run dir isolé §14.1).
|
/// Répertoire de travail (run dir isolé §14.1).
|
||||||
cwd: String,
|
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.
|
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
@ -143,6 +145,7 @@ impl CodexExecSession {
|
|||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
cwd: impl Into<String>,
|
cwd: impl Into<String>,
|
||||||
seed_conversation_id: Option<String>,
|
seed_conversation_id: Option<String>,
|
||||||
|
writable_roots: Vec<String>,
|
||||||
sandbox: Option<SandboxPlan>,
|
sandbox: Option<SandboxPlan>,
|
||||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@ -150,6 +153,7 @@ impl CodexExecSession {
|
|||||||
id,
|
id,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
|
writable_roots,
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
sandbox,
|
sandbox,
|
||||||
sandbox_enforcer,
|
sandbox_enforcer,
|
||||||
@ -160,18 +164,18 @@ impl CodexExecSession {
|
|||||||
///
|
///
|
||||||
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
||||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
/// - 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
|
/// - 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 à
|
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||||
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
|
/// é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
|
/// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag
|
||||||
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
|
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
|
||||||
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Défaut
|
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent
|
||||||
/// raisonnable, aligné sur l'autonomie projet (CLAUDE.md §12) ; à terme **piloté par
|
/// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root
|
||||||
/// les permissions de l'agent** (`.ideai/permissions.json` + sandbox OS) — non
|
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
|
||||||
/// implémenté ici.
|
|
||||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||||
let mut args = vec!["exec".to_owned()];
|
let mut args = vec!["exec".to_owned()];
|
||||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
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("--skip-git-repo-check".to_owned());
|
||||||
args.push("--sandbox".to_owned());
|
args.push("--sandbox".to_owned());
|
||||||
args.push("workspace-write".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());
|
args.push(prompt.to_owned());
|
||||||
SpawnLine {
|
SpawnLine {
|
||||||
command: self.command.clone(),
|
command: self.command.clone(),
|
||||||
|
|||||||
@ -79,7 +79,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
async fn start(
|
async fn start(
|
||||||
&self,
|
&self,
|
||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
_ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
sandbox: Option<&SandboxPlan>,
|
sandbox: Option<&SandboxPlan>,
|
||||||
@ -109,12 +109,18 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
||||||
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
||||||
let session: Arc<dyn AgentSession> = match adapter {
|
let session: Arc<dyn AgentSession> = match adapter {
|
||||||
StructuredAdapter::Claude => {
|
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
|
||||||
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
|
id, command, cwd, seed, plan, enforcer,
|
||||||
}
|
)),
|
||||||
StructuredAdapter::Codex => {
|
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
|
||||||
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
|
id,
|
||||||
}
|
command,
|
||||||
|
cwd,
|
||||||
|
seed,
|
||||||
|
vec![ctx.project_root.clone()],
|
||||||
|
plan,
|
||||||
|
enforcer,
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,7 @@ mod tests {
|
|||||||
PreparedContext {
|
PreparedContext {
|
||||||
content: MarkdownDoc::new("# ctx"),
|
content: MarkdownDoc::new("# ctx"),
|
||||||
relative_path: "CLAUDE.md".to_owned(),
|
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).
|
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
||||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||||
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
||||||
let completed =
|
let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
|
||||||
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
||||||
|
|
||||||
let msg = codex::parse_event(
|
let msg = codex::parse_event(
|
||||||
@ -310,6 +310,7 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
@ -321,7 +322,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stream_is_closed_after_final() {
|
async fn stream_is_closed_after_final() {
|
||||||
let fake = FakeCli::printing(&claude_script());
|
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 stream = session.send("x").await.expect("send ok");
|
||||||
let events: Vec<_> = stream.collect();
|
let events: Vec<_> = stream.collect();
|
||||||
let after_final = events
|
let after_final = events
|
||||||
@ -339,7 +347,14 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||||
"{ ceci n'est pas du json",
|
"{ 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 {
|
match session.send("x").await {
|
||||||
Err(AgentSessionError::Decode(_)) => {}
|
Err(AgentSessionError::Decode(_)) => {}
|
||||||
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
||||||
@ -437,6 +452,42 @@ mod tests {
|
|||||||
assert_eq!(content_cx, "réponse Codex");
|
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]
|
#[tokio::test]
|
||||||
async fn factory_resume_seeds_conversation_id() {
|
async fn factory_resume_seeds_conversation_id() {
|
||||||
let factory = StructuredSessionFactory::new();
|
let factory = StructuredSessionFactory::new();
|
||||||
@ -564,10 +615,14 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
static C: AtomicU64 = AtomicU64::new(0);
|
static C: AtomicU64 = AtomicU64::new(0);
|
||||||
let n = C.fetch_add(1, Ordering::Relaxed);
|
let n = C.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut bin = std::env::temp_dir();
|
let dir = std::env::current_dir()
|
||||||
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
|
.expect("cwd")
|
||||||
let mut argv = std::env::temp_dir();
|
.join("target")
|
||||||
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
|
.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");
|
let mut s = String::from("#!/bin/sh\n");
|
||||||
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
|
// 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":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
||||||
r#"{"type":"turn.completed","usage":{}}"#,
|
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 events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||||
let finals = events
|
let finals = events
|
||||||
.iter()
|
.iter()
|
||||||
@ -789,7 +852,10 @@ mod tests {
|
|||||||
.skip(1)
|
.skip(1)
|
||||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||||
.count();
|
.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
|
/// 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":"system","subtype":"init","session_id":"c"}"#,
|
||||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
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 events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
||||||
let finals = events
|
let finals = events
|
||||||
.iter()
|
.iter()
|
||||||
@ -959,6 +1032,7 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -983,7 +1057,8 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"r","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);
|
assert_eq!(session.conversation_id(), None);
|
||||||
let _ = session.send("t1").await.expect("t1");
|
let _ = session.send("t1").await.expect("t1");
|
||||||
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
||||||
@ -1093,6 +1168,7 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
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":"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}"#,
|
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();
|
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
@ -1160,7 +1243,8 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"r","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 _ = session.send("bonjour").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1192,7 +1276,15 @@ mod tests {
|
|||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
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 _ = session.send("salut").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
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
|
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
|
||||||
// d'écriture Codex : la commande générée porte EXACTEMENT
|
// 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`
|
// (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
|
// 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
|
// 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,
|
/// Conversation NEUVE : argv EXACT `[exec, --json, --skip-git-repo-check,
|
||||||
/// --sandbox, workspace-write, <prompt>]`. Pas de sous-commande `resume`,
|
/// --sandbox, workspace-write, --add-dir, <project-root>, <prompt>]`.
|
||||||
/// pas de `--ask-for-approval`.
|
/// Pas de sous-commande `resume`, pas de `--ask-for-approval`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_new_conversation_command_carries_exact_args() {
|
async fn codex_new_conversation_command_carries_exact_args() {
|
||||||
let (cmd, argv) = make_recording_fake(&[
|
let (cmd, argv) = make_recording_fake(&[
|
||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
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 _ = session.send("salut").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1247,6 +1348,8 @@ mod tests {
|
|||||||
"--skip-git-repo-check",
|
"--skip-git-repo-check",
|
||||||
"--sandbox",
|
"--sandbox",
|
||||||
"workspace-write",
|
"workspace-write",
|
||||||
|
"--add-dir",
|
||||||
|
"/project/root",
|
||||||
"salut",
|
"salut",
|
||||||
],
|
],
|
||||||
"argv neuf doit être exact (sans resume, sans --ask-for-approval), vu: {args:?}"
|
"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,
|
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
|
||||||
/// --skip-git-repo-check, --sandbox, workspace-write, <prompt>]`. Toujours
|
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
|
||||||
/// pas de `--ask-for-approval`.
|
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_resume_command_carries_exact_args() {
|
async fn codex_resume_command_carries_exact_args() {
|
||||||
let (cmd, argv) = make_recording_fake(&[
|
let (cmd, argv) = make_recording_fake(&[
|
||||||
@ -1268,6 +1371,7 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
|
vec!["/project/root".to_owned()],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1285,6 +1389,8 @@ mod tests {
|
|||||||
"--skip-git-repo-check",
|
"--skip-git-repo-check",
|
||||||
"--sandbox",
|
"--sandbox",
|
||||||
"workspace-write",
|
"workspace-write",
|
||||||
|
"--add-dir",
|
||||||
|
"/project/root",
|
||||||
"vas-y",
|
"vas-y",
|
||||||
],
|
],
|
||||||
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
|
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
|
||||||
@ -1339,13 +1445,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
|
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
|
||||||
// < 10^12 ⇒ secondes ⇒ ×1000.
|
// < 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]
|
#[test]
|
||||||
fn parse_reset_ms_integer_millis_are_kept_as_is() {
|
fn parse_reset_ms_integer_millis_are_kept_as_is() {
|
||||||
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
|
// ≥ 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
|
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
|
||||||
@ -1388,12 +1500,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
|
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]
|
#[test]
|
||||||
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
|
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) --------------
|
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
|
||||||
@ -1487,11 +1605,20 @@ mod tests {
|
|||||||
// Pas de séparateur de date/heure.
|
// Pas de séparateur de date/heure.
|
||||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
|
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
|
||||||
// Année non numérique.
|
// 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).
|
// 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.
|
// 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.
|
// Minute manquante dans l'heure.
|
||||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
|
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":"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}"#,
|
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();
|
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
|
|||||||
@ -158,8 +158,7 @@ async fn run_turn_sandboxed(
|
|||||||
|
|
||||||
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result =
|
let result = drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||||
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
|
||||||
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
||||||
let _ = done_tx.send(result);
|
let _ = done_tx.send(result);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -187,7 +187,10 @@ async fn structured_run_turn_without_plan_does_not_sandbox() {
|
|||||||
.expect("run_turn natif réussit");
|
.expect("run_turn natif réussit");
|
||||||
|
|
||||||
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
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!(
|
assert!(
|
||||||
denied_marker.exists(),
|
denied_marker.exists(),
|
||||||
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
|
"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() {
|
async fn structured_run_turn_none_plan_is_native_path() {
|
||||||
let dir = fresh_dir("p4");
|
let dir = fresh_dir("p4");
|
||||||
let marker = dir.join("native.txt");
|
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 {
|
let spec = SpawnLine {
|
||||||
command: "sh".to_owned(),
|
command: "sh".to_owned(),
|
||||||
args: vec!["-c".to_owned(), script],
|
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))
|
run_turn(&spec_a, None, Some(&enforcer))
|
||||||
.await
|
.await
|
||||||
.expect("tour A ok");
|
.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!(
|
assert!(
|
||||||
!a_into_b.exists(),
|
!a_into_b.exists(),
|
||||||
"tour A: écriture dans B (hors grant A) doit être bloquée"
|
"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;
|
use super::factory::StructuredSessionFactory;
|
||||||
|
|
||||||
if !landlock_is_enforced() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,6 +459,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
|||||||
let ctx = PreparedContext {
|
let ctx = PreparedContext {
|
||||||
content: MarkdownDoc::new("# ctx"),
|
content: MarkdownDoc::new("# ctx"),
|
||||||
relative_path: "CLAUDE.md".to_owned(),
|
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 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
|
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()
|
.iter()
|
||||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||||
.count();
|
.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.
|
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -152,6 +152,7 @@ impl AgentContextStore for IdeaiContextStore {
|
|||||||
Err(FsError::NotFound(_)) => Ok(AgentManifest {
|
Err(FsError::NotFound(_)) => Ok(AgentManifest {
|
||||||
version: MANIFEST_VERSION,
|
version: MANIFEST_VERSION,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
}),
|
}),
|
||||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -186,7 +186,11 @@ fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
|
|||||||
return Some((s.parse::<i64>().ok()?, 0));
|
return Some((s.parse::<i64>().ok()?, 0));
|
||||||
};
|
};
|
||||||
let sec = sec.parse::<i64>().ok()?;
|
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 {
|
while d3.len() < 3 {
|
||||||
d3.push('0');
|
d3.push('0');
|
||||||
}
|
}
|
||||||
@ -257,7 +261,10 @@ mod tests {
|
|||||||
fn wall_clock_to_ms_same_day_when_future() {
|
fn wall_clock_to_ms_same_day_when_future() {
|
||||||
let now_10h = DAY_START + 10 * 3_600_000;
|
let now_10h = DAY_START + 10 * 3_600_000;
|
||||||
// 15:00 est dans le futur ⇒ même jour.
|
// 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]
|
#[test]
|
||||||
@ -282,7 +289,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_absolute_ms_epoch_seconds_and_millis() {
|
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("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]
|
#[test]
|
||||||
|
|||||||
@ -45,6 +45,7 @@ fn ctx() -> PreparedContext {
|
|||||||
PreparedContext {
|
PreparedContext {
|
||||||
content: MarkdownDoc::new("# hi"),
|
content: MarkdownDoc::new("# hi"),
|
||||||
relative_path: ".ideai/agent.md".to_owned(),
|
relative_path: ".ideai/agent.md".to_owned(),
|
||||||
|
project_root: "/repo".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -151,3 +151,63 @@ async fn manifest_file_is_camelcase_json_under_ideai() {
|
|||||||
);
|
);
|
||||||
assert!(entry.get("md_path").is_none(), "no snake_case leak");
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -78,6 +78,7 @@ impl FakeContexts {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})))
|
})))
|
||||||
@ -1227,21 +1228,18 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
|
|||||||
agent_id,
|
agent_id,
|
||||||
SessionId::from_uuid(Uuid::from_u128(910)),
|
SessionId::from_uuid(Uuid::from_u128(910)),
|
||||||
);
|
);
|
||||||
let server = server(service)
|
let server = server(service).with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
||||||
.with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
|
||||||
|
|
||||||
let raw = tools_call(
|
let raw = tools_call(
|
||||||
1,
|
1,
|
||||||
"idea_ask_agent",
|
"idea_ask_agent",
|
||||||
json!({ "target": "architect", "task": "never answered" }),
|
json!({ "target": "architect", "task": "never answered" }),
|
||||||
);
|
);
|
||||||
let response = tokio::time::timeout(
|
let response =
|
||||||
std::time::Duration::from_secs(10),
|
tokio::time::timeout(std::time::Duration::from_secs(10), server.handle_raw(&raw))
|
||||||
server.handle_raw(&raw),
|
.await
|
||||||
)
|
.expect("must not hang past the injected timeout")
|
||||||
.await
|
.expect("reply owed");
|
||||||
.expect("must not hang past the injected timeout")
|
|
||||||
.expect("reply owed");
|
|
||||||
|
|
||||||
let error = response.error.expect("a JSON-RPC error on timeout");
|
let error = response.error.expect("a JSON-RPC error on timeout");
|
||||||
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");
|
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");
|
||||||
|
|||||||
@ -74,6 +74,7 @@ impl FakeContexts {
|
|||||||
manifest: AgentManifest {
|
manifest: AgentManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
|
orchestrator: None,
|
||||||
},
|
},
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
})))
|
})))
|
||||||
|
|||||||
Reference in New Issue
Block a user