diff --git a/.dockerignore b/.dockerignore index 64ae4ee..d7a6db3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ .git -.github +.github/* +!.github/ +!.github/pull_request_template.md **/target elixir/_build elixir/deps diff --git a/Makefile b/Makefile index 07f6be7..d6d69b4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,24 @@ -.PHONY: docker-bootstrap-local docker-bootstrap-local-logs +.PHONY: install fmt fmt-check test clippy ci audit docker-build -docker-bootstrap-local: - $(MAKE) -C rust docker-bootstrap-local +install: + ./install.sh -docker-bootstrap-local-logs: - $(MAKE) -C rust docker-bootstrap-local-logs +fmt: + cd rust && cargo fmt + +fmt-check: + cd rust && cargo fmt --check + +test: + cd rust && cargo test + +clippy: + cd rust && cargo clippy --all-targets --all-features -- -D warnings + +ci: fmt-check test clippy + +audit: + bash scripts/public-readiness-audit.sh + +docker-build: + docker build -f rust/Dockerfile -t kairastra:local . diff --git a/README.md b/README.md index a742cd6..0a8a747 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ You are set up correctly when all of these are true: | --- | --- | | `krstr run` | Start the orchestrator loop using repo-root `WORKFLOW.md` by default. | | `krstr run --once` | Run one dispatch pass and wait for started workers before exit. | -| `krstr setup` | Generate repo-root `WORKFLOW.md`, `.kairastra/kairastra.env`, add `.kairastra/` to `.gitignore`, sync required `.agents/skills/` workflow skills into the repo with confirmation, scaffold repo support dirs, and optionally bootstrap GitHub metadata. | +| `krstr setup` | Generate repo-root `WORKFLOW.md`, `.kairastra/provider-settings.json`, `.kairastra/kairastra.env`, add `.kairastra/` to `.gitignore`, sync required `.agents/skills/` workflow skills into the repo with confirmation, scaffold repo support dirs, and optionally bootstrap GitHub metadata. | | `krstr setup --reconfigure` | Re-run setup and overwrite the generated local Kairastra files for the current repo. | | `krstr doctor` | Validate workflow, GitHub connectivity, local commands, auth state, and GitHub metadata readiness. | | `krstr auth status` | Show provider auth status. | @@ -120,6 +120,8 @@ You are set up correctly when all of these are true: - `krstr setup` writes operator files under `.kairastra/` by default. - The generated repo-root `WORKFLOW.md` keeps setup-specific front matter and uses the canonical prompt body from the repo root template. - The runtime reads `WORKFLOW.md` directly from the host filesystem. +- `.kairastra/provider-settings.json` stores compact provider defaults such as model, reasoning, + and autonomy settings for `codex`, `claude`, and `gemini`. - The generated env file keeps machine-specific settings and secrets outside the workflow file. Use [docs/workflow-reference.md](docs/workflow-reference.md) for the schema and operational rules. diff --git a/WORKFLOW.md b/WORKFLOW.md index 9c00006..5429e04 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -407,13 +407,8 @@ providers: networkAccess: true claude: command: claude - model: $KAIRASTRA_CLAUDE_MODEL - reasoning_effort: $KAIRASTRA_CLAUDE_REASONING_EFFORT - approval_policy: never gemini: command: gemini - model: $KAIRASTRA_GEMINI_MODEL - approval_mode: $KAIRASTRA_GEMINI_APPROVAL_MODE --- You are working on GitHub issue `{{ issue.identifier }}`. diff --git a/docs/workflow-reference.md b/docs/workflow-reference.md index 213cc87..4f57f34 100644 --- a/docs/workflow-reference.md +++ b/docs/workflow-reference.md @@ -7,6 +7,17 @@ The repo-root `WORKFLOW.md` is the canonical template shipped with Kairastra. `k generates repo-root `WORKFLOW.md` by combining setup-specific front matter with the canonical prompt body from that root template. +Provider runtime defaults are loaded from `.kairastra/provider-settings.json` when that file is +present. `WORKFLOW.md` remains the orchestration source of truth, and any explicit provider values +in workflow front matter override the local JSON defaults. + +The generated provider settings file is intentionally compact. It carries the operator-facing +defaults that materially affect day-to-day runs: + +- `codex`: `model`, `reasoning_effort`, `collaboration_mode`, `fast` +- `claude`: `model`, `reasoning_effort`, `permission_mode` +- `gemini`: `model`, `approval_mode`, `sandbox` + ## Structure `WORKFLOW.md` is split into: diff --git a/rust/Dockerfile b/rust/Dockerfile index 5c0bc4c..16bf585 100644 --- a/rust/Dockerfile +++ b/rust/Dockerfile @@ -5,6 +5,9 @@ COPY rust/Cargo.toml ./Cargo.toml COPY rust/Cargo.lock ./Cargo.lock COPY rust/src ./src COPY rust/README.md ./README.md +COPY WORKFLOW.md ../WORKFLOW.md +COPY .github/pull_request_template.md ../.github/pull_request_template.md +COPY .agents/skills ../.agents/skills RUN cargo build --locked --release @@ -44,6 +47,10 @@ RUN apt-get update \ && rm -f /tmp/claude \ && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /home/kairastra/.gemini \ + && printf '{"projects":{}}\n' > /home/kairastra/.gemini/projects.json \ + && chown -R kairastra:kairastra /home/kairastra/.gemini + COPY --from=builder /src/rust/target/release/kairastra /usr/local/bin/kairastra USER kairastra diff --git a/rust/src/agent/events.rs b/rust/src/agent/events.rs index 41c0080..d68d31a 100644 --- a/rust/src/agent/events.rs +++ b/rust/src/agent/events.rs @@ -23,6 +23,7 @@ pub enum AgentEventKind { ToolCallCompleted, ToolCallFailed, UnsupportedToolCall, + UserInputRequested, ToolInputAutoAnswered, Malformed, OtherMessage, diff --git a/rust/src/doctor.rs b/rust/src/doctor.rs index fd5f3b7..9ccd47d 100644 --- a/rust/src/doctor.rs +++ b/rust/src/doctor.rs @@ -16,7 +16,10 @@ use crate::github_bootstrap::{ }; use crate::providers; use crate::shared_skills; -use crate::workflow::{default_env_file_path, default_workflow_path, load_definition}; +use crate::workflow::{ + default_env_file_path, default_workflow_path, load_definition, + provider_settings_path_for_workflow, +}; #[derive(Debug, Clone, Copy, ValueEnum)] pub enum DoctorFormat { @@ -102,6 +105,11 @@ pub async fn run(options: DoctorOptions) -> Result { status: DoctorStatus::Warn, detail: "workflow not loaded".to_string(), }; + let mut provider_settings_check = DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Warn, + detail: "workflow not loaded".to_string(), + }; let mut provider_auth_check = DoctorCheck { name: "agent_provider_auth", status: DoctorStatus::Warn, @@ -143,6 +151,7 @@ pub async fn run(options: DoctorOptions) -> Result { status: DoctorStatus::Pass, detail: format!("loaded {}", path.display()), }); + provider_settings_check = check_provider_settings_defaults(path); provider_command_check = check_named_command( "agent_provider_command", provider_command_name(&settings)?, @@ -181,6 +190,7 @@ pub async fn run(options: DoctorOptions) -> Result { }); } + checks.push(provider_settings_check); checks.push(provider_command_check); checks.push(provider_auth_check); checks.push(provider_runtime_check); @@ -256,6 +266,105 @@ fn check_named_command(check_name: &'static str, command_name: &'static str) -> } } +fn check_provider_settings_defaults(workflow_path: &Path) -> DoctorCheck { + let path = provider_settings_path_for_workflow(workflow_path); + if !path.is_file() { + return DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Pass, + detail: format!("provider settings file not present at {}", path.display()), + }; + } + + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) => { + return DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Warn, + detail: format!("could not read {}: {}", path.display(), error), + }; + } + }; + let value: serde_json::Value = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(error) => { + return DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Warn, + detail: format!( + "could not parse provider settings file {}: {}", + path.display(), + error + ), + }; + } + }; + let Some(providers) = value.as_object() else { + return DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Warn, + detail: format!( + "provider settings file {} must contain a top-level JSON object", + path.display() + ), + }; + }; + + let expected = [ + ( + "codex", + ["model", "reasoning_effort", "collaboration_mode", "fast"].as_slice(), + ), + ( + "claude", + ["model", "reasoning_effort", "permission_mode"].as_slice(), + ), + ("gemini", ["model", "approval_mode", "sandbox"].as_slice()), + ]; + let mut missing = Vec::new(); + + for (provider, keys) in expected { + match providers + .get(provider) + .and_then(serde_json::Value::as_object) + { + Some(object) => { + let missing_keys = keys + .iter() + .copied() + .filter(|key| !object.contains_key(*key)) + .collect::>(); + if !missing_keys.is_empty() { + missing.push(format!("{provider}: {}", missing_keys.join(", "))); + } + } + None => missing.push(format!("{provider}: missing provider object")), + } + } + + if missing.is_empty() { + DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Pass, + detail: format!( + "recommended compact provider defaults present in {}", + path.display() + ), + } + } else { + DoctorCheck { + name: "provider_settings_defaults", + status: DoctorStatus::Warn, + detail: format!( + "{} is missing recommended compact defaults for {}; run `krstr setup --reconfigure` to refresh it", + path.display(), + missing.join("; ") + ), + } + } +} + fn provider_command_name(settings: &Settings) -> Result<&'static str> { providers::command_name(settings.agent.provider.as_str()) } @@ -1008,12 +1117,13 @@ fn print_text_report(report: &DoctorReport) { #[cfg(test)] mod tests { use super::{ - check_auth_status, check_seed_repo_git, check_seed_repo_skills, - check_seed_repo_support_dirs, check_workspace_root, infer_mode, DoctorStatus, + check_auth_status, check_provider_settings_defaults, check_seed_repo_git, + check_seed_repo_skills, check_seed_repo_support_dirs, check_workspace_root, infer_mode, + DoctorStatus, }; use crate::config::Settings; use crate::deploy::DeployMode; - use crate::workflow::load_definition; + use crate::workflow::{load_definition, provider_settings_path_for_workflow}; use std::ffi::OsString; use std::fs; use std::path::Path; @@ -1206,6 +1316,12 @@ providers: workflow_path } + fn write_provider_settings(workflow_path: &Path, body: &str) { + let settings_path = provider_settings_path_for_workflow(workflow_path); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write(settings_path, body).unwrap(); + } + #[test] fn infer_mode_defaults_to_native() { std::env::set_var("KAIRASTRA_DEPLOY_MODE", "native"); @@ -1369,4 +1485,64 @@ providers: assert!(check.detail.contains("auth_problem=expired_oauth_token")); assert!(check.detail.contains("credentials_usable=false")); } + + #[test] + fn provider_settings_defaults_warn_when_compact_keys_are_missing() { + let dir = tempdir().unwrap(); + let workflow_path = write_minimal_workflow(dir.path()); + write_provider_settings( + &workflow_path, + r#"{ + "codex": { + "model": "gpt-5.4" + }, + "claude": { + "model": "sonnet" + } +}"#, + ); + + let check = check_provider_settings_defaults(&workflow_path); + assert_eq!(check.status, DoctorStatus::Warn); + assert!(check + .detail + .contains("codex: reasoning_effort, collaboration_mode, fast")); + assert!(check + .detail + .contains("claude: reasoning_effort, permission_mode")); + assert!(check.detail.contains("gemini: missing provider object")); + } + + #[test] + fn provider_settings_defaults_pass_when_compact_defaults_are_present() { + let dir = tempdir().unwrap(); + let workflow_path = write_minimal_workflow(dir.path()); + write_provider_settings( + &workflow_path, + r#"{ + "codex": { + "model": "gpt-5.4", + "reasoning_effort": "medium", + "collaboration_mode": "default", + "fast": true + }, + "claude": { + "model": "sonnet", + "reasoning_effort": "medium", + "permission_mode": "bypassPermissions" + }, + "gemini": { + "model": "gemini-2.5-pro", + "approval_mode": "yolo", + "sandbox": true + } +}"#, + ); + + let check = check_provider_settings_defaults(&workflow_path); + assert_eq!(check.status, DoctorStatus::Pass); + assert!(check + .detail + .contains("recommended compact provider defaults present")); + } } diff --git a/rust/src/github.rs b/rust/src/github.rs index 3aa3214..1949983 100644 --- a/rust/src/github.rs +++ b/rust/src/github.rs @@ -20,6 +20,7 @@ use crate::providers::is_workpad_comment; const GITHUB_READ_MAX_ATTEMPTS: u32 = 3; const GITHUB_READ_RETRY_BASE_DELAY_MS: u64 = 250; +const GITHUB_PAGE_SIZE: usize = 100; #[async_trait] pub trait Tracker: Send + Sync { @@ -79,20 +80,6 @@ impl GitHubTracker { fn issue_matches_repo_scope(&self, owner: &str, repo: &str) -> bool { match self.settings.repo.as_deref() { - Some(configured_repo) if self.settings.mode == GitHubMode::ProjectsV2 => { - if self.settings.project_owner.is_none() - && project_owner_from_url(self.settings.project_url.as_deref()) - .map(|project_owner| { - project_owner.eq_ignore_ascii_case(&self.settings.owner) - }) - .unwrap_or(true) - { - configured_repo.eq_ignore_ascii_case(repo) - } else { - self.settings.owner.eq_ignore_ascii_case(owner) - && configured_repo.eq_ignore_ascii_case(repo) - } - } Some(configured_repo) => { self.settings.owner.eq_ignore_ascii_case(owner) && configured_repo.eq_ignore_ascii_case(repo) @@ -654,6 +641,13 @@ mutation KairastraUpdateProjectItemStatus( self.transition_issue_project_status(issue, target).await } + pub async fn transition_issue_to_in_progress(&self, issue: &Issue) -> Result { + let Some(target) = self.settings.in_progress_state.as_deref() else { + return Ok(issue.clone()); + }; + self.transition_issue_project_status(issue, target).await + } + pub async fn transition_closed_issue_to_done(&self, issue: &Issue) -> Result { let Some(target) = self.settings.done_state.as_deref() else { return Ok(issue.clone()); @@ -710,13 +704,24 @@ mutation KairastraUpdateProjectItemStatus( repo: &str, head_sha: &str, ) -> Result { - let check_runs_path = - format!("/repos/{owner}/{repo}/commits/{head_sha}/check-runs?per_page=100"); - let check_runs_response = self - .rest_json(reqwest::Method::GET, &check_runs_path, None) - .await?; - let check_runs: RestCheckRunsResponse = serde_json::from_value(check_runs_response) - .context("invalid GitHub check-runs payload")?; + let mut check_runs = Vec::new(); + let mut page = 1_u32; + loop { + let check_runs_path = format!( + "/repos/{owner}/{repo}/commits/{head_sha}/check-runs?per_page={GITHUB_PAGE_SIZE}&page={page}" + ); + let check_runs_response = self + .rest_json(reqwest::Method::GET, &check_runs_path, None) + .await?; + let mut parsed: RestCheckRunsResponse = serde_json::from_value(check_runs_response) + .context("invalid GitHub check-runs payload")?; + let page_len = parsed.check_runs.len(); + check_runs.append(&mut parsed.check_runs); + if page_len < GITHUB_PAGE_SIZE { + break; + } + page += 1; + } let statuses_path = format!("/repos/{owner}/{repo}/commits/{head_sha}/status"); let statuses_response = self @@ -725,11 +730,11 @@ mutation KairastraUpdateProjectItemStatus( let statuses: RestCombinedStatus = serde_json::from_value(statuses_response) .context("invalid GitHub commit status payload")?; - let had_checks = !(check_runs.check_runs.is_empty() && statuses.statuses.is_empty()); + let had_checks = !(check_runs.is_empty() && statuses.statuses.is_empty()); let mut failing = Vec::new(); let mut pending = Vec::new(); - for check in check_runs.check_runs { + for check in check_runs { match check.status.as_str() { "completed" => match check.conclusion.as_deref() { Some("success") | Some("neutral") | Some("skipped") => {} @@ -776,9 +781,21 @@ mutation KairastraUpdateProjectItemStatus( pub async fn list_issue_comments(&self, issue: &Issue) -> Result> { let (owner, repo, issue_number) = issue_locator(issue)?; - let path = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments?per_page=100"); - let response = self.rest_json(reqwest::Method::GET, &path, None).await?; - serde_json::from_value(response).context("invalid GitHub issue comments payload") + self.list_all_issue_comments(&owner, &repo, issue_number) + .await + } + + pub async fn create_issue_comment( + &self, + issue: &Issue, + body: &str, + ) -> Result { + let (owner, repo, issue_number) = issue_locator(issue)?; + let path = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments"); + let response = self + .rest_json(reqwest::Method::POST, &path, Some(json!({ "body": body }))) + .await?; + serde_json::from_value(response).context("invalid GitHub created issue comment payload") } fn parse_issue_comments(response: JsonValue) -> Result> { @@ -790,9 +807,9 @@ mutation KairastraUpdateProjectItemStatus( pub async fn ensure_workpad_comment(&self, issue: &Issue, body: &str) -> Result { let (owner, repo, issue_number) = issue_locator(issue)?; - let path = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments?per_page=100"); - let response = self.rest_json(reqwest::Method::GET, &path, None).await?; - let comments = Self::parse_issue_comments(response)?; + let comments = self + .list_all_issue_comments(&owner, &repo, issue_number) + .await?; if let Some(comment) = comments .into_iter() @@ -810,12 +827,7 @@ mutation KairastraUpdateProjectItemStatus( return self.patch_workpad_comment(issue, comment.id, body).await; } - let path = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments"); - let response = self - .rest_json(reqwest::Method::POST, &path, Some(json!({ "body": body }))) - .await?; - let comment: RestIssueComment = serde_json::from_value(response) - .context("invalid GitHub created issue comment payload")?; + let comment = self.create_issue_comment(issue, body).await?; let mut updated = issue.clone(); updated.workpad_comment_id = Some(comment.id); @@ -847,9 +859,9 @@ mutation KairastraUpdateProjectItemStatus( pub async fn refresh_workpad_comment(&self, issue: &Issue) -> Result { let (owner, repo, issue_number) = issue_locator(issue)?; - let path = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments?per_page=100"); - let response = self.rest_json(reqwest::Method::GET, &path, None).await?; - let comments = Self::parse_issue_comments(response)?; + let comments = self + .list_all_issue_comments(&owner, &repo, issue_number) + .await?; let mut updated = issue.clone(); if let Some(comment) = comments @@ -865,6 +877,30 @@ mutation KairastraUpdateProjectItemStatus( Ok(updated) } + async fn list_all_issue_comments( + &self, + owner: &str, + repo: &str, + issue_number: u64, + ) -> Result> { + let mut page = 1_u32; + let mut comments = Vec::new(); + loop { + let path = format!( + "/repos/{owner}/{repo}/issues/{issue_number}/comments?per_page={GITHUB_PAGE_SIZE}&page={page}" + ); + let response = self.rest_json(reqwest::Method::GET, &path, None).await?; + let mut page_comments = Self::parse_issue_comments(response)?; + let page_len = page_comments.len(); + comments.append(&mut page_comments); + if page_len < GITHUB_PAGE_SIZE { + break; + } + page += 1; + } + Ok(comments) + } + async fn list_repo_issues(&self, state: &str) -> Result> { let repo = self .settings @@ -1795,9 +1831,18 @@ struct RestLabel { #[derive(Debug, Clone, Deserialize)] pub struct RestIssueComment { - id: u64, + pub id: u64, pub body: String, pub html_url: Option, + pub user: Option, + pub author_association: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RestIssueCommentUser { + pub login: Option, } #[derive(Debug, Clone)] @@ -2417,6 +2462,28 @@ mod tests { "owner": { "login": "openai" } } } + }, + { + "id": "item-3", + "status": { "__typename": "ProjectV2ItemFieldSingleSelectValue", "name": "Todo" }, + "priority": null, + "content": { + "__typename": "Issue", + "id": "issue-node-3", + "number": 77, + "title": "Skip same repo name from another owner", + "body": "body", + "url": "https://github.com/not-openai/kairastra/issues/77", + "state": "OPEN", + "createdAt": "2026-03-13T00:00:00Z", + "updatedAt": "2026-03-13T01:00:00Z", + "assignees": { "nodes": [] }, + "labels": { "nodes": [] }, + "repository": { + "name": "kairastra", + "owner": { "login": "not-openai" } + } + } } ] } @@ -3467,6 +3534,81 @@ mod tests { assert!(!summary.allows_review_handoff()); } + #[tokio::test] + async fn pull_request_checks_summary_paginates_check_runs() { + let server = MockServer::start().await; + let page_one_checks = (0..100) + .map(|index| { + serde_json::json!({ + "name": format!("CI {index}"), + "status": "completed", + "conclusion": "success" + }) + }) + .collect::>(); + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/commits/ghi789/check-runs")) + .and(query_param("per_page", "100")) + .and(query_param("page", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "check_runs": page_one_checks + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/commits/ghi789/check-runs")) + .and(query_param("per_page", "100")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "check_runs": [ + { + "name": "Late CI", + "status": "completed", + "conclusion": "failure" + } + ] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/commits/ghi789/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "statuses": [] + }))) + .mount(&server) + .await; + + let tracker = GitHubTracker::new( + settings(&format!( + r#"tracker: + kind: github + owner: openai + repo: kairastra + api_key: fake + endpoint: {0}/graphql + rest_endpoint: {0} + mode: issues_only + status_source: + type: label +"#, + server.uri() + )) + .tracker, + ) + .unwrap(); + + let summary = tracker + .pull_request_checks_summary("openai", "kairastra", "ghi789") + .await + .unwrap(); + + assert_eq!(summary.state, PullRequestChecksState::Failing); + assert_eq!(summary.failing, vec!["Late CI".to_string()]); + } + #[tokio::test] async fn closed_issue_transition_updates_project_status_to_done() { let server = MockServer::start().await; @@ -3755,6 +3897,184 @@ mod tests { ); } + #[tokio::test] + async fn list_issue_comments_paginates_all_pages() { + let server = MockServer::start().await; + let page_one_comments = (0..100) + .map(|index| { + serde_json::json!({ + "id": index + 1, + "body": format!("noise {index}"), + "html_url": format!("https://github.com/openai/kairastra/issues/42#issuecomment-{}", index + 1) + }) + }) + .collect::>(); + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/issues/42/comments")) + .and(query_param("per_page", "100")) + .and(query_param("page", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(page_one_comments)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/issues/42/comments")) + .and(query_param("per_page", "100")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 101, + "body": "last", + "html_url": "https://github.com/openai/kairastra/issues/42#issuecomment-101" + } + ]))) + .mount(&server) + .await; + + let tracker = GitHubTracker::new( + settings(&format!( + r#"tracker: + kind: github + owner: openai + api_key: fake + endpoint: {0}/graphql + rest_endpoint: {0} + repo: kairastra + mode: issues_only + status_source: + type: label +"#, + server.uri() + )) + .tracker, + ) + .unwrap(); + + let issue = Issue { + id: "issue-node-42".to_string(), + project_item_id: None, + identifier: "openai/kairastra#42".to_string(), + title: "Issue".to_string(), + description: None, + priority: None, + state: "Todo".to_string(), + branch_name: None, + url: Some("https://github.com/openai/kairastra/issues/42".to_string()), + assignees: Vec::new(), + labels: Vec::new(), + blocked_by: Vec::new(), + created_at: None, + updated_at: None, + workpad_comment_id: None, + workpad_comment_url: None, + workpad_comment_body: None, + }; + + let comments = tracker.list_issue_comments(&issue).await.unwrap(); + + assert_eq!(comments.len(), 101); + assert_eq!(comments.last().map(|comment| comment.id), Some(101)); + } + + #[tokio::test] + async fn ensure_workpad_comment_finds_existing_comment_on_later_page() { + let server = MockServer::start().await; + let page_one_comments = (0..100) + .map(|index| { + serde_json::json!({ + "id": index + 1, + "body": format!("noise {index}"), + "html_url": format!("https://github.com/openai/kairastra/issues/42#issuecomment-{}", index + 1) + }) + }) + .collect::>(); + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/issues/42/comments")) + .and(query_param("per_page", "100")) + .and(query_param("page", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(page_one_comments)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/repos/openai/kairastra/issues/42/comments")) + .and(query_param("per_page", "100")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 101, + "body": "## Agent Workpad\n\nold", + "html_url": "https://github.com/openai/kairastra/issues/42#issuecomment-101" + } + ]))) + .mount(&server) + .await; + + Mock::given(method("PATCH")) + .and(path("/repos/openai/kairastra/issues/comments/101")) + .and(body_string_contains("## Agent Workpad")) + .and(body_string_contains("new")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 101, + "body": "## Agent Workpad\n\nnew", + "html_url": "https://github.com/openai/kairastra/issues/42#issuecomment-101" + }))) + .mount(&server) + .await; + + let tracker = GitHubTracker::new( + settings(&format!( + r#"tracker: + kind: github + owner: openai + api_key: fake + endpoint: {0}/graphql + rest_endpoint: {0} + repo: kairastra + mode: issues_only + status_source: + type: label +"#, + server.uri() + )) + .tracker, + ) + .unwrap(); + + let issue = Issue { + id: "issue-node-42".to_string(), + project_item_id: None, + identifier: "openai/kairastra#42".to_string(), + title: "Issue".to_string(), + description: None, + priority: None, + state: "Todo".to_string(), + branch_name: None, + url: Some("https://github.com/openai/kairastra/issues/42".to_string()), + assignees: Vec::new(), + labels: Vec::new(), + blocked_by: Vec::new(), + created_at: None, + updated_at: None, + workpad_comment_id: None, + workpad_comment_url: None, + workpad_comment_body: None, + }; + + let updated = tracker + .ensure_workpad_comment(&issue, "## Agent Workpad\n\nnew") + .await + .unwrap(); + + assert_eq!(updated.workpad_comment_id, Some(101)); + assert_eq!( + updated.workpad_comment_body.as_deref(), + Some("## Agent Workpad\n\nnew") + ); + } + #[tokio::test] async fn update_workpad_comment_patches_discovered_comment_when_id_missing() { let server = MockServer::start().await; diff --git a/rust/src/github_bootstrap.rs b/rust/src/github_bootstrap.rs index 1d7f562..872d3fa 100644 --- a/rust/src/github_bootstrap.rs +++ b/rust/src/github_bootstrap.rs @@ -647,6 +647,11 @@ fn fixed_label_specs() -> Vec { color: "0e8a16", description: "Assigned to Gemini-driven automation.".to_string(), }, + RepoLabelSpec { + name: "mode:plan".to_string(), + color: "fbca04", + description: "Forces Codex issue turns to start in planning mode.".to_string(), + }, ] } @@ -841,6 +846,7 @@ mod tests { let names = specs.into_iter().map(|spec| spec.name).collect::>(); assert!(names.contains(&"kairastra".to_string())); assert!(names.contains(&"agent:codex".to_string())); + assert!(names.contains(&"mode:plan".to_string())); assert!(names.contains(&"Human Review".to_string())); assert!(!names.contains(&"needs-review".to_string())); } diff --git a/rust/src/model.rs b/rust/src/model.rs index da4dc98..076d693 100644 --- a/rust/src/model.rs +++ b/rust/src/model.rs @@ -40,3 +40,92 @@ pub struct WorkflowDefinition { pub config: serde_yaml::Value, pub prompt_template: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HumanInputOption { + pub label: String, + pub description: Option, + #[serde(default, alias = "isOther")] + pub is_other: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HumanInputQuestion { + pub id: String, + pub header: Option, + pub question: String, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HumanInputAnswer { + pub question_id: String, + pub selected_option: Option, + pub other_text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PendingHumanInputStatus { + AwaitingAnswer, + Answered, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HumanInputRequest { + pub request_id: String, + pub provider: String, + pub issue_id: String, + pub issue_identifier: String, + pub session_id: Option, + pub requested_at: DateTime, + pub questions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingHumanInput { + pub request: HumanInputRequest, + pub status: PendingHumanInputStatus, + pub prompt_comment_id: Option, + pub prompt_comment_url: Option, + pub answered_comment_id: Option, + pub answered_by_login: Option, + pub answered_at: Option>, + pub answers: Vec, +} + +#[cfg(test)] +mod tests { + use super::{HumanInputRequest, PendingHumanInputStatus}; + + #[test] + fn human_input_option_accepts_provider_camel_case_other_flag() { + let request: HumanInputRequest = serde_json::from_value(serde_json::json!({ + "request_id": "input-123", + "provider": "codex", + "issue_id": "issue-1", + "issue_identifier": "openai/kairastra#1", + "session_id": "thread-1-turn-1", + "requested_at": "2026-06-01T00:00:00Z", + "questions": [{ + "id": "scope", + "header": "Scope", + "question": "Pick a scope", + "options": [{ + "label": "Other", + "description": "Free text", + "isOther": true + }] + }] + })) + .expect("provider payload should deserialize"); + + assert!(request.questions[0].options[0].is_other); + } + + #[test] + fn pending_human_input_status_keeps_snake_case_serialization() { + let encoded = serde_json::to_string(&PendingHumanInputStatus::AwaitingAnswer).unwrap(); + assert_eq!(encoded, "\"awaiting_answer\""); + } +} diff --git a/rust/src/orchestrator.rs b/rust/src/orchestrator.rs index a3051b5..2bfd35a 100644 --- a/rust/src/orchestrator.rs +++ b/rust/src/orchestrator.rs @@ -14,8 +14,10 @@ use tracing::{debug, error, info, trace, warn}; use crate::agent::{AgentEvent, AgentEventKind}; use crate::config::{normalize_issue_state, ProviderId, Settings}; -use crate::github::{is_rate_limited_error, GitHubTracker, Tracker}; -use crate::model::Issue; +use crate::github::{is_rate_limited_error, GitHubTracker, RestIssueComment, Tracker}; +use crate::model::{ + HumanInputAnswer, HumanInputRequest, Issue, PendingHumanInput, PendingHumanInputStatus, +}; use crate::providers; use crate::runner::{run_issue, WorkerMessage, WorkerOutcome}; use crate::workflow::{WorkflowSnapshot, WorkflowStore}; @@ -68,6 +70,7 @@ struct RunningEntry { workspace_path: Option, started_at: Instant, last_agent_timestamp: Instant, + stall_timeout_ms: u64, session_id: Option, agent_process_pid: Option, last_agent_event: Option, @@ -79,6 +82,7 @@ struct RunningEntry { last_reported_output_tokens: u64, last_reported_total_tokens: u64, turn_count: usize, + pending_human_input_request: Option, attempt: Option, handle: JoinHandle<()>, } @@ -221,6 +225,9 @@ impl Orchestrator { .await?; let issues = self.tracker.fetch_candidate_issues().await?; + let issues = self + .reconcile_pending_human_inputs(snapshot, state, issues) + .await?; debug!( fetched = issues.len(), running = state.running.len(), @@ -399,8 +406,6 @@ impl Orchestrator { .into_iter() .map(|issue| (issue.id.clone(), issue)) .collect(); - let stall_timeout_ms = providers::stall_timeout_ms(&snapshot.settings)?; - let mut remove_ids = Vec::new(); let mut retries = Vec::new(); let running_ids: Vec = state.running.keys().cloned().collect(); @@ -409,8 +414,9 @@ impl Orchestrator { continue; }; - if stall_timeout_ms > 0 - && running.last_agent_timestamp.elapsed() >= Duration::from_millis(stall_timeout_ms) + if running.stall_timeout_ms > 0 + && running.last_agent_timestamp.elapsed() + >= Duration::from_millis(running.stall_timeout_ms) { running.handle.abort(); state.claimed.remove(&issue_id); @@ -539,6 +545,7 @@ impl Orchestrator { }; let provider_name = provider.as_str().to_string(); snapshot.settings.agent.provider = provider; + let stall_timeout_ms = providers::stall_timeout_ms(&snapshot.settings)?; state.claimed.insert(issue.id.clone()); let issue_id = issue.id.clone(); @@ -550,22 +557,22 @@ impl Orchestrator { let issue_for_task = issue.clone(); let handle = tokio::spawn(async move { - let result = run_issue( + let completion = run_issue( snapshot, tracker, issue_for_task.clone(), attempt, tx.clone(), ) - .await - .map_err(|error| format!("{error:#}")); + .await; let _ = tx.send(WorkerMessage::Finished { issue_id: issue_for_task.id.clone(), identifier: issue_for_task.identifier.clone(), workspace_path: workspace_hint_for_task.unwrap_or_default(), attempt, - result, + result: completion.outcome, + human_input_request: completion.human_input_request, }); }); @@ -580,6 +587,7 @@ impl Orchestrator { workspace_path: workspace_hint, started_at, last_agent_timestamp: started_at, + stall_timeout_ms, session_id: None, agent_process_pid: None, last_agent_event: None, @@ -591,6 +599,7 @@ impl Orchestrator { last_reported_output_tokens: 0, last_reported_total_tokens: 0, turn_count: 0, + pending_human_input_request: None, attempt, handle, }, @@ -716,6 +725,10 @@ impl Orchestrator { if let Some(usage) = extract_token_usage(&event.payload) { usage_delta = Some(apply_token_usage(running, usage)); } + if let AgentEventKind::UserInputRequested = event.event { + running.pending_human_input_request = + serde_json::from_value(event.payload.clone()).ok(); + } rate_limits = extract_rate_limits(&event.payload); log_provider_event(&running.provider, &running.identifier, &event); if let AgentEventKind::SessionStarted = event.event { @@ -746,6 +759,7 @@ impl Orchestrator { workspace_path, attempt, result, + human_input_request, .. } => { let running = state.running.remove(&issue_id); @@ -759,6 +773,18 @@ impl Orchestrator { let session_id = running .as_ref() .and_then(|entry| entry.session_id.as_deref()); + let pending_human_input_request = resolve_human_input_request( + human_input_request, + running + .as_ref() + .and_then(|entry| entry.pending_human_input_request.clone()), + ); + let persisted_workspace_path = finished_workspace_path( + running + .as_ref() + .and_then(|entry| entry.workspace_path.as_deref()), + workspace_path.as_path(), + ); match result { Ok(WorkerOutcome::Completed) => { match self @@ -791,7 +817,40 @@ impl Orchestrator { } Err(error) => { error!(issue_identifier = %identifier, error, "worker failed"); - if let Some(blocked) = + if error.contains("waiting_for_user_input") { + match pending_human_input_request { + Some(request) => { + match self + .persist_human_input_request( + snapshot, + &issue_id, + persisted_workspace_path, + request, + ) + .await + { + Ok(issue) => hold_blocked_claim(state, &issue), + Err(annotation_error) => { + clear_blocked_claim(state, &issue_id); + log_runtime_error( + "human input request annotation", + &annotation_error, + ); + } + } + } + None => { + clear_blocked_claim(state, &issue_id); + log_runtime_error( + "human input request annotation", + &anyhow::anyhow!( + "waiting_for_user_input without request payload (provider={provider}, session={}, issue_identifier={identifier})", + session_id.unwrap_or("unavailable"), + ), + ); + } + } + } else if let Some(blocked) = classify_blocked_worker_failure(&snapshot.settings, provider, &error) { match self @@ -892,6 +951,154 @@ impl Orchestrator { .await } + async fn persist_human_input_request( + &self, + snapshot: &WorkflowSnapshot, + issue_id: &str, + workspace_path: &Path, + request: HumanInputRequest, + ) -> Result { + let mut refreshed = self + .tracker + .fetch_issue_states_by_ids(&[issue_id.to_string()]) + .await?; + let Some(mut issue) = refreshed.pop() else { + return Err(anyhow::anyhow!( + "human input issue disappeared before annotation" + )); + }; + + let prompt_comment = self + .tracker + .create_issue_comment(&issue, &render_human_input_prompt_comment(&request)) + .await?; + + let pending = PendingHumanInput { + request, + status: PendingHumanInputStatus::AwaitingAnswer, + prompt_comment_id: Some(prompt_comment.id), + prompt_comment_url: prompt_comment.html_url.clone(), + answered_comment_id: None, + answered_by_login: None, + answered_at: None, + answers: Vec::new(), + }; + workspace::write_pending_human_input(workspace_path, &pending)?; + + if issue.workpad_comment_id.is_some() { + issue = self.tracker.refresh_workpad_comment(&issue).await?; + } + let base_body = issue.workpad_comment_body.clone().unwrap_or_else(|| { + render_blocked_failure_workpad( + pending.request.provider.as_str(), + Some(workspace_path), + &issue, + ) + }); + let blocker_section = render_blocker_section( + pending.request.provider.as_str(), + None, + pending.request.session_id.as_deref(), + &format!("waiting_for_user_input: {}", pending.request.request_id), + "Reply to the pending Kairastra GitHub comment with the requested answers, then move the issue back to an active state if it does not transition automatically.", + ); + let merged_body = merge_blocker_section(&base_body, &blocker_section); + issue = self + .tracker + .update_workpad_comment(&issue, &merged_body) + .await?; + + if snapshot.settings.active_state(&issue.state) + && !snapshot.settings.terminal_state(&issue.state) + { + issue = self + .tracker + .transition_issue_to_human_review(&issue) + .await?; + } + + warn!( + issue_identifier = %issue.identifier, + request_id = %pending.request.request_id, + state = %issue.state, + "issue waiting for human input; posted GitHub prompt comment" + ); + Ok(issue) + } + + async fn reconcile_pending_human_inputs( + &self, + snapshot: &WorkflowSnapshot, + state: &mut RuntimeState, + issues: Vec, + ) -> Result> { + let mut reconciled = Vec::with_capacity(issues.len()); + + for mut issue in issues { + let workspace_path = + match workspace::workspace_path(&snapshot.settings, &issue.identifier) { + Ok(path) => path, + Err(_) => { + reconciled.push(issue); + continue; + } + }; + + let Some(mut pending) = workspace::load_pending_human_input(&workspace_path)? else { + reconciled.push(issue); + continue; + }; + + match pending.status { + PendingHumanInputStatus::AwaitingAnswer => { + if pending.prompt_comment_id.is_none() { + let prompt_comment = self + .tracker + .create_issue_comment( + &issue, + &render_human_input_prompt_comment(&pending.request), + ) + .await?; + pending.prompt_comment_id = Some(prompt_comment.id); + pending.prompt_comment_url = prompt_comment.html_url.clone(); + workspace::write_pending_human_input(&workspace_path, &pending)?; + } + + let comments = self.tracker.list_issue_comments(&issue).await?; + if let Some((comment, answers)) = + find_human_input_answer(&pending.request, &comments) + { + pending.status = PendingHumanInputStatus::Answered; + pending.answered_comment_id = Some(comment.id); + pending.answered_by_login = + comment.user.as_ref().and_then(|user| user.login.clone()); + pending.answered_at = comment.updated_at.or(comment.created_at); + pending.answers = answers; + workspace::write_pending_human_input(&workspace_path, &pending)?; + let _ = self + .tracker + .create_issue_comment(&issue, &render_human_input_ack_comment(&pending)) + .await; + } + } + PendingHumanInputStatus::Answered => {} + } + + if pending.status == PendingHumanInputStatus::Answered { + clear_blocked_claim(state, &issue.id); + if !snapshot.settings.terminal_state(&issue.state) { + issue = self.tracker.transition_issue_to_in_progress(&issue).await?; + } + } else { + hold_blocked_claim(state, &issue); + } + + reconciled.push(issue); + } + + Ok(reconciled) + } + async fn persist_issue_blocker( &self, snapshot: &WorkflowSnapshot, @@ -1055,14 +1262,15 @@ fn log_generic_agent_event(provider: &str, issue_identifier: &str, event: &Agent "agent requested unsupported tool" ); } - AgentEventKind::ToolInputAutoAnswered => { + AgentEventKind::UserInputRequested => { info!( issue_identifier = %issue_identifier, provider, payload = %event.payload, - "agent tool input auto-answered" + "agent requested structured user input" ); } + AgentEventKind::ToolInputAutoAnswered => {} AgentEventKind::Malformed => { warn!( issue_identifier = %issue_identifier, @@ -1136,13 +1344,14 @@ fn log_codex_event(issue_identifier: &str, event: &AgentEvent) { "codex emitted malformed stdout payload" ); } - AgentEventKind::ToolInputAutoAnswered => { + AgentEventKind::UserInputRequested => { info!( issue_identifier = %issue_identifier, payload = %event.payload, - "codex tool request user input auto-answered" + "codex requested structured user input" ); } + AgentEventKind::ToolInputAutoAnswered => {} AgentEventKind::Notification => log_codex_notification(issue_identifier, &event.payload), _ => {} } @@ -1163,6 +1372,150 @@ fn log_generic_notification(provider: &str, issue_identifier: &str, payload: &se ); } +fn render_human_input_prompt_comment(request: &HumanInputRequest) -> String { + let mut body = String::new(); + body.push_str("Kairastra is waiting for structured input before it can continue.\n\n"); + body.push_str(&format!("- Request ID: `{}`\n", request.request_id)); + body.push_str("- Reply using this exact template:\n\n"); + body.push_str("```text\n"); + body.push_str(&format!("/kairastra answer {}\n", request.request_id)); + for question in &request.questions { + let placeholder = if question.options.is_empty() { + "" + } else { + "