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:
@ -364,7 +364,46 @@ pub fn write_terminal(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let input = request.into_input()?;
|
||||
state.write_terminal.execute(input).map_err(ErrorDto::from)
|
||||
let session_id = input.session_id;
|
||||
let bytes = input.data.len();
|
||||
let control = describe_terminal_write(&input.data);
|
||||
application::diag!(
|
||||
"[pty-write] write_terminal start: session={session_id} bytes={bytes} control={control}"
|
||||
);
|
||||
match state.write_terminal.execute(input) {
|
||||
Ok(()) => {
|
||||
application::diag!(
|
||||
"[pty-write] write_terminal ok: session={session_id} bytes={bytes} control={control}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
application::diag!(
|
||||
"[pty-write] write_terminal failed: session={session_id} bytes={bytes} \
|
||||
control={control} error={e}"
|
||||
);
|
||||
Err(ErrorDto::from(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_terminal_write(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return "<empty>".to_owned();
|
||||
}
|
||||
if data.len() > 16 {
|
||||
return "<bulk>".to_owned();
|
||||
}
|
||||
data.iter()
|
||||
.map(|byte| match *byte {
|
||||
b'\r' => "\\r".to_owned(),
|
||||
b'\n' => "\\n".to_owned(),
|
||||
0x7f => "\\x7f".to_owned(),
|
||||
byte if byte < 0x20 => format!("\\x{byte:02x}"),
|
||||
byte => char::from(byte).to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// `resize_terminal` — resize a live PTY.
|
||||
@ -1479,6 +1518,10 @@ pub async fn delegation_delivered(
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
let ticket = parse_ticket_id(&request.ticket)?;
|
||||
application::diag!(
|
||||
"[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}",
|
||||
project.id
|
||||
);
|
||||
state
|
||||
.orchestrator_service
|
||||
.note_delegation_delivered(&project, agent_id, ticket);
|
||||
@ -1502,6 +1545,10 @@ pub async fn set_front_attached(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
application::diag!(
|
||||
"[delivery] set_front_attached command: agent={agent_id} attached={}",
|
||||
request.attached
|
||||
);
|
||||
state
|
||||
.orchestrator_service
|
||||
.set_agent_front_attached(agent_id, request.attached);
|
||||
|
||||
@ -1288,13 +1288,11 @@ impl OrchestratorService {
|
||||
// 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.
|
||||
// rendezvous.
|
||||
let _ = project;
|
||||
eprintln!(
|
||||
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
|
||||
(front ack, ticket {ticket})"
|
||||
crate::diag!(
|
||||
"[delivery] front ack received: agent={agent_id} ticket={ticket} \
|
||||
(write-portal reports text+submit writes completed)"
|
||||
);
|
||||
}
|
||||
|
||||
@ -1313,8 +1311,14 @@ impl OrchestratorService {
|
||||
/// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire
|
||||
/// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur.
|
||||
pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) {
|
||||
crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}");
|
||||
if let Some(input) = &self.input {
|
||||
input.set_front_attached(agent, attached);
|
||||
} else {
|
||||
crate::diag!(
|
||||
"[delivery] front attachment ignored because input mediator is not wired: \
|
||||
agent={agent} attached={attached}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user