fix(input): fiabilise la livraison de délégation et journalise le submit

Durcit le portail d'écriture de délégation et son acheminement bout-en-bout
(commande Tauri → orchestrateur → file d'entrée infrastructure → portail
frontend), avec une journalisation du submit pour diagnostiquer les cas où la
délégation n'était pas remise. Couvre le portail d'écriture côté front
(useWritePortal) avec ses tests.

QA : checks application/front/infrastructure/app-tauri verts. Les tests loopback
socket Unix réels ne sont pas exécutables en sandbox (UnixListener::bind →
PermissionDenied) ; alternatives avec skips vertes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:57:20 +02:00
parent befff76de8
commit 018eb1a25e
8 changed files with 352 additions and 23 deletions

View File

@ -93,7 +93,7 @@ type HeadlessSink =
/// Aligné sur le défaut du write-portal frontend (`DEFAULT_SUBMIT_DELAY_MS`) : sépare
/// 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;
const DEFAULT_SUBMIT_DELAY_MS: u32 = 350;
/// 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.
@ -147,6 +147,7 @@ impl BusyTracker {
/// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud,
/// zéro régression).
fn mark_starting(&self, agent: AgentId) {
eprintln!("[input-mediator] cold-start gate armed agent={agent}");
self.lock_starting().insert(agent);
}
@ -263,8 +264,10 @@ impl BusyTracker {
let mut busy = self.lock();
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
if entry.is_busy() {
eprintln!("[input-mediator] enqueue queued behind busy agent={agent}");
false
} else {
eprintln!("[input-mediator] turn started agent={agent} state={state:?}");
*entry = state;
true
}
@ -281,11 +284,21 @@ impl BusyTracker {
let d = match &self.headless_sink {
Some(sink) => match sink(agent, d) {
Some(d) => d,
None => return,
None => {
eprintln!("[input-mediator] delegation handled by headless sink agent={agent}");
return;
}
},
None => d,
};
if let Some(events) = &self.events {
eprintln!(
"[input-mediator] publishing DelegationReady agent={agent} ticket={} text_bytes={} submit_sequence={} submit_delay_ms={:?}",
d.ticket,
d.text.as_bytes().len(),
describe_submit_sequence(d.submit_sequence.as_deref()),
d.submit_delay_ms,
);
events.publish(DomainEvent::DelegationReady {
agent_id: agent,
ticket: d.ticket,
@ -309,7 +322,13 @@ impl BusyTracker {
self.lock_starting().remove(&agent);
let deferred = self.lock_deferred().remove(&agent);
match deferred {
Some(d) => self.publish_deferred(agent, d),
Some(d) => {
eprintln!(
"[input-mediator] prompt-ready released deferred delegation agent={agent} ticket={}",
d.ticket
);
self.publish_deferred(agent, d);
}
None => self.mark_idle(agent),
}
}
@ -322,6 +341,10 @@ impl BusyTracker {
fn release_cold_start(&self, agent: AgentId) {
self.lock_starting().remove(&agent);
if let Some(d) = self.lock_deferred().remove(&agent) {
eprintln!(
"[input-mediator] mcp-ready released deferred delegation agent={agent} ticket={}",
d.ticket
);
self.publish_deferred(agent, d);
}
}
@ -336,6 +359,7 @@ impl BusyTracker {
.is_some_and(|s| s.is_busy())
};
if was_busy {
eprintln!("[input-mediator] turn marked idle agent={agent}");
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
@ -470,6 +494,10 @@ impl MediatedInbox {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(&agent)
{
eprintln!(
"[input-mediator] front-owned delivery selected agent={agent} ticket={}",
d.ticket
);
return Some(d);
}
// Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement
@ -480,11 +508,19 @@ impl MediatedInbox {
.get(&agent)
.cloned();
let Some(handle) = handle else {
eprintln!(
"[input-mediator] no PTY handle for headless delivery agent={agent} ticket={}",
d.ticket
);
return Some(d);
};
let pty = Arc::clone(&pty);
let text = d.text;
let submit = d.submit_sequence.unwrap_or_else(|| "\r".to_owned());
let ticket = d.ticket;
let submit = d
.submit_sequence
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "\r".to_owned());
let delay = u64::from(d.submit_delay_ms.unwrap_or(DEFAULT_SUBMIT_DELAY_MS));
// Écriture sur un thread détaché : ne JAMAIS bloquer l'appelant (thread du
// watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue).
@ -493,11 +529,43 @@ impl MediatedInbox {
// 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 _ = write_delegation_chunks(pty.as_ref(), &handle, &text);
eprintln!(
"[input-mediator] headless write text start agent={agent} ticket={ticket} handle={} text_bytes={} submit_sequence={} submit_delay_ms={delay}",
handle.session_id,
text.as_bytes().len(),
describe_submit_sequence(Some(&submit)),
);
match write_delegation_chunks(pty.as_ref(), &handle, &text) {
Ok(()) => eprintln!(
"[input-mediator] headless write text ok agent={agent} ticket={ticket} handle={}",
handle.session_id
),
Err(e) => {
eprintln!(
"[input-mediator] headless write text failed agent={agent} ticket={ticket} handle={} error={e}",
handle.session_id
);
return;
}
}
if delay > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay));
}
let _ = pty.write(&handle, submit.as_bytes());
eprintln!(
"[input-mediator] headless submit start agent={agent} ticket={ticket} handle={} submit_sequence={}",
handle.session_id,
describe_submit_sequence(Some(&submit)),
);
match pty.write(&handle, submit.as_bytes()) {
Ok(()) => eprintln!(
"[input-mediator] headless submit ok agent={agent} ticket={ticket} handle={}",
handle.session_id
),
Err(e) => eprintln!(
"[input-mediator] headless submit failed agent={agent} ticket={ticket} handle={} error={e}",
handle.session_id
),
}
});
None
})
@ -692,6 +760,24 @@ fn write_delegation_chunks(
pty.write(handle, text[start..].as_bytes())
}
fn describe_submit_sequence(value: Option<&str>) -> String {
match value {
None => "<default>".to_owned(),
Some("") => "<empty>".to_owned(),
Some(value) => value
.chars()
.map(|ch| match ch {
'\r' => "\\r".to_owned(),
'\n' => "\\n".to_owned(),
'\u{7f}' => "\\x7f".to_owned(),
ch if ch.is_control() => format!("\\x{:02x}", ch as u32),
ch => ch.to_string(),
})
.collect::<Vec<_>>()
.join(""),
}
}
impl InputMediator for MediatedInbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
@ -761,6 +847,10 @@ impl InputMediator for MediatedInbox {
}
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
eprintln!(
"[input-mediator] bind handle agent={agent} handle={}",
handle.session_id
);
self.handles().insert(agent, handle);
}
@ -776,6 +866,13 @@ impl InputMediator for MediatedInbox {
// then arm the prompt-ready watcher when the profile declares a literal marker
// (C5). A `None`/empty pattern arms nothing: Idle then comes only from the
// explicit signal or the per-turn timeout (safe fallback, never a false Idle).
eprintln!(
"[input-mediator] bind handle with prompt agent={agent} handle={} prompt_ready={} submit_sequence={} submit_delay_ms={:?}",
handle.session_id,
prompt_ready_pattern.as_deref().unwrap_or("<none>"),
describe_submit_sequence(submit.sequence.as_deref()),
submit.delay_ms,
);
self.handles().insert(agent, handle.clone());
self.submit().insert(agent, submit);
if let Some(pattern) = prompt_ready_pattern {
@ -805,8 +902,10 @@ impl InputMediator for MediatedInbox {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if attached {
eprintln!("[input-mediator] front attached agent={agent}");
front.insert(agent);
} else {
eprintln!("[input-mediator] front detached agent={agent}");
front.remove(&agent);
}
}