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:
7 files changed
+369
-4
No files matched your search
+78
-1
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user