Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 18 additions & 22 deletions .github/workflows/live-canary.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
name: Live Canary

on:
# Each cron below is matched by `if: github.event.schedule == '<cron>'` on a
# specific job. Keep this list in sync with the `if:` guards — an orphan cron
# will fire with no work, and a new job needs its cron added here.
# Every job's `if:` guard matches the one cron below. Adding a new
# scheduled slot means also updating those guards, so keep this
# block and the job conditions in lockstep.
schedule:
# Temporary: every lane runs hourly while we dial in coverage. Staggered
# across minute offsets so they don't all spike at :00. Revisit once
# signal is stable — provider-matrix + browser-consent lanes are
# expensive and were previously daily/weekly.
- cron: "0 * * * *" # → auth-smoke + auth-full + auth-channels + deterministic-replay
- cron: "15 * * * *" # → auth-live-seeded (real Google/GitHub/Notion tokens)
- cron: "30 * * * *" # → public-smoke + persona-rotating + private-oauth
- cron: "45 * * * *" # → auth-browser-consent (Playwright OAuth consent)
- cron: "50 * * * *" # → provider-matrix (full provider lane)
# Single daily slot: every lane runs once per day at 02:00 UTC as
# parallel jobs in the same workflow run. One run = one red dot on
# failure, one notification, one place to drill into per-lane
# status. Job-level `if:` guards below all match this cron.
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
lane:
Expand Down Expand Up @@ -70,7 +66,7 @@ jobs:
auth-smoke:
name: Auth Smoke
if: >
(github.event_name == 'schedule' && github.event.schedule == '0 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'auth-smoke'))
runs-on: ubuntu-latest
timeout-minutes: 60
Expand Down Expand Up @@ -102,7 +98,7 @@ jobs:
auth-full:
name: Auth Full
if: >
(github.event_name == 'schedule' && github.event.schedule == '0 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'auth-full'))
runs-on: ubuntu-latest
timeout-minutes: 75
Expand Down Expand Up @@ -134,7 +130,7 @@ jobs:
auth-channels:
name: Auth Channels
if: >
(github.event_name == 'schedule' && github.event.schedule == '0 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'auth-channels'))
runs-on: ubuntu-latest
timeout-minutes: 60
Expand Down Expand Up @@ -166,7 +162,7 @@ jobs:
auth-live-seeded:
name: Auth Live Seeded
if: >
(github.event_name == 'schedule' && github.event.schedule == '15 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'auth-live-seeded'))
runs-on: ubuntu-latest
timeout-minutes: 75
Expand Down Expand Up @@ -256,7 +252,7 @@ jobs:
auth-browser-consent:
name: Auth Browser Consent
if: >
(github.event_name == 'schedule' && github.event.schedule == '45 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'auth-browser-consent'))
runs-on: ubuntu-latest
timeout-minutes: 90
Expand Down Expand Up @@ -359,7 +355,7 @@ jobs:
deterministic-replay:
name: Deterministic Replay
if: >
(github.event_name == 'schedule' && github.event.schedule == '0 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' &&
(inputs.lane == 'all' || inputs.lane == 'deterministic-replay'))
runs-on: ubuntu-latest
Expand Down Expand Up @@ -400,7 +396,7 @@ jobs:
public-smoke:
name: Public Live Smoke
if: >
(github.event_name == 'schedule' && github.event.schedule == '30 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'public-smoke'))
runs-on: ubuntu-latest
timeout-minutes: 120
Expand Down Expand Up @@ -461,7 +457,7 @@ jobs:
persona-rotating:
name: Rotating Persona Live
if: >
(github.event_name == 'schedule' && github.event.schedule == '30 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'persona-rotating'))
runs-on: ubuntu-latest
timeout-minutes: 180
Expand Down Expand Up @@ -521,7 +517,7 @@ jobs:
name: Private OAuth Live
if: >
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'private-oauth')) ||
(github.event_name == 'schedule' && github.event.schedule == '30 * * * *' && vars.LIVE_CANARY_PRIVATE_OAUTH_ENABLED == 'true')
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *' && vars.LIVE_CANARY_PRIVATE_OAUTH_ENABLED == 'true')
runs-on: [self-hosted, ironclaw-live]
timeout-minutes: 120
env:
Expand Down Expand Up @@ -574,7 +570,7 @@ jobs:
provider-matrix:
name: Provider Matrix (${{ matrix.provider }})
if: >
(github.event_name == 'schedule' && github.event.schedule == '50 * * * *') ||
(github.event_name == 'schedule' && github.event.schedule == '0 2 * * *') ||
(github.event_name == 'workflow_dispatch' && (inputs.lane == 'all' || inputs.lane == 'provider-matrix'))
runs-on: ubuntu-latest
timeout-minutes: 120
Expand Down
69 changes: 69 additions & 0 deletions crates/ironclaw_common/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,17 @@ pub enum AppEvent {
goal: String,
},

/// A child thread completed (terminal state reached).
///
/// Symmetric to `ChildThreadSpawned`: the UI uses the pair to mark
/// child branches finished in tree views. Bridged from engine
/// `EventKind::ChildCompleted`.
#[serde(rename = "child_thread_completed")]
ChildThreadCompleted {
parent_thread_id: String,
child_thread_id: String,
},

/// A mission spawned a new thread.
#[serde(rename = "mission_thread_spawned")]
MissionThreadSpawned {
Expand Down Expand Up @@ -507,6 +518,51 @@ pub enum AppEvent {
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},

/// CodeAct (Python / Monty) execution failed.
///
/// Bridged from engine `EventKind::CodeExecutionFailed`. The engine's
/// `CodeExecutionFailure` enum isn't re-exported into this crate
/// (dependency direction: `ironclaw_engine` depends on
/// `ironclaw_common`, not vice versa), so the wire type is a
/// dedicated parallel enum with matching snake_case serialization —
/// per `.claude/rules/types.md` "Wire-stable enums", not a stringly
/// typed field.
#[serde(rename = "code_execution_failed")]
CodeExecutionFailed {
category: CodeExecutionFailureCategory,
error: String,
duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
code_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
}

/// Wire-side mirror of `ironclaw_engine::CodeExecutionFailure`.
///
/// Must be kept in variant-for-variant lock with the engine enum. Both
/// types serialize to the same snake_case strings so that a single
/// frontend matcher handles any direct-engine telemetry path that may
/// later emerge alongside the bridge projection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CodeExecutionFailureCategory {
/// Python parse error — LLM generated invalid syntax.
SyntaxError,
/// Python runtime error (NameError, TypeError, ValueError, etc.).
RuntimeError,
/// Name lookup failed — function/variable not in scope and not a known tool.
NameLookup,
/// Monty VM panicked (caught by `catch_unwind`).
VmPanic,
/// Resource limit hit (timeout, memory, allocation cap).
ResourceLimit,
/// A tool call inside code returned an error.
ToolError,
/// OS operation attempted (blocked by sandbox).
OsDenied,
}

impl AppEvent {
Expand Down Expand Up @@ -543,8 +599,10 @@ impl AppEvent {
Self::TurnMetrics { .. } => "turn_metrics",
Self::ThreadStateChanged { .. } => "thread_state_changed",
Self::ChildThreadSpawned { .. } => "child_thread_spawned",
Self::ChildThreadCompleted { .. } => "child_thread_completed",
Self::MissionThreadSpawned { .. } => "mission_thread_spawned",
Self::PlanUpdate { .. } => "plan_update",
Self::CodeExecutionFailed { .. } => "code_execution_failed",
}
}

Expand Down Expand Up @@ -741,6 +799,17 @@ mod tests {
mission_id: None,
thread_id: None,
},
AppEvent::ChildThreadCompleted {
parent_thread_id: String::new(),
child_thread_id: String::new(),
},
AppEvent::CodeExecutionFailed {
category: CodeExecutionFailureCategory::SyntaxError,
error: String::new(),
duration_ms: 0,
code_hash: None,
thread_id: None,
},
];

for variant in &variants {
Expand Down
4 changes: 2 additions & 2 deletions crates/ironclaw_common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ mod timezone;
mod util;

pub use event::{
AppEvent, JobResultStatus, JobResultStatusParseError, OnboardingStateDto, PlanStepDto,
ToolDecisionDto,
AppEvent, CodeExecutionFailureCategory, JobResultStatus, JobResultStatusParseError,
OnboardingStateDto, PlanStepDto, ToolDecisionDto,
};
pub use identity::{
CredentialName, ExtensionName, ExternalThreadId, ExternalThreadIdError, IdentityError,
Expand Down
133 changes: 133 additions & 0 deletions src/bridge/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4566,6 +4566,31 @@ async fn forward_event_to_channel(
///
/// Returns multiple events when needed (e.g., ToolStarted + ToolCompleted
/// so the frontend creates the card then resolves it).
/// Bridge engine-side `CodeExecutionFailure` to its wire mirror
/// `CodeExecutionFailureCategory` in `ironclaw_common`.
///
/// Exhaustive on purpose: if the engine enum gains a variant, this must
/// fail to compile so both enums stay in lockstep. Per
/// `.claude/rules/types.md` "Wire-stable enums" — do not reach for
/// `format!("{:?}", ...)` or `.to_string()` here; the serde rename rules
/// on the two enums independently produce snake_case, and a `Debug`
/// detour would silently drift.
fn code_execution_category_to_wire(
category: &ironclaw_engine::CodeExecutionFailure,
) -> ironclaw_common::CodeExecutionFailureCategory {
use ironclaw_common::CodeExecutionFailureCategory as Wire;
use ironclaw_engine::CodeExecutionFailure as Src;
match category {
Src::SyntaxError => Wire::SyntaxError,
Src::RuntimeError => Wire::RuntimeError,
Src::NameLookup => Wire::NameLookup,
Src::VmPanic => Wire::VmPanic,
Src::ResourceLimit => Wire::ResourceLimit,
Src::ToolError => Wire::ToolError,
Src::OsDenied => Wire::OsDenied,
}
}

fn thread_event_to_app_events(
event: &ironclaw_engine::ThreadEvent,
thread_id: &str,
Expand Down Expand Up @@ -4660,6 +4685,27 @@ fn thread_event_to_app_events(
child_thread_id: child_id.to_string(),
goal: goal.clone(),
}],
EventKind::ChildCompleted { child_id } => vec![AppEvent::ChildThreadCompleted {
parent_thread_id: thread_id.into(),
child_thread_id: child_id.to_string(),
}],
EventKind::StepFailed { error, .. } => vec![AppEvent::Error {
message: format!("Step failed: {error}"),
thread_id: Some(thread_id.into()),
}],
EventKind::CodeExecutionFailed {
category,
error,
code_hash,
duration_ms,
..
} => vec![AppEvent::CodeExecutionFailed {
category: code_execution_category_to_wire(category),
error: error.clone(),
duration_ms: *duration_ms,
code_hash: code_hash.clone(),
thread_id: Some(thread_id.into()),
}],
EventKind::SkillActivated { skill_names } => vec![AppEvent::SkillActivated {
skill_names: skill_names.clone(),
thread_id: Some(thread_id.into()),
Expand Down Expand Up @@ -7109,6 +7155,93 @@ mod tests {
));
}

#[test]
fn thread_event_to_app_events_bridges_step_failed_to_error() {
let event = ironclaw_engine::ThreadEvent::new(
ironclaw_engine::ThreadId::new(),
ironclaw_engine::EventKind::StepFailed {
step_id: ironclaw_engine::StepId::new(),
error: "llm provider returned 502".to_string(),
},
);

let app_events = thread_event_to_app_events(&event, "thread-step-fail");

assert_eq!(app_events.len(), 1);
let AppEvent::Error { message, thread_id } = &app_events[0] else {
panic!("expected AppEvent::Error, got {:?}", app_events[0]);
};
assert!(
message.contains("llm provider returned 502"),
"error message should carry the engine error text, got {message:?}"
);
assert_eq!(thread_id.as_deref(), Some("thread-step-fail"));
}

#[test]
fn thread_event_to_app_events_bridges_child_completed() {
let child = ironclaw_engine::ThreadId::new();
let event = ironclaw_engine::ThreadEvent::new(
ironclaw_engine::ThreadId::new(),
ironclaw_engine::EventKind::ChildCompleted { child_id: child },
);

let app_events = thread_event_to_app_events(&event, "thread-parent");

assert_eq!(app_events.len(), 1);
let AppEvent::ChildThreadCompleted {
parent_thread_id,
child_thread_id,
} = &app_events[0]
else {
panic!(
"expected AppEvent::ChildThreadCompleted, got {:?}",
app_events[0]
);
};
assert_eq!(parent_thread_id, "thread-parent");
assert_eq!(child_thread_id, &child.to_string());
}

#[test]
fn thread_event_to_app_events_bridges_code_execution_failed() {
let event = ironclaw_engine::ThreadEvent::new(
ironclaw_engine::ThreadId::new(),
ironclaw_engine::EventKind::CodeExecutionFailed {
step_id: ironclaw_engine::StepId::new(),
category: ironclaw_engine::CodeExecutionFailure::RuntimeError,
error: "NameError: 'foo' is not defined".to_string(),
code_hash: Some("abc123".to_string()),
duration_ms: 42,
},
);

let app_events = thread_event_to_app_events(&event, "thread-codeact");

assert_eq!(app_events.len(), 1);
let AppEvent::CodeExecutionFailed {
category,
error,
duration_ms,
code_hash,
thread_id,
} = &app_events[0]
else {
panic!(
"expected AppEvent::CodeExecutionFailed, got {:?}",
app_events[0]
);
};
assert_eq!(
*category,
ironclaw_common::CodeExecutionFailureCategory::RuntimeError
);
assert_eq!(error, "NameError: 'foo' is not defined");
assert_eq!(*duration_ms, 42);
assert_eq!(code_hash.as_deref(), Some("abc123"));
assert_eq!(thread_id.as_deref(), Some("thread-codeact"));
}

#[test]
fn resolved_call_id_legacy_fallback_uses_last_unresolved_parallel_call() {
let mut thread = ironclaw_engine::Thread::new(
Expand Down
Loading
Loading