diff --git a/.github/workflows/live-canary.yml b/.github/workflows/live-canary.yml index c484b4a467d..b4c742319d3 100644 --- a/.github/workflows/live-canary.yml +++ b/.github/workflows/live-canary.yml @@ -1,19 +1,15 @@ name: Live Canary on: - # Each cron below is matched by `if: github.event.schedule == ''` 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: @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs index 492fe158940..d3f76df027f 100644 --- a/crates/ironclaw_common/src/event.rs +++ b/crates/ironclaw_common/src/event.rs @@ -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 { @@ -507,6 +518,51 @@ pub enum AppEvent { #[serde(skip_serializing_if = "Option::is_none")] thread_id: Option, }, + + /// 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, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, +} + +/// 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 { @@ -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", } } @@ -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 { diff --git a/crates/ironclaw_common/src/lib.rs b/crates/ironclaw_common/src/lib.rs index 22521186713..f0ddf7ab1d6 100644 --- a/crates/ironclaw_common/src/lib.rs +++ b/crates/ironclaw_common/src/lib.rs @@ -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, diff --git a/src/bridge/router.rs b/src/bridge/router.rs index f4d3d460cc0..eed08d480b5 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -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, @@ -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()), @@ -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( diff --git a/src/channels/repl.rs b/src/channels/repl.rs index d3d46e7eed5..ed1cc64c649 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -389,12 +389,9 @@ impl Channel for ReplChannel { async fn start(&self) -> Result { let (tx, rx) = mpsc::channel(32); - // Store tx so send_status can inject approval responses directly. - // Skip for single-message mode — no interactive approval is needed - // and the extra sender would keep the stream open after /quit. - if self.single_message.is_none() - && let Ok(mut guard) = self.msg_tx.lock() - { + // Store tx so send_status can inject approval responses directly and + // single-message mode can send `/quit` after the first response. + if let Ok(mut guard) = self.msg_tx.lock() { *guard = Some(tx.clone()); } let single_message = self.single_message.clone(); @@ -856,8 +853,10 @@ mod tests { use super::*; - /// Regression: single-message mode must close the stream after the one - /// message so callers (and tests) don't hang forever. + /// Regression: single-message mode sends the user line, then after the agent + /// completes the turn `finish_single_message_turn()` injects `/quit` so the + /// main loop can exit; only then should the stream close (msg_tx clone kept + /// the channel open after the input thread exited). #[tokio::test] async fn single_message_mode_sends_message_and_closes_stream() { let repl = ReplChannel::with_message("hi".to_string()); @@ -870,15 +869,21 @@ mod tests { assert_eq!(first.channel, "repl"); assert_eq!(first.content, "hi"); - // The spawned thread sent the message and returned, dropping its - // sender. Because we skip storing a clone in msg_tx for single- - // message mode, the stream should close immediately. + repl.finish_single_message_turn().await; + + let quit = timeout(Duration::from_secs(1), stream.next()) + .await + .expect("timed out waiting for /quit injection") + .expect("/quit message missing"); + assert_eq!(quit.channel, "repl"); + assert_eq!(quit.content, "/quit"); + assert!( timeout(Duration::from_secs(1), stream.next()) .await .expect("timed out waiting for stream to close") .is_none(), - "stream should end after the single message" + "stream should end after /quit sender is dropped" ); } diff --git a/tools-src/github/Cargo.toml b/tools-src/github/Cargo.toml index 4c8ae6ecc90..8d0086d9bba 100644 --- a/tools-src/github/Cargo.toml +++ b/tools-src/github/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "github-tool" -version = "0.2.2" +version = "0.2.3" edition = "2021" description = "GitHub integration tool for IronClaw (WASM component)" license = "MIT OR Apache-2.0" diff --git a/tools-src/github/README.md b/tools-src/github/README.md index 2e8ca8a98d3..988cc7521c4 100644 --- a/tools-src/github/README.md +++ b/tools-src/github/README.md @@ -8,6 +8,7 @@ search, branches, file reads and writes, releases, and workflows. - **Repositories** - Get repo details, list user repos, create repositories - **Search** - Search repositories, code, and issues/PRs - **Branches** - List branches and create new branches from an existing ref +- **Fork** - Fork repositories - **Issues** - List/create/get issues, list/add issue comments - **Pull Requests** - List/create/get PRs, review files, create reviews, list/reply review comments, merge PRs - **File Content** - Read files and create/update/delete repository files @@ -286,6 +287,21 @@ When updating an existing file, include the current blob `sha`. } ``` +### Fork Repository + +```json +{ + "action": "fork_repo", + "owner": "nearai", + "repo": "ironclaw", + "organization": "my-org", + "name": "ironclaw-fork", + "default_branch_only": true +} +``` + +`organization`, `name`, and `default_branch_only` are optional. Omit `organization` to fork into the authenticated user's account. + ### Create Branch ```json diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index f9c02156708..036f7e9dad7 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -1,5 +1,5 @@ { - "version": "0.2.2", + "version": "0.2.3", "wit_version": "0.3.0", "description": "Manage GitHub repositories, issues, pull requests, search, branches, file reads and writes, releases, and workflows.", "discovery_summary": { @@ -16,7 +16,7 @@ "notes": [ "Use `tool_info(name: \"github\", detail: \"schema\")` for the full action schema before guessing fields.", "Supported families: repositories, issues, pull requests, reviews/comments, search, branches, code reads, file writes, releases, workflow dispatch/runs, and webhook normalization.", - "Not supported yet: forks, labels, milestones, projects, org/team admin, GraphQL, release asset uploads, and repository deletion." + "Not supported yet: labels, milestones, projects, org/team admin, GraphQL, release asset uploads, and repository deletion." ], "examples": [ { diff --git a/tools-src/github/src/lib.rs b/tools-src/github/src/lib.rs index 63a2f73f835..15d6232f0b8 100644 --- a/tools-src/github/src/lib.rs +++ b/tools-src/github/src/lib.rs @@ -430,6 +430,14 @@ enum GitHubAction { page: Option, limit: Option, }, + #[serde(rename = "fork_repo")] + ForkRepo { + owner: String, + repo: String, + organization: Option, + name: Option, + default_branch_only: Option, + }, #[serde(rename = "handle_webhook")] HandleWebhook { webhook: GitHubWebhookRequest }, } @@ -752,6 +760,19 @@ fn execute_inner(params: &str) -> Result { page, limit, } => get_workflow_runs(&owner, &repo, workflow_id.as_deref(), page, limit), + GitHubAction::ForkRepo { + owner, + repo, + organization, + name, + default_branch_only, + } => fork_repo( + &owner, + &repo, + organization.as_deref(), + name.as_deref(), + default_branch_only, + ), GitHubAction::HandleWebhook { webhook } => handle_webhook(webhook), } } @@ -926,6 +947,49 @@ fn create_repo( github_request("POST", &path, Some(req_body.to_string())) } +fn fork_repo( + owner: &str, + repo: &str, + organization: Option<&str>, + name: Option<&str>, + default_branch_only: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(owner, "owner")?; + validate_input_length(repo, "repo")?; + if let Some(org) = organization { + validate_input_length(org, "organization")?; + if !validate_path_segment(org) { + return Err("Invalid org name".into()); + } + } + if let Some(n) = name { + validate_input_length(n, "name")?; + if !validate_path_segment(n) { + return Err("Invalid fork name".into()); + } + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!("/repos/{}/{}/forks", encoded_owner, encoded_repo); + + let mut req_body = serde_json::json!({}); + if let Some(org) = organization { + req_body["organization"] = serde_json::json!(org); + } + if let Some(n) = name { + req_body["name"] = serde_json::json!(n); + } + if let Some(only) = default_branch_only { + req_body["default_branch_only"] = serde_json::json!(only); + } + + github_request("POST", &path, Some(req_body.to_string())) +} + fn list_issues( owner: &str, repo: &str, @@ -2300,6 +2364,17 @@ const SCHEMA: &str = r#"{ }, "required": ["action", "owner", "repo"] }, + { + "properties": { + "action": { "const": "fork_repo" }, + "owner": { "type": "string", "description": "Repository owner (user or org) to fork from" }, + "repo": { "type": "string", "description": "Repository name to fork" }, + "organization": { "type": "string", "description": "Optional organization to fork into; omit to fork into the authenticated user's account" }, + "name": { "type": "string", "description": "Optional name for the fork; defaults to the original repo name" }, + "default_branch_only": { "type": "boolean", "default": false, "description": "When true, only the default branch is copied into the fork" } + }, + "required": ["action", "owner", "repo"] + }, { "properties": { "action": { "const": "handle_webhook" }, @@ -2377,6 +2452,7 @@ mod tests { "create_release", "trigger_workflow", "get_workflow_runs", + "fork_repo", "handle_webhook", ] .into_iter()