chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -19,7 +19,8 @@
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
@ -161,7 +162,9 @@ mod mcp_tests {
|
||||
let mcp = profile(slug).mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"profile `{slug}` should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
|
||||
@ -461,8 +461,8 @@ impl ChangeAgentProfile {
|
||||
mutated_agent = Some(agent);
|
||||
}
|
||||
}
|
||||
let agent = mutated_agent
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let agent =
|
||||
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
@ -524,8 +524,10 @@ impl ChangeAgentProfile {
|
||||
// Capture the preserved pair id (stable across all leaves of this
|
||||
// agent) — used to relaunch with handoff re-injection (P7).
|
||||
if pair_id.is_none() {
|
||||
if let Some(cid) =
|
||||
named.tree.leaf(leaf_id).and_then(|l| l.conversation_id.clone())
|
||||
if let Some(cid) = named
|
||||
.tree
|
||||
.leaf(leaf_id)
|
||||
.and_then(|l| l.conversation_id.clone())
|
||||
{
|
||||
pair_id = Some(cid);
|
||||
}
|
||||
@ -1300,14 +1302,13 @@ impl LaunchAgent {
|
||||
// pure partagée avec `resolve_conversation` (aucun couplage à
|
||||
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
|
||||
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
|
||||
let pair_conversation_id =
|
||||
input.conversation_id.clone().unwrap_or_else(|| {
|
||||
ConversationId::for_pair(
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(agent.id),
|
||||
)
|
||||
.to_string()
|
||||
});
|
||||
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
|
||||
ConversationId::for_pair(
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(agent.id),
|
||||
)
|
||||
.to_string()
|
||||
});
|
||||
return self
|
||||
.launch_structured(
|
||||
factory.as_ref(),
|
||||
@ -1679,10 +1680,18 @@ impl LaunchAgent {
|
||||
///
|
||||
/// Dispatch by [`McpConfigStrategy`]:
|
||||
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
|
||||
/// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly
|
||||
/// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited)
|
||||
/// is left untouched, and any write/exists failure is swallowed so it **never**
|
||||
/// fails the launch.
|
||||
/// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is
|
||||
/// swallowed so it **never** fails the launch), with two clobber regimes:
|
||||
/// * with an injected [`McpRuntime`] (real app-tauri launch) the file is
|
||||
/// **regenerated and clobbered on every (re)launch**, exactly like the
|
||||
/// convention file. The declaration's `command` carries the **stable** IdeA
|
||||
/// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between
|
||||
/// runs (the AppImage mount path changes at each remount/reboot), so a stale
|
||||
/// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is
|
||||
/// IdeA-managed (not user-edited), so clobbering is safe;
|
||||
/// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal
|
||||
/// declaration is available, so the write stays **non-clobbering** (mirrors
|
||||
/// [`Self::seed_cli_permissions`]) — never overwriting a real declaration.
|
||||
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
|
||||
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
|
||||
///
|
||||
@ -1708,17 +1717,33 @@ impl LaunchAgent {
|
||||
};
|
||||
match &mcp.config {
|
||||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||||
// Non-clobbering, best-effort — mirrors `seed_cli_permissions`.
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let declaration = mcp_server_declaration(mcp.transport, runtime);
|
||||
// Best-effort: a write failure must not fail the launch.
|
||||
let declaration = mcp_server_declaration(mcp.transport, runtime);
|
||||
match runtime {
|
||||
// Real launch (app-tauri injected the runtime): the declaration
|
||||
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
|
||||
// by `idea_exe_path`) and the project's live loopback endpoint. Both
|
||||
// can drift between runs — the AppImage internal mount path changes
|
||||
// at every remount/reboot — so the file MUST be regenerated and
|
||||
// **clobbered** on every (re)launch, exactly like the convention file
|
||||
// (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited,
|
||||
// so there is nothing to preserve. Best-effort: a write failure must
|
||||
// never fail the launch.
|
||||
Some(_) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
// An exists() probe failure is swallowed too (best-effort).
|
||||
Err(_) => {}
|
||||
// Internal launch (orchestrator / hot-swap / tests): we only have a
|
||||
// degraded **minimal** declaration (no endpoint/project/requester).
|
||||
// Stay non-clobbering — mirrors `seed_cli_permissions` — so we never
|
||||
// overwrite a real declaration previously written by an app-tauri
|
||||
// launch with the minimal one.
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
Err(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||
@ -1976,6 +2001,7 @@ fn claude_settings_seed(project_root: &str) -> String {
|
||||
]
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
@ -2347,11 +2373,7 @@ mod tests {
|
||||
|
||||
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
|
||||
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
|
||||
assert!(
|
||||
doc.contains(
|
||||
"- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"
|
||||
)
|
||||
);
|
||||
assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"));
|
||||
|
||||
// Deterministic order: first entry precedes the second.
|
||||
let alpha_at = doc.find("[Alpha]").unwrap();
|
||||
@ -2533,15 +2555,7 @@ mod tests {
|
||||
MemoryType::Project,
|
||||
)];
|
||||
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&h),
|
||||
false,
|
||||
);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false);
|
||||
|
||||
assert!(
|
||||
doc.contains("# Reprise de la conversation"),
|
||||
@ -2636,6 +2650,8 @@ mod tests {
|
||||
// Valid JSON.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
|
||||
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
|
||||
// IdeA MCP server pre-approved so idea_* tools load without a prompt.
|
||||
assert_eq!(parsed["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
"/home/me/proj"
|
||||
|
||||
@ -20,19 +20,17 @@ pub use structured::send_blocking;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
ProviderSessionProvider,
|
||||
LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
|
||||
@ -35,8 +35,8 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub(crate) use store::{persist_doc, resolve_doc};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
MutateLayoutInput, MutateLayoutOutput,
|
||||
|
||||
@ -109,12 +109,7 @@ impl LayoutOperation {
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => tree.split(
|
||||
*target,
|
||||
*direction,
|
||||
LeafCell::new(*new_leaf),
|
||||
*container,
|
||||
),
|
||||
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
||||
Self::Merge {
|
||||
container,
|
||||
keep_index,
|
||||
|
||||
@ -28,19 +28,19 @@ pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider, ProviderSessionProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
||||
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||
UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET,
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
||||
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
||||
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
|
||||
@ -29,9 +29,7 @@ use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{
|
||||
AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath,
|
||||
};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -90,7 +88,11 @@ impl ReadContext {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the requested context, returning its Markdown body.
|
||||
@ -98,7 +100,11 @@ impl ReadContext {
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
let ReadContextInput { project, target, requester } = input;
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
None => {
|
||||
// Global project context: shared read-lease, then read the root file.
|
||||
@ -109,8 +115,8 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
}
|
||||
Some(name) => {
|
||||
@ -173,7 +179,12 @@ impl ProposeContext {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs, clock }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the proposal: direct write under a write-lease, or a materialised
|
||||
@ -182,7 +193,12 @@ impl ProposeContext {
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
|
||||
let ProposeContextInput { project, target, content, requester } = input;
|
||||
let ProposeContextInput {
|
||||
project,
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
Some(name) => {
|
||||
// Per-agent context: direct write under an exclusive write-lease.
|
||||
@ -273,7 +289,11 @@ impl ReadMemory {
|
||||
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
|
||||
/// is [`AppError::Invalid`].
|
||||
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
|
||||
let ReadMemoryInput { project, slug, requester } = input;
|
||||
let ReadMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
requester,
|
||||
} = input;
|
||||
match slug {
|
||||
Some(raw) => {
|
||||
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
@ -329,7 +349,12 @@ impl WriteMemory {
|
||||
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
|
||||
/// failure.
|
||||
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
|
||||
let WriteMemoryInput { project, slug, content, requester } = input;
|
||||
let WriteMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
@ -527,11 +552,7 @@ mod tests {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Memory, MemoryError> {
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
let body = self
|
||||
.notes
|
||||
.lock()
|
||||
@ -556,11 +577,7 @@ mod tests {
|
||||
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<(), MemoryError> {
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(
|
||||
@ -805,7 +822,10 @@ mod tests {
|
||||
guard.acquire_write(agent_party(2), slug.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "a second writer must block while w1 holds");
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"a second writer must block while w1 holds"
|
||||
);
|
||||
drop(w1);
|
||||
let w2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
|
||||
@ -19,15 +19,15 @@ use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::conversation::{
|
||||
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
|
||||
};
|
||||
use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph};
|
||||
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
|
||||
use domain::input::{InputMediator, InputSource};
|
||||
use domain::input::{InputMediator, InputSource, SubmitConfig};
|
||||
use domain::mailbox::{Ticket, TicketId};
|
||||
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
use domain::{
|
||||
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
|
||||
};
|
||||
|
||||
use crate::conversation::RecordTurn;
|
||||
|
||||
@ -416,7 +416,10 @@ impl OrchestratorService {
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} => self.propose_context(project, target, content, requester).await,
|
||||
} => {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -453,10 +456,7 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!(
|
||||
"read {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
})
|
||||
}
|
||||
@ -481,15 +481,17 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
let detail = match outcome {
|
||||
ProposeOutcome::Written => format!(
|
||||
"wrote {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
ProposeOutcome::Written => {
|
||||
format!("wrote {} context", target.as_deref().unwrap_or("project"))
|
||||
}
|
||||
ProposeOutcome::Proposed { path } => {
|
||||
format!("filed proposal for project context at {path}")
|
||||
}
|
||||
};
|
||||
Ok(OrchestratorOutcome { detail, reply: None })
|
||||
Ok(OrchestratorOutcome {
|
||||
detail,
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
@ -755,8 +757,8 @@ impl OrchestratorService {
|
||||
.await?;
|
||||
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
|
||||
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
|
||||
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit);
|
||||
|
||||
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
|
||||
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
|
||||
@ -866,8 +868,8 @@ impl OrchestratorService {
|
||||
let handle = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, target)
|
||||
.await?;
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern);
|
||||
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit);
|
||||
|
||||
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
|
||||
// forget: we drop the PendingReply (the human reads the terminal). The
|
||||
@ -922,6 +924,34 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only.
|
||||
///
|
||||
/// The frontend write-portal calls this (via the `delegation_delivered` Tauri
|
||||
/// command) once it has **physically written** a delegation `ticket` into the agent's
|
||||
/// native PTY, to distinguish "queued because the human line was busy" from "written"
|
||||
/// in logs/observability. It **does not** change correlation: the requester's `ask`
|
||||
/// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle
|
||||
/// guards are untouched. It is a pure log no-op when nothing is wired, so a missing
|
||||
/// mediator/registry never breaks the rendezvous.
|
||||
pub fn note_delegation_delivered(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
ticket: TicketId,
|
||||
) {
|
||||
// Observability beacon only: never mutates the mailbox/busy state nor resolves
|
||||
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
|
||||
// a pure, infallible hook so a missing mediator/registry never breaks the
|
||||
// rendezvous. The application crate carries no logging framework (zero new dep);
|
||||
// the ack is materialised as an `eprintln!` trace, the same lightweight channel
|
||||
// the orchestrator already uses for best-effort diagnostics.
|
||||
let _ = project;
|
||||
eprintln!(
|
||||
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
|
||||
(front ack, ticket {ticket})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
|
||||
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
|
||||
/// stable per-agent id derived from the target (legacy routing — never panics).
|
||||
@ -944,7 +974,6 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
|
||||
/// it is currently processing (Option 1, lot B-4).
|
||||
///
|
||||
@ -1039,19 +1068,18 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = self
|
||||
.sessions
|
||||
.session_for_agent(&agent_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||
))
|
||||
})?;
|
||||
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||
))
|
||||
})?;
|
||||
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
|
||||
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
|
||||
self.bind_conversation_session(conversation_id, session_id);
|
||||
self.sessions.handle(&session_id).ok_or_else(|| {
|
||||
AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement"))
|
||||
AppError::Process(format!(
|
||||
"handle PTY de l'agent {target} introuvable après lancement"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -1318,30 +1346,41 @@ impl OrchestratorService {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
|
||||
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
|
||||
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
|
||||
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
|
||||
async fn prompt_pattern_for_agent(
|
||||
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
|
||||
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
|
||||
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
|
||||
/// pattern arms prompt detection, the submit config is echoed on the next
|
||||
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
|
||||
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
|
||||
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
|
||||
/// front applies its own `"\r"`/~60 ms default).
|
||||
async fn prompt_and_submit_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Option<String> {
|
||||
let agent = self
|
||||
) -> (Option<String>, SubmitConfig) {
|
||||
let Some(agent) = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.agents
|
||||
.into_iter()
|
||||
.find(|a| a.id == agent_id)?;
|
||||
let profiles = self.profiles.list().await.ok()?;
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.and_then(|p| p.prompt_ready_pattern)
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default());
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default());
|
||||
};
|
||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
||||
(profile.prompt_ready_pattern, submit)
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||
|
||||
@ -90,7 +90,12 @@ impl TerminalSessions {
|
||||
/// per-agent lookup for the orchestrator's ask path.
|
||||
#[must_use]
|
||||
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
|
||||
let sid = self.conversations.lock().ok()?.get(&conversation).copied()?;
|
||||
let sid = self
|
||||
.conversations
|
||||
.lock()
|
||||
.ok()?
|
||||
.get(&conversation)
|
||||
.copied()?;
|
||||
// Only return it if the session is still live in `entries`.
|
||||
self.entries
|
||||
.lock()
|
||||
|
||||
Reference in New Issue
Block a user