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>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), ErrorDto> {
|
) -> Result<(), ErrorDto> {
|
||||||
let input = request.into_input()?;
|
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.
|
/// `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 project = resolve_project(&request.project_id, &state).await?;
|
||||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
let ticket = parse_ticket_id(&request.ticket)?;
|
let ticket = parse_ticket_id(&request.ticket)?;
|
||||||
|
application::diag!(
|
||||||
|
"[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}",
|
||||||
|
project.id
|
||||||
|
);
|
||||||
state
|
state
|
||||||
.orchestrator_service
|
.orchestrator_service
|
||||||
.note_delegation_delivered(&project, agent_id, ticket);
|
.note_delegation_delivered(&project, agent_id, ticket);
|
||||||
@ -1502,6 +1545,10 @@ pub async fn set_front_attached(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), ErrorDto> {
|
) -> Result<(), ErrorDto> {
|
||||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
|
application::diag!(
|
||||||
|
"[delivery] set_front_attached command: agent={agent_id} attached={}",
|
||||||
|
request.attached
|
||||||
|
);
|
||||||
state
|
state
|
||||||
.orchestrator_service
|
.orchestrator_service
|
||||||
.set_agent_front_attached(agent_id, request.attached);
|
.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
|
// Observability beacon only: never mutates the mailbox/busy state nor resolves
|
||||||
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
|
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
|
||||||
// a pure, infallible hook so a missing mediator/registry never breaks the
|
// a pure, infallible hook so a missing mediator/registry never breaks the
|
||||||
// rendezvous. The application crate carries no logging framework (zero new dep);
|
// rendezvous.
|
||||||
// the ack is materialised as an `eprintln!` trace, the same lightweight channel
|
|
||||||
// the orchestrator already uses for best-effort diagnostics.
|
|
||||||
let _ = project;
|
let _ = project;
|
||||||
eprintln!(
|
crate::diag!(
|
||||||
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
|
"[delivery] front ack received: agent={agent_id} ticket={ticket} \
|
||||||
(front ack, 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
|
/// 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.
|
/// 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) {
|
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 {
|
if let Some(input) = &self.input {
|
||||||
input.set_front_attached(agent, attached);
|
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
|
/// 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
|
/// 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 = 350;
|
||||||
/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless.
|
/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless.
|
||||||
const DELEGATION_WRITE_CHUNK_BYTES: usize = 512;
|
const DELEGATION_WRITE_CHUNK_BYTES: usize = 512;
|
||||||
/// Pause entre deux fragments de consigne en mode headless.
|
/// 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,
|
/// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud,
|
||||||
/// zéro régression).
|
/// zéro régression).
|
||||||
fn mark_starting(&self, agent: AgentId) {
|
fn mark_starting(&self, agent: AgentId) {
|
||||||
|
eprintln!("[input-mediator] cold-start gate armed agent={agent}");
|
||||||
self.lock_starting().insert(agent);
|
self.lock_starting().insert(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,8 +264,10 @@ impl BusyTracker {
|
|||||||
let mut busy = self.lock();
|
let mut busy = self.lock();
|
||||||
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
|
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
|
||||||
if entry.is_busy() {
|
if entry.is_busy() {
|
||||||
|
eprintln!("[input-mediator] enqueue queued behind busy agent={agent}");
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!("[input-mediator] turn started agent={agent} state={state:?}");
|
||||||
*entry = state;
|
*entry = state;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -281,11 +284,21 @@ impl BusyTracker {
|
|||||||
let d = match &self.headless_sink {
|
let d = match &self.headless_sink {
|
||||||
Some(sink) => match sink(agent, d) {
|
Some(sink) => match sink(agent, d) {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return,
|
None => {
|
||||||
|
eprintln!("[input-mediator] delegation handled by headless sink agent={agent}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => d,
|
None => d,
|
||||||
};
|
};
|
||||||
if let Some(events) = &self.events {
|
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 {
|
events.publish(DomainEvent::DelegationReady {
|
||||||
agent_id: agent,
|
agent_id: agent,
|
||||||
ticket: d.ticket,
|
ticket: d.ticket,
|
||||||
@ -309,7 +322,13 @@ impl BusyTracker {
|
|||||||
self.lock_starting().remove(&agent);
|
self.lock_starting().remove(&agent);
|
||||||
let deferred = self.lock_deferred().remove(&agent);
|
let deferred = self.lock_deferred().remove(&agent);
|
||||||
match deferred {
|
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),
|
None => self.mark_idle(agent),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -322,6 +341,10 @@ impl BusyTracker {
|
|||||||
fn release_cold_start(&self, agent: AgentId) {
|
fn release_cold_start(&self, agent: AgentId) {
|
||||||
self.lock_starting().remove(&agent);
|
self.lock_starting().remove(&agent);
|
||||||
if let Some(d) = self.lock_deferred().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);
|
self.publish_deferred(agent, d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -336,6 +359,7 @@ impl BusyTracker {
|
|||||||
.is_some_and(|s| s.is_busy())
|
.is_some_and(|s| s.is_busy())
|
||||||
};
|
};
|
||||||
if was_busy {
|
if was_busy {
|
||||||
|
eprintln!("[input-mediator] turn marked idle agent={agent}");
|
||||||
if let Some(events) = &self.events {
|
if let Some(events) = &self.events {
|
||||||
events.publish(DomainEvent::AgentBusyChanged {
|
events.publish(DomainEvent::AgentBusyChanged {
|
||||||
agent_id: agent,
|
agent_id: agent,
|
||||||
@ -470,6 +494,10 @@ impl MediatedInbox {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
.contains(&agent)
|
.contains(&agent)
|
||||||
{
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[input-mediator] front-owned delivery selected agent={agent} ticket={}",
|
||||||
|
d.ticket
|
||||||
|
);
|
||||||
return Some(d);
|
return Some(d);
|
||||||
}
|
}
|
||||||
// Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement
|
// Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement
|
||||||
@ -480,11 +508,19 @@ impl MediatedInbox {
|
|||||||
.get(&agent)
|
.get(&agent)
|
||||||
.cloned();
|
.cloned();
|
||||||
let Some(handle) = handle else {
|
let Some(handle) = handle else {
|
||||||
|
eprintln!(
|
||||||
|
"[input-mediator] no PTY handle for headless delivery agent={agent} ticket={}",
|
||||||
|
d.ticket
|
||||||
|
);
|
||||||
return Some(d);
|
return Some(d);
|
||||||
};
|
};
|
||||||
let pty = Arc::clone(&pty);
|
let pty = Arc::clone(&pty);
|
||||||
let text = d.text;
|
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));
|
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
|
// É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).
|
// 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
|
// 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.
|
// gros write unique comme un paste et garder/perdre la fin au démarrage.
|
||||||
std::thread::spawn(move || {
|
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 {
|
if delay > 0 {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
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
|
None
|
||||||
})
|
})
|
||||||
@ -692,6 +760,24 @@ fn write_delegation_chunks(
|
|||||||
pty.write(handle, text[start..].as_bytes())
|
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 {
|
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;
|
||||||
@ -761,6 +847,10 @@ impl InputMediator for MediatedInbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
|
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
|
||||||
|
eprintln!(
|
||||||
|
"[input-mediator] bind handle agent={agent} handle={}",
|
||||||
|
handle.session_id
|
||||||
|
);
|
||||||
self.handles().insert(agent, handle);
|
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
|
// 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
|
// (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).
|
// 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.handles().insert(agent, handle.clone());
|
||||||
self.submit().insert(agent, submit);
|
self.submit().insert(agent, submit);
|
||||||
if let Some(pattern) = prompt_ready_pattern {
|
if let Some(pattern) = prompt_ready_pattern {
|
||||||
@ -805,8 +902,10 @@ impl InputMediator for MediatedInbox {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
if attached {
|
if attached {
|
||||||
|
eprintln!("[input-mediator] front attached agent={agent}");
|
||||||
front.insert(agent);
|
front.insert(agent);
|
||||||
} else {
|
} else {
|
||||||
|
eprintln!("[input-mediator] front detached agent={agent}");
|
||||||
front.remove(&agent);
|
front.remove(&agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,15 +28,27 @@ export class TauriInputGateway implements InputGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
ticket: string,
|
ticket: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
console.info("[input-gateway] delegationDelivered:start", {
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
ticket,
|
||||||
|
});
|
||||||
await invoke("delegation_delivered", {
|
await invoke("delegation_delivered", {
|
||||||
request: { projectId, agentId, ticket },
|
request: { projectId, agentId, ticket },
|
||||||
});
|
});
|
||||||
|
console.info("[input-gateway] delegationDelivered:ok", {
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
ticket,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||||
|
console.info("[input-gateway] setFrontAttached:start", { agentId, attached });
|
||||||
await invoke("set_front_attached", {
|
await invoke("set_front_attached", {
|
||||||
request: { agentId, attached },
|
request: { agentId, attached },
|
||||||
});
|
});
|
||||||
|
console.info("[input-gateway] setFrontAttached:ok", { agentId, attached });
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelResume(agentId: string): Promise<boolean> {
|
async cancelResume(agentId: string): Promise<boolean> {
|
||||||
|
|||||||
@ -64,11 +64,13 @@ export function makeTerminalHandle(
|
|||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
write(data: Uint8Array): Promise<void> {
|
write(data: Uint8Array): Promise<void> {
|
||||||
const run = chain.then(() =>
|
const run = chain.then(async () => {
|
||||||
invoke<void>("write_terminal", {
|
logTerminalWrite("start", sessionId, data);
|
||||||
|
await invoke<void>("write_terminal", {
|
||||||
request: { sessionId, data: Array.from(data) },
|
request: { sessionId, data: Array.from(data) },
|
||||||
}),
|
});
|
||||||
);
|
logTerminalWrite("ok", sessionId, data);
|
||||||
|
});
|
||||||
// Keep the chain alive even if this write rejects: the next write must
|
// Keep the chain alive even if this write rejects: the next write must
|
||||||
// still run. Swallow the error on the *chain* copy only — `run` keeps the
|
// still run. Swallow the error on the *chain* copy only — `run` keeps the
|
||||||
// rejection so the caller can observe it.
|
// rejection so the caller can observe it.
|
||||||
@ -91,6 +93,35 @@ export function makeTerminalHandle(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logTerminalWrite(phase: "start" | "ok", sessionId: string, data: Uint8Array): void {
|
||||||
|
if (data.length === 1 && data[0] >= 0x20 && data[0] !== 0x7f) return;
|
||||||
|
console.info("[terminal-write]", phase, {
|
||||||
|
sessionId,
|
||||||
|
bytes: data.length,
|
||||||
|
control: describeBytes(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeBytes(data: Uint8Array): string {
|
||||||
|
if (data.length === 0) return "<empty>";
|
||||||
|
if (data.length > 16) return "<bulk>";
|
||||||
|
return Array.from(data)
|
||||||
|
.map((byte) => {
|
||||||
|
switch (byte) {
|
||||||
|
case 0x0d:
|
||||||
|
return "\\r";
|
||||||
|
case 0x0a:
|
||||||
|
return "\\n";
|
||||||
|
case 0x7f:
|
||||||
|
return "\\x7f";
|
||||||
|
default:
|
||||||
|
if (byte < 0x20) return `\\x${byte.toString(16).padStart(2, "0")}`;
|
||||||
|
return String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
export class TauriTerminalGateway implements TerminalGateway {
|
export class TauriTerminalGateway implements TerminalGateway {
|
||||||
async openTerminal(
|
async openTerminal(
|
||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export type DomainEvent =
|
|||||||
* PTY-writes the turn: the frontend write-portal runs the handshake (b→e)
|
* PTY-writes the turn: the frontend write-portal runs the handshake (b→e)
|
||||||
* and writes `text` + `submitSequence`. `submitSequence`/`submitDelayMs`
|
* and writes `text` + `submitSequence`. `submitSequence`/`submitDelayMs`
|
||||||
* come from the target's profile; absent ⇒ the portal applies its defaults
|
* come from the target's profile; absent ⇒ the portal applies its defaults
|
||||||
* (`"\r"`, ~60 ms).
|
* (`"\r"`, ~350 ms).
|
||||||
*/
|
*/
|
||||||
type: "delegationReady";
|
type: "delegationReady";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|||||||
@ -235,6 +235,26 @@ describe("useWritePortal (§20)", () => {
|
|||||||
expect(writes).toEqual(["msg", "\n"]);
|
expect(writes).toEqual(["msg", "\n"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults an empty profile submitSequence to Enter", async () => {
|
||||||
|
const { system, view } = setup();
|
||||||
|
const { handle, writes } = makeHandle();
|
||||||
|
act(() => view.result.current.portal.bindHandle(handle));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
system.emit({
|
||||||
|
type: "delegationReady",
|
||||||
|
agentId: "ag1",
|
||||||
|
ticket: "t1",
|
||||||
|
text: "msg",
|
||||||
|
submitSequence: "",
|
||||||
|
submitDelayMs: 0,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writes).toEqual(["msg", "\r"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores delegationReady for a different agent", async () => {
|
it("ignores delegationReady for a different agent", async () => {
|
||||||
const { system, view } = setup();
|
const { system, view } = setup();
|
||||||
const { handle, writes } = makeHandle();
|
const { handle, writes } = makeHandle();
|
||||||
|
|||||||
@ -58,6 +58,8 @@ const DELEGATION_WRITE_CHUNK_BYTES = 512;
|
|||||||
/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */
|
/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */
|
||||||
const DELEGATION_WRITE_CHUNK_DELAY_MS = 8;
|
const DELEGATION_WRITE_CHUNK_DELAY_MS = 8;
|
||||||
|
|
||||||
|
const LOG_PREFIX = "[write-portal]";
|
||||||
|
|
||||||
/** A pending delegation kept in the local FIFO. */
|
/** A pending delegation kept in the local FIFO. */
|
||||||
interface PendingDelegation {
|
interface PendingDelegation {
|
||||||
ticket: string;
|
ticket: string;
|
||||||
@ -135,10 +137,24 @@ export function useWritePortal(
|
|||||||
if (previous === desired) return;
|
if (previous === desired) return;
|
||||||
|
|
||||||
if (previous) {
|
if (previous) {
|
||||||
void inputRef.current?.setFrontAttached(previous, false).catch(() => {});
|
portalLog("front-attached:set", { agent: previous, attached: false });
|
||||||
|
void inputRef.current?.setFrontAttached(previous, false).catch((error) => {
|
||||||
|
portalLog("front-attached:set-failed", {
|
||||||
|
agent: previous,
|
||||||
|
attached: false,
|
||||||
|
error: describeError(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (desired) {
|
if (desired) {
|
||||||
void inputRef.current?.setFrontAttached(desired, true).catch(() => {});
|
portalLog("front-attached:set", { agent: desired, attached: true });
|
||||||
|
void inputRef.current?.setFrontAttached(desired, true).catch((error) => {
|
||||||
|
portalLog("front-attached:set-failed", {
|
||||||
|
agent: desired,
|
||||||
|
attached: true,
|
||||||
|
error: describeError(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
frontAttachedAgentRef.current = desired;
|
frontAttachedAgentRef.current = desired;
|
||||||
};
|
};
|
||||||
@ -164,7 +180,16 @@ export function useWritePortal(
|
|||||||
setOverlay(true);
|
setOverlay(true);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
let drainNext = false;
|
||||||
try {
|
try {
|
||||||
|
portalLog("inject:start", {
|
||||||
|
agent,
|
||||||
|
ticket: head.ticket,
|
||||||
|
textBytes: encoder.encode(head.text).byteLength,
|
||||||
|
queue: queueRef.current.length,
|
||||||
|
submitSequence: describeControl(head.submitSequence),
|
||||||
|
submitDelayMs: head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS,
|
||||||
|
});
|
||||||
// Yield once so any keystroke that landed in the micro-window between
|
// Yield once so any keystroke that landed in the micro-window between
|
||||||
// "empty observed" (a) and the suspension taking hold (b) is counted
|
// "empty observed" (a) and the suspension taking hold (b) is counted
|
||||||
// before we re-check K at step (c). Without this the race window would
|
// before we re-check K at step (c). Without this the race window would
|
||||||
@ -174,6 +199,7 @@ export function useWritePortal(
|
|||||||
// (backspace) — never Ctrl-U (which could clear more than the human typed).
|
// (backspace) — never Ctrl-U (which could clear more than the human typed).
|
||||||
const k = counterRef.current;
|
const k = counterRef.current;
|
||||||
if (k > 0) {
|
if (k > 0) {
|
||||||
|
portalLog("inject:erase-human-window", { agent, ticket: head.ticket, count: k });
|
||||||
await handle.write(encoder.encode("\x7f".repeat(k)));
|
await handle.write(encoder.encode("\x7f".repeat(k)));
|
||||||
counterRef.current = 0;
|
counterRef.current = 0;
|
||||||
}
|
}
|
||||||
@ -183,19 +209,37 @@ export function useWritePortal(
|
|||||||
// Long delegated prompts are chunked: Codex's TUI can otherwise treat a
|
// Long delegated prompts are chunked: Codex's TUI can otherwise treat a
|
||||||
// large single write like a paste and occasionally keep/drop the tail on
|
// large single write like a paste and occasionally keep/drop the tail on
|
||||||
// cold-started agents before Enter is received.
|
// cold-started agents before Enter is received.
|
||||||
await writeDelegationText(handle, head.text);
|
await writeDelegationText(handle, head.text, head.ticket);
|
||||||
const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
|
const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
|
||||||
|
portalLog("inject:before-submit-delay", { agent, ticket: head.ticket, delay });
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE;
|
const submit = normalizeSubmitSequence(head.submitSequence, head.ticket);
|
||||||
|
portalLog("inject:submit-write:start", {
|
||||||
|
agent,
|
||||||
|
ticket: head.ticket,
|
||||||
|
submitSequence: describeControl(submit),
|
||||||
|
submitBytes: encoder.encode(submit).byteLength,
|
||||||
|
});
|
||||||
await handle.write(encoder.encode(submit));
|
await handle.write(encoder.encode(submit));
|
||||||
|
portalLog("inject:submit-write:ok", { agent, ticket: head.ticket });
|
||||||
|
|
||||||
// Dequeue + ack exactly once (best-effort; never throws the handshake).
|
// Dequeue + ack exactly once (best-effort; never throws the handshake).
|
||||||
queueRef.current.shift();
|
queueRef.current.shift();
|
||||||
|
drainNext = true;
|
||||||
try {
|
try {
|
||||||
|
portalLog("inject:ack:start", { agent, ticket: head.ticket });
|
||||||
await inputRef.current.delegationDelivered(project, agent, head.ticket);
|
await inputRef.current.delegationDelivered(project, agent, head.ticket);
|
||||||
|
portalLog("inject:ack:ok", { agent, ticket: head.ticket });
|
||||||
} catch {
|
} catch {
|
||||||
|
portalLog("inject:ack:failed", { agent, ticket: head.ticket });
|
||||||
/* ack is observability-only; never block the portal */
|
/* ack is observability-only; never block the portal */
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
portalLog("inject:failed", {
|
||||||
|
agent,
|
||||||
|
ticket: head.ticket,
|
||||||
|
error: describeError(error),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
// (e) lower the overlay + resume the relay, with the 2 s anti-flash floor.
|
// (e) lower the overlay + resume the relay, with the 2 s anti-flash floor.
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
@ -205,7 +249,7 @@ export function useWritePortal(
|
|||||||
setOverlay(false);
|
setOverlay(false);
|
||||||
injectingRef.current = false;
|
injectingRef.current = false;
|
||||||
// A boundary may now be open for the next queued delegation.
|
// A boundary may now be open for the next queued delegation.
|
||||||
if (queueRef.current.length > 0) tryInject.current();
|
if (queueRef.current.length > 0 && drainNext) tryInject.current();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
@ -231,6 +275,14 @@ export function useWritePortal(
|
|||||||
submitSequence: event.submitSequence,
|
submitSequence: event.submitSequence,
|
||||||
submitDelayMs: event.submitDelayMs,
|
submitDelayMs: event.submitDelayMs,
|
||||||
});
|
});
|
||||||
|
portalLog("event:delegation-ready", {
|
||||||
|
agent: subscribedAgent,
|
||||||
|
ticket: event.ticket,
|
||||||
|
textBytes: encoder.encode(event.text).byteLength,
|
||||||
|
queue: queueRef.current.length,
|
||||||
|
submitSequence: describeControl(event.submitSequence),
|
||||||
|
submitDelayMs: event.submitDelayMs,
|
||||||
|
});
|
||||||
// A delegation may already be at a clean boundary (line empty) — try now.
|
// A delegation may already be at a clean boundary (line empty) — try now.
|
||||||
tryInject.current();
|
tryInject.current();
|
||||||
})
|
})
|
||||||
@ -239,6 +291,7 @@ export function useWritePortal(
|
|||||||
else {
|
else {
|
||||||
unsubscribe = un;
|
unsubscribe = un;
|
||||||
subscribedAgentRef.current = subscribedAgent;
|
subscribedAgentRef.current = subscribedAgent;
|
||||||
|
portalLog("event:subscribed", { agent: subscribedAgent });
|
||||||
reconcileFrontAttachment.current();
|
reconcileFrontAttachment.current();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -248,6 +301,7 @@ export function useWritePortal(
|
|||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
if (subscribedAgentRef.current === subscribedAgent) {
|
if (subscribedAgentRef.current === subscribedAgent) {
|
||||||
subscribedAgentRef.current = null;
|
subscribedAgentRef.current = null;
|
||||||
|
portalLog("event:unsubscribed", { agent: subscribedAgent });
|
||||||
}
|
}
|
||||||
if (frontAttachedAgentRef.current === subscribedAgent) {
|
if (frontAttachedAgentRef.current === subscribedAgent) {
|
||||||
void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {});
|
void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {});
|
||||||
@ -279,6 +333,7 @@ export function useWritePortal(
|
|||||||
},
|
},
|
||||||
bindHandle(handle: TerminalHandle) {
|
bindHandle(handle: TerminalHandle) {
|
||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
|
portalLog("handle:bound", { agent: agentIdRef.current, sessionId: handle.sessionId });
|
||||||
// Tell the backend a frontend cell is mounted only after the event
|
// Tell the backend a frontend cell is mounted only after the event
|
||||||
// subscription is ready. Until then, the backend keeps the headless PTY
|
// subscription is ready. Until then, the backend keeps the headless PTY
|
||||||
// write fallback, so a delegation cannot disappear during mount.
|
// write fallback, so a delegation cannot disappear during mount.
|
||||||
@ -287,6 +342,10 @@ export function useWritePortal(
|
|||||||
tryInject.current();
|
tryInject.current();
|
||||||
},
|
},
|
||||||
unbindHandle() {
|
unbindHandle() {
|
||||||
|
portalLog("handle:unbound", {
|
||||||
|
agent: agentIdRef.current,
|
||||||
|
sessionId: handleRef.current?.sessionId,
|
||||||
|
});
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
// The cell is gone — let the backend fall back to headless delivery so a
|
// The cell is gone — let the backend fall back to headless delivery so a
|
||||||
// delegation arriving while this agent has no live cell is not lost.
|
// delegation arriving while this agent has no live cell is not lost.
|
||||||
@ -307,16 +366,40 @@ function sleep(ms: number): Promise<void> {
|
|||||||
async function writeDelegationText(
|
async function writeDelegationText(
|
||||||
handle: TerminalHandle,
|
handle: TerminalHandle,
|
||||||
text: string,
|
text: string,
|
||||||
|
ticket: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES);
|
const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES);
|
||||||
for (let i = 0; i < chunks.length; i += 1) {
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
portalLog("inject:text-chunk:start", {
|
||||||
|
ticket,
|
||||||
|
sessionId: handle.sessionId,
|
||||||
|
chunk: i + 1,
|
||||||
|
chunks: chunks.length,
|
||||||
|
bytes: encoder.encode(chunks[i]).byteLength,
|
||||||
|
});
|
||||||
await handle.write(encoder.encode(chunks[i]));
|
await handle.write(encoder.encode(chunks[i]));
|
||||||
|
portalLog("inject:text-chunk:ok", {
|
||||||
|
ticket,
|
||||||
|
sessionId: handle.sessionId,
|
||||||
|
chunk: i + 1,
|
||||||
|
chunks: chunks.length,
|
||||||
|
});
|
||||||
if (i + 1 < chunks.length) {
|
if (i + 1 < chunks.length) {
|
||||||
await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS);
|
await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSubmitSequence(sequence: string | undefined, ticket: string): string {
|
||||||
|
if (sequence == null) return DEFAULT_SUBMIT_SEQUENCE;
|
||||||
|
if (sequence.length > 0) return sequence;
|
||||||
|
portalLog("inject:empty-submit-sequence-defaulted", {
|
||||||
|
ticket,
|
||||||
|
defaultSubmitSequence: describeControl(DEFAULT_SUBMIT_SEQUENCE),
|
||||||
|
});
|
||||||
|
return DEFAULT_SUBMIT_SEQUENCE;
|
||||||
|
}
|
||||||
|
|
||||||
function splitUtf8Chunks(text: string, maxBytes: number): string[] {
|
function splitUtf8Chunks(text: string, maxBytes: number): string[] {
|
||||||
if (text.length === 0) return [""];
|
if (text.length === 0) return [""];
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
@ -337,3 +420,36 @@ function splitUtf8Chunks(text: string, maxBytes: number): string[] {
|
|||||||
if (current.length > 0) chunks.push(current);
|
if (current.length > 0) chunks.push(current);
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeControl(value: string | undefined): string {
|
||||||
|
if (value == null) return "<default>";
|
||||||
|
if (value.length === 0) return "<empty>";
|
||||||
|
return [...value]
|
||||||
|
.map((ch) => {
|
||||||
|
switch (ch) {
|
||||||
|
case "\r":
|
||||||
|
return "\\r";
|
||||||
|
case "\n":
|
||||||
|
return "\\n";
|
||||||
|
case "\x7f":
|
||||||
|
return "\\x7f";
|
||||||
|
default: {
|
||||||
|
const code = ch.codePointAt(0)!;
|
||||||
|
if (code < 0x20) return `\\x${code.toString(16).padStart(2, "0")}`;
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
if (error && typeof error === "object" && "message" in error) {
|
||||||
|
return String((error as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function portalLog(event: string, fields: Record<string, unknown>): void {
|
||||||
|
console.info(LOG_PREFIX, event, fields);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user