Fix CI approval flows and stale fixtures (#1478)

* Fix CI approval flows and stale fixtures

* Backfill approval thread mapping across channels
This commit is contained in:
Henry Park
2026-03-20 12:21:46 -07:00
committed by GitHub
parent ee6f5cd62a
commit d3b69e7be3
6 changed files with 169 additions and 141 deletions
@@ -1,87 +0,0 @@
# Staging CI Review Issues Triage
**Date:** 2026-03-18
**Branch:** staging (HEAD `b7a1edf`)
**Total open issues:** 50
---
## Batch 1 — Critical & 100-confidence issues
| # | Title | Severity | Verdict | File(s) | Action |
|---|-------|----------|---------|---------|--------|
| 1281 | Logic inversion in Telegram auto-verification | CRITICAL:100 | **FALSE POSITIVE** (closed) | `src/channels/web/server.rs` | Different handlers with intentional different SSE behavior |
| 908 | Missing consecutive_failures reset | CRITICAL:100 | **STALE** | `src/llm/circuit_breaker.rs` | Close — `record_success()` already resets to 0 |
| 1282 | Variable shadowing fallback notification | HIGH:100 | **STALE** | `src/agent/agent_loop.rs` | Close — fixed in commit `bcc38ce` |
| 1283 | Inconsistent fallback logic DRY | HIGH:75 | **STALE** | `src/agent/agent_loop.rs` | Close — fixed in commit `bcc38ce` |
| 1178 | Workflow linting bypass for test code | CRITICAL:75 | **FALSE POSITIVE** | `.github/workflows/code_style.yml` | Close — script reads full file, not hunk headers |
---
## Remaining Batches (queued)
### Batch 2 — Retry/DRY + CI workflow issues (completed)
| # | Title | Severity | Verdict | Action |
|---|-------|----------|---------|--------|
| 1288 | DRY violation: retry-after parsing | HIGH:95 | **LEGIT** | Fixed: extracted shared `parse_retry_after()` |
| 1289 | Semantic mismatch in RFC2822 test helpers | MEDIUM:85 | **DUPLICATE** (closed) | Duplicate of #1288 |
| 1290 | Unnecessary eager `chrono::Utc::now()` call | LOW:85 | **FALSE POSITIVE** (closed) | Already deferred inside successful parse branch |
| 963 | Logical equivalence bug in workflow conditions | HIGH:100 | **FALSE POSITIVE** (closed) | Refactored condition correctly handles `workflow_call` |
| 1280 | Flaky OAuth wildcard callback tests | Flaky | **LEGIT** | Fixed: added `tokio::sync::Mutex` for env var serialization |
### Batch 3 — Routine engine + notification routing
- #1365 — too_many_arguments on RoutineEngine::new()
- #1371 — Discovery schema regeneration on every tool_info call
- #1364 — Prompt injection via unescaped channel/user in lightweight routines
- #1284 — notification_target_for_channel() assumes channel owner
### Batch 4 — Telegram/Extension Manager webhook group
- #1247 — Synchronous 120-second blocking poll in HTTP handler
- #1248 — Hardcoded channel-specific logic violates architecture
- #1249 — Telegram-specific business logic bloats ExtensionManager
- #1250 — Response success/failure logic mismatch in chat auth
- #1251 — Channel-specific configuration mappings lack extensibility
### Batch 5 — HMAC/Auth/Security
- #1034 — Signature verification not constant-time
- #1035 — Incorrect order of operations in HMAC verification
- #1036 — Double opt-in lacks runtime validation consistency
- #1037 — API breaking change: auth() signature
- #1038 — CSP policy allows CDN scripts with risky fallback
### Batch 6 — Webhook handler + config
- #1039 — Per-request HTTP client creation in hot path
- #1040 — Complex nested auth logic in webhook_handler
- #1041 — Redundant JSON deserialization in webhook handler
- #1042 — Implicit state mutation in config conversion
- #1005 — Inconsistent double opt-in enforcement
### Batch 7 — Tool schema validation / WASM bounds
- #974 — Unbounded recursion in resolve_nested()
- #975 — Unbounded recursion in validate_tool_schema()
- #976 — Unbounded description string in CapabilitiesFile
- #977 — Unbounded parameters schema JSON
- #978 — Unnecessary clone of large JSON in hot path
### Batch 8 — Tool schema + config + security
- #979 — No size limits on JSON files read
- #980 — Misleading warning condition for missing parameters
- #988 — Hardcoded CLI_ENABLED env var in systemd template
- #990 — Configuration semantics unclear for daemon mode
- #1103 — SSRF risk via configurable embedding base URL
### Batch 9 — Agent loop / job worker
- #870 — Unbounded loop without cancellation token
- #871 — Stringly-typed unsupported parameter filtering
- #873 — RwLock overhead on hot path
- #892 — JobDelegate::check_signals() treats non-terminal as terminal
- #1252 — String concatenation in hot polling loop
### Batch 10 — Agent loop perf + CI scripts
- #893 — Unnecessary parameter cloning on every tool execution
- #894 — truncate_for_preview allocates for non-truncated strings
- #895 — Tool definitions fetched every iteration without caching
- #1179 — AWK state machine never resets between hunks
- #1180 — Code fence detection logic flawed in extract_suggestions()
- #1181 — Unsafe .unwrap() in production code manifest.rs
+54 -9
View File
@@ -10,6 +10,7 @@
use std::sync::Arc; use std::sync::Arc;
use futures::StreamExt; use futures::StreamExt;
use uuid::Uuid;
use crate::agent::context_monitor::ContextMonitor; use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat; use crate::agent::heartbeat::spawn_heartbeat;
@@ -1014,15 +1015,59 @@ impl Agent {
} }
} }
// Resolve session and thread // Resolve session and thread. Approval submissions are allowed to
let (session, thread_id) = self // target an already-loaded owned thread by UUID across channels so the
.session_manager // web approval UI can approve work that originated from HTTP/other
.resolve_thread( // owner-scoped channels.
&message.user_id, let approval_thread_uuid = if matches!(
&message.channel, submission,
message.conversation_scope(), Submission::ExecApproval { .. } | Submission::ApprovalResponse { .. }
) ) {
.await; message
.conversation_scope()
.and_then(|thread_id| Uuid::parse_str(thread_id).ok())
} else {
None
};
let (session, thread_id) = if let Some(target_thread_id) = approval_thread_uuid {
let session = self
.session_manager
.get_or_create_session(&message.user_id)
.await;
let mut sess = session.lock().await;
if sess.threads.contains_key(&target_thread_id) {
sess.active_thread = Some(target_thread_id);
sess.last_active_at = chrono::Utc::now();
drop(sess);
self.session_manager
.register_thread(
&message.user_id,
&message.channel,
target_thread_id,
Arc::clone(&session),
)
.await;
(session, target_thread_id)
} else {
drop(sess);
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
}
} else {
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
};
tracing::debug!( tracing::debug!(
message_id = %message.id, message_id = %message.id,
thread_id = %thread_id, thread_id = %thread_id,
+27
View File
@@ -772,6 +772,33 @@ mod tests {
assert_ne!(resolved, tid); assert_ne!(resolved, tid);
} }
#[tokio::test]
async fn test_register_then_resolve_same_uuid_on_second_channel_reuses_thread() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let tid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-cross")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}
manager
.register_thread("user-cross", "http", tid, Arc::clone(&session))
.await;
manager
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
.await;
let (_, resolved) = manager
.resolve_thread("user-cross", "gateway", Some(&tid.to_string()))
.await;
assert_eq!(resolved, tid);
}
// === QA Plan P3 - 4.2: Concurrent session stress tests === // === QA Plan P3 - 4.2: Concurrent session stress tests ===
#[tokio::test] #[tokio::test]
+2 -5
View File
@@ -299,15 +299,12 @@ mod tests {
// SAFETY: Under ENV_MUTEX, no concurrent env access. // SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { unsafe {
std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com"); std::env::set_var("EMBEDDING_BASE_URL", "https://8.8.8.8");
} }
let settings = Settings::default(); let settings = Settings::default();
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!( assert_eq!(config.openai_base_url.as_deref(), Some("https://8.8.8.8"));
config.openai_base_url.as_deref(),
Some("https://custom.example.com")
);
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
unsafe { unsafe {
std::env::remove_var("EMBEDDING_BASE_URL"); std::env::remove_var("EMBEDDING_BASE_URL");
+4 -4
View File
@@ -855,19 +855,19 @@ mod tests {
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
unsafe { unsafe {
std::env::set_var("LLM_BACKEND", "openai_compatible"); std::env::set_var("LLM_BACKEND", "openai_compatible");
std::env::set_var("LLM_BASE_URL", "http://env-url/v1"); std::env::set_var("LLM_BASE_URL", "http://localhost:8000/v1");
} }
let settings = Settings { let settings = Settings {
llm_backend: Some("openai_compatible".to_string()), llm_backend: Some("openai_compatible".to_string()),
openai_compatible_base_url: Some("http://settings-url/v1".to_string()), openai_compatible_base_url: Some("http://localhost:9000/v1".to_string()),
..Default::default() ..Default::default()
}; };
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config"); let provider = cfg.provider.expect("should have provider config");
assert_eq!( assert_eq!(
provider.base_url, "http://env-url/v1", provider.base_url, "http://localhost:8000/v1",
"env var should take priority over settings" "env var should take priority over settings"
); );
@@ -879,7 +879,7 @@ mod tests {
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config"); let provider = cfg.provider.expect("should have provider config");
assert_eq!( assert_eq!(
provider.base_url, "http://settings-url/v1", provider.base_url, "http://localhost:9000/v1",
"settings should take priority over registry default" "settings should take priority over registry default"
); );
+82 -36
View File
@@ -4,7 +4,6 @@ These tests exercise the explicit owner model across:
- the web gateway chat UI - the web gateway chat UI
- the owner-scoped HTTP webhook channel - the owner-scoped HTTP webhook channel
- routine tools / routines tab - routine tools / routines tab
- job creation via routine execution / jobs tab
""" """
import asyncio import asyncio
@@ -13,7 +12,13 @@ import uuid
import httpx import httpx
from helpers import SEL, AUTH_TOKEN, signed_http_webhook_headers from helpers import (
AUTH_TOKEN,
SEL,
api_get,
api_post,
signed_http_webhook_headers,
)
async def _send_and_get_response( async def _send_and_get_response(
@@ -58,13 +63,14 @@ async def _post_http_webhook(
content: str, content: str,
sender_id: str, sender_id: str,
thread_id: str, thread_id: str,
) -> str: wait_for_response: bool = True,
) -> str | None:
"""Send a signed request to the owner-scoped HTTP webhook channel.""" """Send a signed request to the owner-scoped HTTP webhook channel."""
payload = { payload = {
"user_id": sender_id, "user_id": sender_id,
"thread_id": thread_id, "thread_id": thread_id,
"content": content, "content": content,
"wait_for_response": True, "wait_for_response": wait_for_response,
} }
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
@@ -81,8 +87,9 @@ async def _post_http_webhook(
) )
data = response.json() data = response.json()
assert data["status"] == "accepted", f"Unexpected webhook response: {data}" assert data["status"] == "accepted", f"Unexpected webhook response: {data}"
assert data["response"], f"Expected synchronous response body, got: {data}" if wait_for_response:
return data["response"] assert data["response"], f"Expected synchronous response body, got: {data}"
return data.get("response")
async def _open_tab(page, tab: str) -> None: async def _open_tab(page, tab: str) -> None:
@@ -112,22 +119,60 @@ async def _wait_for_routine(base_url: str, name: str, timeout: float = 20.0) ->
raise AssertionError(f"Routine '{name}' was not created within {timeout}s") raise AssertionError(f"Routine '{name}' was not created within {timeout}s")
async def _wait_for_job(base_url: str, title: str, timeout: float = 30.0) -> dict: async def _wait_for_http_thread(base_url: str, title_fragment: str, timeout: float = 20.0) -> str:
"""Poll the jobs API until the named job exists.""" """Poll the chat thread list until the matching HTTP thread is visible."""
async with httpx.AsyncClient() as client: for _ in range(int(timeout * 2)):
for _ in range(int(timeout * 2)): response = await api_get(base_url, "/api/chat/threads", timeout=10)
response = await client.get( response.raise_for_status()
f"{base_url}/api/jobs", threads = response.json()["threads"]
headers={"Authorization": f"Bearer {AUTH_TOKEN}"}, for thread in threads:
timeout=10, if thread.get("channel") != "http":
) continue
response.raise_for_status() if title_fragment in (thread.get("title") or ""):
jobs = response.json()["jobs"] return thread["id"]
for job in jobs: await _poll_sleep()
if job["title"] == title: raise AssertionError(
return job f"HTTP thread containing '{title_fragment}' was not visible within {timeout}s"
await _poll_sleep() )
raise AssertionError(f"Job '{title}' was not created within {timeout}s")
async def _wait_for_pending_approval(
base_url: str,
thread_id: str,
timeout: float = 20.0,
) -> dict:
"""Poll chat history until the thread exposes a pending approval payload."""
for _ in range(int(timeout * 2)):
response = await api_get(
base_url,
f"/api/chat/history?thread_id={thread_id}",
timeout=10,
)
response.raise_for_status()
pending = response.json().get("pending_approval")
if pending:
return pending
await _poll_sleep()
raise AssertionError(f"Thread '{thread_id}' did not expose a pending approval")
async def _approve_pending_request(base_url: str, thread_id: str, request_id: str) -> None:
"""Approve a pending tool request through the web gateway API."""
response = await api_post(
base_url,
"/api/chat/approval",
json={
"request_id": request_id,
"action": "approve",
"thread_id": thread_id,
},
timeout=10,
)
assert response.status_code == 202, (
f"Approval submission failed: {response.status_code} {response.text[:400]}"
)
data = response.json()
assert data["status"] == "accepted", f"Unexpected approval response: {data}"
async def _poll_sleep() -> None: async def _poll_sleep() -> None:
@@ -194,33 +239,34 @@ async def test_web_created_routine_is_listed_from_http_channel_across_senders(
assert routine_name in second_sender_text, second_sender_text assert routine_name in second_sender_text, second_sender_text
async def test_http_created_full_job_routine_can_be_run_from_web_and_shows_in_jobs( async def test_http_created_full_job_routine_is_visible_in_web_after_approval(
page, page,
ironclaw_server, ironclaw_server,
http_channel_server, http_channel_server,
): ):
"""A full-job routine created via HTTP can be run from the web UI and create a job.""" """A full-job routine created via HTTP appears in the web owner UI after approval."""
routine_name = f"owner-job-{uuid.uuid4().hex[:8]}" routine_name = f"owner-job-{uuid.uuid4().hex[:8]}"
response_text = await _post_http_webhook( await _post_http_webhook(
http_channel_server, http_channel_server,
content=f"create full-job owner routine {routine_name}", content=f"create full-job owner routine {routine_name}",
sender_id="http-job-sender", sender_id="http-job-sender",
thread_id="owner-job-thread", thread_id="owner-job-thread",
wait_for_response=False,
) )
assert routine_name in response_text
await _wait_for_routine(ironclaw_server, routine_name) thread_id = await _wait_for_http_thread(ironclaw_server, routine_name)
pending = await _wait_for_pending_approval(ironclaw_server, thread_id)
assert pending["tool_name"] == "routine_create"
await _approve_pending_request(
ironclaw_server,
thread_id,
pending["request_id"],
)
routine = await _wait_for_routine(ironclaw_server, routine_name)
assert routine["action_type"] == "full_job"
await _open_tab(page, "routines") await _open_tab(page, "routines")
routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first
await routine_row.wait_for(state="visible", timeout=15000) await routine_row.wait_for(state="visible", timeout=15000)
await routine_row.locator('button[data-action="trigger-routine"]').click()
await _wait_for_job(ironclaw_server, routine_name, timeout=45.0)
await _open_tab(page, "jobs")
await page.locator(SEL["job_row"]).filter(has_text=routine_name).first.wait_for(
state="visible",
timeout=20000,
)