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

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

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

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

View File

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