test(engine,protocol): fix the external-agent and inventory tests hosted CI failed (CW-7879)

Hosted CI 42771647964 on e173b159b1 was the first nextest, macOS and
Windows run of the external-agent code. Lint (clippy) and MSRV passed.
Test failed on all three OSes: ubuntu 19178 run / 1 failed, macOS 19184
run / 1 failed, Windows 18426 run / 4 failed. No product behavior changes
here.

1. a_fixture_agent_turn_runs_end_to_end_and_never_auto_approves (ubuntu
   "missing turn.completed", macOS "Thread already has an active turn").
   Turn settlement saves the terminal status, publishes turn.completed,
   then frees the thread, the same order the native path uses. The test
   helper polled only the stored status and went on at once. wait_for_turn
   now also waits for the thread to be free, through a cfg(test) probe
   external_turn_is_active.
2. Windows, three external-agent tests: "The fixture agent needs `node` on
   PATH." The tests' own guard ran `node --version`, which Windows
   resolves to node.exe, while the product resolver looks for a file named
   exactly `node`. The guard now asks the product resolver, so the tests
   skip where the product cannot launch the fixture.
3. Windows, typed_methods_inventory_and_pinned_export_agree: "inventory has
   no section Client requests". METHODS.md is read with include_str! and
   matched on "## <section>\n"; a Windows checkout made it CRLF. It is now
   pinned to LF in .gitattributes beside the protocol schema.

Left open, found here and not fixed: fleet::agents::resolve ignores
PATHEXT, so no external agent resolves on Windows and these tests skip
there. And a client that starts the next turn the instant it reads
turn.completed can be refused with "Thread already has an active turn",
on external and native sessions alike.

Checks: Boat farm run 20261110T084916Z-wfmainred-ea01fix2-f022b5 (Linux
x86_64, CARGO_INCREMENTAL=0), cargo test --locked -p codewhale-engine
--lib -- external_agent fleet::acp
approval_required_awaits_external_decision_allow
external_approval_is_never_acknowledged_when_its_decision_append_fails:
14 passed, 0 failed. rustfmt --check clean. The two races and both
Windows failures were not reproduced locally; hosted CI on this commit is
the proof.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Fable 5.2 committed 2026-11-10 01:09:53 -08:00
1 parent e173b159b1
commit 59b1664105
3 files changed
+19 -10

No files matched your search

+1
View File
@@ -83,4 +83,5 @@ web/public/brand/fonts/OFL.txt text eol=lf whitespace=-blank-at-eol
# Generated protocol and SDK exports are compared byte for byte; keep LF on Windows checkouts.
crates/app-server-protocol/schema/** text eol=lf
crates/app-server-protocol/METHODS.md text eol=lf
npm/runtime-sdk/generated/** text eol=lf
@@ -641,6 +641,16 @@ impl RuntimeThreadManager {
self.active.lock().await.external.contains_key(thread_id)
}
#[cfg(test)]
pub(crate) async fn external_turn_is_active(&self, thread_id: &str) -> bool {
self.active
.lock()
.await
.external
.get(thread_id)
.is_some_and(|state| state.active_turn.is_some())
}
pub async fn stop_external_agent(&self, thread_id: &str) -> Result<bool> {
let removed = {
let mut active = self.active.lock().await;
@@ -5,11 +5,8 @@ fn fixture_script() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp-agent.mjs")
}
fn node_available() -> bool {
std::process::Command::new("node")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
async fn node_available() -> bool {
crate::fleet::agents::resolve("node").await.is_some()
}
fn fixture_thread_request(workspace: &Path, posture: &str) -> CreateThreadRequest {
@@ -36,7 +33,8 @@ async fn wait_for_turn(manager: &RuntimeThreadManager, turn_id: &str) -> Result<
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let turn = manager.store.load_turn(turn_id)?;
if !turn.status.is_active_work() {
if !turn.status.is_active_work() && !manager.external_turn_is_active(&turn.thread_id).await
{
return Ok(turn);
}
anyhow::ensure!(Instant::now() < deadline, "turn {turn_id} never settled");
@@ -146,7 +144,7 @@ async fn an_unknown_or_missing_agent_cannot_create_a_thread() -> Result<()> {
#[tokio::test]
async fn a_fixture_agent_turn_runs_end_to_end_and_never_auto_approves() -> Result<()> {
if !node_available() {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
@@ -360,7 +358,7 @@ async fn a_fixture_agent_turn_runs_end_to_end_and_never_auto_approves() -> Resul
#[tokio::test]
async fn external_postures_allow_only_complete_in_workspace_edit_requests() -> Result<()> {
if !node_available() {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
@@ -573,7 +571,7 @@ async fn duplicate_external_answers_wait_for_the_same_durable_settlement() -> Re
#[tokio::test]
async fn interrupt_during_external_persistence_never_dispatches_prompt() -> Result<()> {
if !node_available() {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
@@ -637,7 +635,7 @@ async fn interrupt_during_external_persistence_never_dispatches_prompt() -> Resu
#[cfg(unix)]
#[tokio::test]
async fn an_abandoned_external_startup_stops_its_agent_inside_the_shutdown_barrier() -> Result<()> {
if !node_available() {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();