feat(TASK-049): classify idle-prompt ctrl-c + ctrl-d as a completed exit

Ctrl-C at the reedline prompt no longer latches the abort signal
unconditionally: latch_prompt_interrupt only marks an interruption when
the supervisor had live agents/jobs to cancel (new
Supervisor::has_active_tasks), so the advertised exit path (ctrl-c at an
idle prompt, then ctrl-d) fires agent.completed at teardown instead of
agent.interrupted. Mid-turn ctrl-c still latches directly via
wait_user_interrupt/poll_abort_signal, preserving the
interrupted-then-exit classification.

Also: probe-lane tests for escalation.answered single-fire,
escalation question env truncation, agent.failed inverse gate, and
hooks probe Scenario I (session.started vs session.resumed); comment
notes on escalation.answered semantics and the agent__cancel blind
spot.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 19:55:52 -06:00
1 parent 739c1be8bb
commit a3b71a6889
7 files changed
+369 -4

No files matched your search

+78 -1
View File
@@ -1782,7 +1782,11 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result<Val
let _ = request.reply_tx.send(reply.to_string());
// Origin envs come from the taken request, not this (replying)
// context, so `escalation.answered` and `escalation.raised`
// describe the same agent for one escalation id.
// describe the same agent for one escalation id. "Answered"
// means a parent submitted an answer, not that the asker
// received it: the reply_tx.send failure is ignored by design,
// so the event also fires for an escalation whose asker already
// timed out or was cancelled.
hooks::fire(
HookEvent::EscalationAnswered,
ctx,
@@ -3399,6 +3403,79 @@ mod tests {
assert!(result.is_err());
}
/// `escalation.answered` belongs to the successful take -> reply arm
/// alone: exactly once per escalation, and never from the failure arms
/// (unknown id, already-taken id).
#[test]
#[serial]
fn reply_escalation_fires_answered_once_and_never_on_failure_arms() {
let _sink = hooks::test_sink::install();
let marker = "t049_answered_once";
let mut hooks_map = crate::hooks::HooksMap::default();
hooks_map.insert(
"escalation.answered".to_string(),
vec![crate::hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
);
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks: hooks_map,
..Default::default()
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
let queue = Arc::new(EscalationQueue::new());
let (tx, rx) = tokio::sync::oneshot::channel();
queue.submit(EscalationRequest {
id: "esc_once".into(),
from_agent_id: "a1".into(),
from_agent_name: "explore".into(),
question: "[input] What do?".into(),
options: None,
reply_tx: tx,
});
ctx.escalation_queue = Some(queue);
// Unknown id: an error result, and no event.
let miss = handle_reply_escalation(
&mut ctx,
&json!({"escalation_id": "missing", "reply": "nope"}),
)
.unwrap();
assert_eq!(miss["status"], "error");
assert!(
hook_captures_named(marker).is_empty(),
"a failed reply must not fire escalation.answered"
);
// The successful take -> reply arm fires it exactly once.
let ok = handle_reply_escalation(
&mut ctx,
&json!({"escalation_id": "esc_once", "reply": "do X"}),
)
.unwrap();
assert_eq!(ok["status"], "ok");
assert_eq!(rx.blocking_recv().unwrap(), "do X");
let answered = hook_captures_named(marker);
assert_eq!(answered.len(), 1, "{answered:?}");
assert_eq!(answered[0].envs["COYOTE_ESCALATION_ID"], "esc_once");
assert_eq!(answered[0].envs["COYOTE_ESCALATION_REPLY"], "do X");
// Replying again to the same id hits the already-taken arm: still one.
let again = handle_reply_escalation(
&mut ctx,
&json!({"escalation_id": "esc_once", "reply": "again"}),
)
.unwrap();
assert_eq!(again["status"], "error");
assert_eq!(
hook_captures_named(marker).len(),
1,
"a second reply must not re-fire escalation.answered"
);
}
#[test]
fn handle_task_create_simple() {
let mut ctx = ctx_with_supervisor(4, 3);
+48
View File
@@ -504,6 +504,54 @@ mod tests {
assert!(!raised[0].envs.contains_key("COYOTE_ESCALATION_REPLY"));
}
/// `COYOTE_ESCALATION_QUESTION` rides through the standard env
/// truncation: a huge question is trimmed to 2048 bytes on the raised
/// seam, never passed through whole.
#[tokio::test]
#[serial]
async fn escalation_question_env_is_truncated_to_2048_bytes() {
let _sink = test_sink::install();
let marker = "esc-trunc-p9j";
let queue = Arc::new(EscalationQueue::new());
let mut child_ctx = ctx_with_escalation_hooks(marker);
child_ctx.escalation_queue = Some(Arc::clone(&queue));
child_ctx.self_agent_id = Some("esc-child-trunc".to_string());
let raise_args = json!({ "question": "q".repeat(3000) });
let raise = handle_escalated(&child_ctx, "input", &raise_args);
let reply = async {
// Bounded poll: on the current-thread runtime the raise future
// submits before its first await, so this resolves immediately.
let mut request = None;
for _ in 0..1000 {
if let Some(entry) = queue.pending_summary().first() {
let id = entry["escalation_id"].as_str().unwrap().to_string();
request = queue.take(&id);
break;
}
tokio::task::yield_now().await;
}
let request = request.expect("escalation must reach the queue");
let _ = request.reply_tx.send("ok".to_string());
};
let (raised_result, ()) = tokio::join!(raise, reply);
assert_eq!(raised_result.unwrap()["answer"], "ok");
let raised = marker_captures(marker, "escalation.raised");
assert_eq!(raised.len(), 1, "{raised:?}");
let question = raised[0]
.envs
.get("COYOTE_ESCALATION_QUESTION")
.expect("QUESTION env must be present on escalation.raised");
assert_eq!(
question.len(),
2048,
"QUESTION env must be 2048-byte truncated"
);
assert!(question.starts_with("[input] qqq"));
}
#[tokio::test(start_paused = true)]
async fn zero_timeout_waits_indefinitely_for_the_reply() {
let (tx, rx) = oneshot::channel();