From 9d7becad1d188d69aac9a5b1cf0960149f11ff31 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:06:42 +0000 Subject: [PATCH 01/13] feat(harness): add unavailable devin_cloud_agent harness kind Adds the Devin cloud agent as a fourth capability-discoverable harness kind, advertised as unavailable with explicit feature gaps, plus the internal opensymphony-devin adapter, client primitives, and event normalization. Co-Authored-By: Leonardo Gonzalez --- AGENTS.md | 13 +- apps/desktop/src-tauri/src/commands.rs | 2 + .../src/orchestrator_run/backends.rs | 42 ++ crates/opensymphony-devin/src/lib.rs | 640 ++++++++++++++++++ .../src/capability.rs | 88 ++- .../tests/gateway_schema.rs | 43 +- crates/opensymphony-gateway/src/lib.rs | 1 + crates/opensymphony-gateway/tests/gateway.rs | 11 + crates/opensymphony-workflow/src/lib.rs | 118 ++++ crates/opensymphony-workflow/src/model.rs | 38 ++ crates/opensymphony-workflow/src/resolve.rs | 113 +++- docs/harness-adapter-compatibility.md | 38 ++ docs/host-client-architecture.md | 25 +- src/lib.rs | 2 + tests/devin_cloud_agent.rs | 223 ++++++ 15 files changed, 1377 insertions(+), 20 deletions(-) create mode 100644 crates/opensymphony-devin/src/lib.rs create mode 100644 tests/devin_cloud_agent.rs diff --git a/AGENTS.md b/AGENTS.md index bb041ca9c..b9b3b8129 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,8 +156,8 @@ The runtime client must: ### Keep harness capability discovery public - Public harness metadata belongs in `opensymphony-gateway-schema::capability::HarnessCapability` and the `/api/v1/capabilities` response. -- Use stable harness kind strings such as `openhands_agent_server`, `codex_app_server`, and `rust_native`; do not expose private adapter type names to clients. -- Concrete harness adapters should implement the domain `HarnessAdapter` capability boundary and keep OpenHands, Codex, or future in-process protocol details inside their adapter modules. +- Use stable harness kind strings such as `openhands_agent_server`, `codex_app_server`, `devin_cloud_agent`, and `rust_native`; do not expose private adapter type names to clients. +- Concrete harness adapters should implement the domain `HarnessAdapter` capability boundary and keep OpenHands, Codex, Devin, or future in-process protocol details inside their adapter modules. - Future or experimental harnesses may be advertised as unavailable capability entries, but their feature gaps must be explicit. - When changing harness capability discovery, update gateway schema round-trip tests, the gateway capabilities endpoint test, adapter-boundary tests, and `docs/harness-adapter-compatibility.md`. @@ -170,6 +170,15 @@ capability must stay available in normal builds. Do not advertise hosted worker pools, remote routing, or loopback WebSocket as production-ready until those paths have their own hardening evidence. +The remote Devin cloud harness uses the `opensymphony_devin` internal module +boundary and is advertised as an unavailable HTTPS capability with explicit +feature gaps. Devin owns its own remote execution workspace: never treat a local +issue workspace path as Devin's working directory, and keep credentials as +environment-variable references rather than values in workflow config, +manifests, or logs. Do not flip `devin_cloud_agent` to available until hosted +security (TLS, authenticated streams, secret injection, tenant isolation), +remote workspace ownership, and event-normalization hardening evidence exist. + ### Preserve forward compatibility OpenHands event schemas can evolve. Implement: diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 24bc6c189..74e9adf6f 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1542,6 +1542,7 @@ pub async fn gateway_capabilities() -> CommandResult { harnesses: vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), + HarnessCapability::devin_cloud_future(), HarnessCapability::rust_native_future(), ], features: vec![ @@ -1788,6 +1789,7 @@ mod tests { vec![ ("openhands_agent_server", true), ("codex_app_server", true), + ("devin_cloud_agent", false), ("rust_native", false), ] ); diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 88751f74e..875626cae 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -19,6 +19,7 @@ use crate::opensymphony_codex::{ codex_approval_request_from_event, codex_event_summary, normalize_server_notification, turn_status, }; +use crate::opensymphony_devin::{DEVIN_CLOUD_AGENT_KIND, DevinCloudAdapter, DevinCloudConfig}; use crate::opensymphony_domain::{ ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, IssueIdentifier, IssueState, IssueStateCategory, NormalizedIssue, RepositoryBindingOutcome, RetryEntry, @@ -2194,6 +2195,26 @@ impl RuntimeWorkerBackend { let handle = tokio::spawn(async move { let mut launch_tx = Some(launch_tx); let run_id = format!("run-{launch_worker_id}"); + if route.harness_kind == DEVIN_CLOUD_AGENT_KIND { + // Devin executes in a Devin-owned cloud workspace, so the local + // issue workspace is never prepared as an execution cwd for this + // route. Dispatch resolves the adapter first and refuses the run + // while the harness capability is advertised as unavailable. + let detail = devin_route_unavailable_detail(&workflow); + report_launch_failure(&mut launch_tx, detail.clone()); + let outcome = WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("devin cloud routing is not available".to_owned()), + Some(detail), + ); + let _ = updates_tx.send(WorkerUpdate::Finished { + worker_id: finished_worker_id.clone(), + outcome, + }); + return; + } let mut workspace_issue = issue_descriptor(&issue); if recovered && let Some(binding) = run.repository_binding.clone() { workspace_issue.repository_binding = @@ -5496,6 +5517,27 @@ fn transport_port_override(url: &Url) -> Result { }) } +/// Devin adapter dispatch detail. +/// +/// The workflow-resolved endpoint and credential *reference* are echoed so +/// operators can confirm routing configuration; the credential value itself is +/// only ever read from the worker environment by the Devin client. +fn devin_route_unavailable_detail(workflow: &ResolvedWorkflow) -> String { + let api = &workflow.extensions.devin.api; + let adapter = DevinCloudAdapter::new(DevinCloudConfig { + base_url: api.base_url.clone(), + api_key_env: api.api_key_env.clone(), + event_poll_interval: Duration::from_millis(api.event_poll_interval_ms), + request_timeout: Duration::from_millis(api.request_timeout_ms), + }); + format!( + "{} endpoint `{}`, credential env `{}`; the local issue workspace stays evidence-only because Devin owns the remote execution workspace", + adapter.unavailability_reason(), + api.base_url, + api.api_key_env + ) +} + fn report_launch_failure( launch_tx: &mut Option>, detail: impl Into, diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs new file mode 100644 index 000000000..6dd16b527 --- /dev/null +++ b/crates/opensymphony-devin/src/lib.rs @@ -0,0 +1,640 @@ +//! Devin cloud harness helpers. +//! +//! Devin is a remote implementation agent: the agent runtime, the checkout, and +//! the shell all live in a Devin-owned cloud workspace. OpenSymphony therefore +//! keeps its local sanitized issue workspace for manifests, journals, and +//! evidence only, and binds the repository to the remote workspace through +//! [`DevinRemoteWorkspaceBinding`] instead of handing the local checkout to the +//! harness as an execution cwd. +//! +//! The capability advertised by this adapter is intentionally unavailable: +//! remote workspace ownership, event normalization, auth/secret handling, and +//! tenant isolation still need hardening evidence before hosted routing can be +//! presented as production-ready. + +use std::{fmt, path::PathBuf, time::Duration}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use url::Url; + +use crate::{ + opensymphony_domain::HarnessAdapter, + opensymphony_gateway_schema::{ + capability::HarnessCapability, + envelope::EntityRef, + event_journal::{EventActor, EventKind, EventRecord}, + }, +}; + +pub const DEVIN_CLOUD_AGENT_KIND: &str = "devin_cloud_agent"; +pub const DEVIN_CLOUD_API_CONTRACT: &str = "devin-cloud-api-v1"; +pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai/v1"; +pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "DEVIN_API_KEY"; +pub const DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS: u64 = 2_000; +pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; + +/// Effective containment label recorded for runs routed to Devin. +/// +/// The local workspace is never the execution directory for this harness. +pub const DEVIN_REMOTE_CONTAINMENT: &str = "devin_owned_remote_workspace"; + +#[derive(Debug, thiserror::Error)] +pub enum DevinConfigError { + #[error("devin.api.base_url must be an absolute https URL: {0}")] + InvalidBaseUrl(String), + #[error("devin.api.base_url must use the https scheme for remote Devin routing")] + InsecureBaseUrl, + #[error("devin.api.base_url must not embed credentials")] + CredentialsInBaseUrl, + #[error("devin.api.api_key_env must be an environment variable name")] + InvalidApiKeyEnv, + #[error("devin repository binding must be an absolute https git URL: {0}")] + InvalidRepositoryUrl(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum DevinClientError { + #[error("devin api credential `{env}` is not set in the worker environment")] + MissingCredential { env: String }, + #[error("failed to build devin api url from `{base_url}` and `{path}`: {source}")] + InvalidUrl { + base_url: String, + path: String, + #[source] + source: url::ParseError, + }, + #[error("devin api transport error: {0}")] + Transport(String), + #[error("devin api returned status {status}: {body}")] + Status { status: u16, body: String }, + #[error("failed to decode devin api response: {0}")] + Decode(String), +} + +/// API token wrapper that never renders its secret value. +#[derive(Clone, PartialEq, Eq)] +pub struct DevinApiToken(String); + +impl DevinApiToken { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for DevinApiToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DevinApiToken(redacted)") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinCloudConfig { + pub base_url: String, + pub api_key_env: String, + pub event_poll_interval: Duration, + pub request_timeout: Duration, +} + +impl Default for DevinCloudConfig { + fn default() -> Self { + Self { + base_url: DEFAULT_DEVIN_API_BASE_URL.to_owned(), + api_key_env: DEFAULT_DEVIN_API_KEY_ENV.to_owned(), + event_poll_interval: Duration::from_millis(DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS), + request_timeout: Duration::from_millis(DEFAULT_DEVIN_REQUEST_TIMEOUT_MS), + } + } +} + +impl DevinCloudConfig { + pub fn validate(&self) -> Result<(), DevinConfigError> { + let parsed = Url::parse(&self.base_url) + .map_err(|error| DevinConfigError::InvalidBaseUrl(error.to_string()))?; + if parsed.scheme() != "https" { + return Err(DevinConfigError::InsecureBaseUrl); + } + if parsed.host().is_none() { + return Err(DevinConfigError::InvalidBaseUrl("missing host".into())); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(DevinConfigError::CredentialsInBaseUrl); + } + if !is_environment_name(&self.api_key_env) { + return Err(DevinConfigError::InvalidApiKeyEnv); + } + + Ok(()) + } +} + +fn is_environment_name(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + && value + .chars() + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) +} + +/// Repository binding for a Devin-owned remote workspace. +/// +/// `local_evidence_path` is the sanitized OpenSymphony issue workspace. It holds +/// run manifests, journals, and evidence for the run; it is *not* the directory +/// Devin executes in and must never be advertised as such. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinRemoteWorkspaceBinding { + pub issue_workspace_key: String, + pub local_evidence_path: PathBuf, + pub repository_url: String, + pub base_branch: Option, +} + +impl DevinRemoteWorkspaceBinding { + pub fn new( + issue_workspace_key: impl Into, + local_evidence_path: impl Into, + repository_url: impl Into, + base_branch: Option, + ) -> Result { + let repository_url = repository_url.into(); + let parsed = Url::parse(&repository_url) + .map_err(|error| DevinConfigError::InvalidRepositoryUrl(error.to_string()))?; + if parsed.scheme() != "https" { + return Err(DevinConfigError::InvalidRepositoryUrl( + "must use the https scheme".into(), + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(DevinConfigError::InvalidRepositoryUrl( + "must not embed credentials".into(), + )); + } + + Ok(Self { + issue_workspace_key: issue_workspace_key.into(), + local_evidence_path: local_evidence_path.into(), + repository_url, + base_branch, + }) + } + + /// Containment label for the run: Devin owns the execution environment. + pub fn effective_containment(&self) -> &'static str { + DEVIN_REMOTE_CONTAINMENT + } +} + +#[derive(Debug, Clone, Default)] +pub struct DevinCloudAdapter { + config: DevinCloudConfig, +} + +impl DevinCloudAdapter { + pub fn new(config: DevinCloudConfig) -> Self { + Self { config } + } + + pub fn config(&self) -> &DevinCloudConfig { + &self.config + } + + pub fn requests(&self) -> DevinRequestBuilder { + DevinRequestBuilder::new(self.config.base_url.clone()) + } + + /// Feature gaps that block hosted Devin routing today. + pub fn unavailability_reason(&self) -> String { + let capability = self.capabilities(); + format!( + "harness `{}` is advertised as unavailable: {}", + capability.kind, + capability.feature_gaps.join(" ") + ) + } +} + +impl HarnessAdapter for DevinCloudAdapter { + fn harness_kind(&self) -> &'static str { + DEVIN_CLOUD_AGENT_KIND + } + + fn capabilities(&self) -> HarnessCapability { + HarnessCapability::devin_cloud_future() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DevinHttpMethod { + Get, + Post, +} + +impl DevinHttpMethod { + pub fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DevinLifecycleRequest { + SessionCreate, + SessionResume, + MessageSend, + RunStart, + RunCancel, + EventFetch, +} + +/// Transport-agnostic description of a Devin cloud API call. +/// +/// Credentials are injected by [`DevinCloudClient`] at send time so request +/// descriptions stay safe to log, snapshot, and unit test. +#[derive(Debug, Clone, PartialEq)] +pub struct DevinRequest { + pub lifecycle: DevinLifecycleRequest, + pub method: DevinHttpMethod, + pub path: String, + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinRequestBuilder { + base_url: String, +} + +impl DevinRequestBuilder { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn create_session( + &self, + prompt: impl Into, + binding: &DevinRemoteWorkspaceBinding, + idempotency_key: Option, + ) -> DevinRequest { + let mut body = json!({ + "prompt": prompt.into(), + "repository": { + "url": binding.repository_url, + "base_branch": binding.base_branch, + }, + }); + if let Some(key) = idempotency_key + && let Some(object) = body.as_object_mut() + { + object.insert("idempotency_key".into(), Value::String(key)); + } + + DevinRequest { + lifecycle: DevinLifecycleRequest::SessionCreate, + method: DevinHttpMethod::Post, + path: "/sessions".into(), + body: Some(body), + } + } + + pub fn resume_session(&self, session_id: &str) -> DevinRequest { + DevinRequest { + lifecycle: DevinLifecycleRequest::SessionResume, + method: DevinHttpMethod::Post, + path: format!("/sessions/{}/resume", encode_path_segment(session_id)), + body: Some(json!({})), + } + } + + pub fn send_message(&self, session_id: &str, message: impl Into) -> DevinRequest { + DevinRequest { + lifecycle: DevinLifecycleRequest::MessageSend, + method: DevinHttpMethod::Post, + path: format!("/sessions/{}/messages", encode_path_segment(session_id)), + body: Some(json!({ "message": message.into() })), + } + } + + pub fn start_run(&self, session_id: &str, prompt: impl Into) -> DevinRequest { + DevinRequest { + lifecycle: DevinLifecycleRequest::RunStart, + method: DevinHttpMethod::Post, + path: format!("/sessions/{}/runs", encode_path_segment(session_id)), + body: Some(json!({ "prompt": prompt.into() })), + } + } + + pub fn cancel_run(&self, session_id: &str, run_id: &str) -> DevinRequest { + DevinRequest { + lifecycle: DevinLifecycleRequest::RunCancel, + method: DevinHttpMethod::Post, + path: format!( + "/sessions/{}/runs/{}/cancel", + encode_path_segment(session_id), + encode_path_segment(run_id) + ), + body: Some(json!({})), + } + } + + pub fn fetch_events(&self, session_id: &str, after_cursor: Option) -> DevinRequest { + let path = match after_cursor { + Some(cursor) => format!( + "/sessions/{}/events?after={cursor}", + encode_path_segment(session_id) + ), + None => format!("/sessions/{}/events", encode_path_segment(session_id)), + }; + + DevinRequest { + lifecycle: DevinLifecycleRequest::EventFetch, + method: DevinHttpMethod::Get, + path, + body: None, + } + } + + pub fn absolute_url(&self, request: &DevinRequest) -> Result { + let joined = format!("{}{}", self.base_url.trim_end_matches('/'), request.path); + Url::parse(&joined).map_err(|source| DevinClientError::InvalidUrl { + base_url: self.base_url.clone(), + path: request.path.clone(), + source, + }) + } +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +/// HTTPS client for the Devin cloud API. +#[derive(Debug, Clone)] +pub struct DevinCloudClient { + http: reqwest::Client, + requests: DevinRequestBuilder, + token: DevinApiToken, +} + +impl DevinCloudClient { + /// Builds a client, reading the API token from the configured environment + /// variable. The token is never stored in configuration or manifests. + pub fn from_environment( + config: &DevinCloudConfig, + environment: impl Fn(&str) -> Option, + ) -> Result { + let token = environment(&config.api_key_env) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| DevinClientError::MissingCredential { + env: config.api_key_env.clone(), + })?; + let http = reqwest::Client::builder() + .timeout(config.request_timeout) + .https_only(true) + .build() + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + + Ok(Self { + http, + requests: DevinRequestBuilder::new(config.base_url.clone()), + token: DevinApiToken::new(token.trim()), + }) + } + + pub fn requests(&self) -> &DevinRequestBuilder { + &self.requests + } + + pub async fn send(&self, request: &DevinRequest) -> Result { + let url = self.requests.absolute_url(request)?; + let mut builder = match request.method { + DevinHttpMethod::Get => self.http.get(url), + DevinHttpMethod::Post => self.http.post(url), + } + .bearer_auth(self.token.expose()); + if let Some(body) = request.body.as_ref() { + builder = builder.json(body); + } + + let response = builder + .send() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + if !status.is_success() { + return Err(DevinClientError::Status { + status: status.as_u16(), + body, + }); + } + if body.trim().is_empty() { + return Ok(Value::Null); + } + + serde_json::from_str(&body).map_err(|error| DevinClientError::Decode(error.to_string())) + } + + pub async fn fetch_events( + &self, + session_id: &str, + after_cursor: Option, + ) -> Result, DevinClientError> { + let response = self + .send(&self.requests.fetch_events(session_id, after_cursor)) + .await?; + Ok(normalize_event_page(&response)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NormalizedDevinEventKind { + SessionCreated, + SessionResumed, + RunStarted, + RunCompleted, + RunFailed, + RunCancelled, + AgentMessage, + UserMessage, + ToolCall, + ToolResult, + StatusChanged, + Error, + Unknown, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NormalizedDevinEvent { + pub kind: NormalizedDevinEventKind, + pub event_type: String, + pub session_id: Option, + pub run_id: Option, + pub cursor: Option, + pub message: Option, + pub status: Option, + pub raw: Value, +} + +/// Normalizes one Devin event payload. +/// +/// Unrecognized event types are retained as [`NormalizedDevinEventKind::Unknown`] +/// with the full raw JSON so future Devin schema additions replay unchanged. +pub fn normalize_devin_event(raw: Value) -> Option { + let event_type = first_string(&raw, &["type", "event_type", "kind"])?; + let kind = match event_type.as_str() { + "session.created" | "session_created" => NormalizedDevinEventKind::SessionCreated, + "session.resumed" | "session_resumed" => NormalizedDevinEventKind::SessionResumed, + "run.started" | "run_started" => NormalizedDevinEventKind::RunStarted, + "run.completed" | "run_completed" => NormalizedDevinEventKind::RunCompleted, + "run.failed" | "run_failed" => NormalizedDevinEventKind::RunFailed, + "run.cancelled" | "run.canceled" | "run_cancelled" => { + NormalizedDevinEventKind::RunCancelled + } + "devin_message" | "agent_message" | "message.agent" => { + NormalizedDevinEventKind::AgentMessage + } + "user_message" | "message.user" => NormalizedDevinEventKind::UserMessage, + "tool_call" | "tool.call" => NormalizedDevinEventKind::ToolCall, + "tool_result" | "tool.result" => NormalizedDevinEventKind::ToolResult, + "status_update" | "session.status" => NormalizedDevinEventKind::StatusChanged, + "error" => NormalizedDevinEventKind::Error, + _ => NormalizedDevinEventKind::Unknown, + }; + + Some(NormalizedDevinEvent { + kind, + event_type, + session_id: first_string(&raw, &["session_id", "sessionId"]), + run_id: first_string(&raw, &["run_id", "runId"]), + cursor: first_u64(&raw, &["cursor", "sequence", "seq"]), + message: first_string(&raw, &["message", "text", "content"]), + status: first_string(&raw, &["status", "status_enum"]), + raw, + }) +} + +/// Normalizes an events page, accepting either a bare array or an object with +/// an `events` array. +pub fn normalize_event_page(response: &Value) -> Vec { + let events = response + .as_array() + .or_else(|| response.get("events").and_then(Value::as_array)) + .cloned() + .unwrap_or_default(); + + events + .into_iter() + .filter_map(normalize_devin_event) + .collect() +} + +pub fn devin_event_summary(event: &NormalizedDevinEvent) -> String { + match event.kind { + NormalizedDevinEventKind::SessionCreated => "Devin session created".into(), + NormalizedDevinEventKind::SessionResumed => "Devin session resumed".into(), + NormalizedDevinEventKind::RunStarted => "Devin run started".into(), + NormalizedDevinEventKind::RunCompleted => "Devin run completed".into(), + NormalizedDevinEventKind::RunFailed => "Devin run failed".into(), + NormalizedDevinEventKind::RunCancelled => "Devin run cancelled".into(), + NormalizedDevinEventKind::AgentMessage => event + .message + .clone() + .unwrap_or_else(|| "Devin agent message".into()), + NormalizedDevinEventKind::UserMessage => "Operator message forwarded to Devin".into(), + NormalizedDevinEventKind::ToolCall => "Devin tool call".into(), + NormalizedDevinEventKind::ToolResult => "Devin tool result".into(), + NormalizedDevinEventKind::StatusChanged => match event.status.as_deref() { + Some(status) => format!("Devin status changed to {status}"), + None => "Devin status changed".into(), + }, + NormalizedDevinEventKind::Error => event + .message + .clone() + .unwrap_or_else(|| "Devin reported an error".into()), + NormalizedDevinEventKind::Unknown => { + format!("Unknown Devin event `{}`", event.event_type) + } + } +} + +pub fn devin_event_payload(event: &NormalizedDevinEvent) -> Value { + json!({ + "source_kind": event.event_type, + "session_id": event.session_id, + "run_id": event.run_id, + "cursor": event.cursor, + "status": event.status, + "message": event.message, + "raw_payload": event.raw, + }) +} + +pub fn normalized_event_to_journal_record( + run_id: impl Into, + sequence: u64, + event: &NormalizedDevinEvent, +) -> EventRecord { + let run_id = run_id.into(); + let kind = match event.kind { + NormalizedDevinEventKind::RunStarted => EventKind::RunStarted, + NormalizedDevinEventKind::RunCompleted => EventKind::RunCompleted, + NormalizedDevinEventKind::RunFailed | NormalizedDevinEventKind::Error => { + EventKind::RunFailed + } + NormalizedDevinEventKind::RunCancelled => EventKind::RunCancelled, + NormalizedDevinEventKind::ToolCall => EventKind::HarnessToolCall, + NormalizedDevinEventKind::ToolResult => EventKind::HarnessToolResult, + NormalizedDevinEventKind::Unknown => EventKind::Unknown { + raw_kind: event.event_type.clone(), + }, + _ => EventKind::HarnessEventNormalized { + source_kind: event.event_type.clone(), + }, + }; + + EventRecord::builder() + .sequence(sequence) + .actor(EventActor::harness(DEVIN_CLOUD_AGENT_KIND)) + .entity_ref(EntityRef::run(run_id.clone())) + .summary(devin_event_summary(event)) + .kind(kind) + .payload(devin_event_payload(event)) + .raw_payload_ref(format!("devin:{run_id}:{sequence}")) + .build() +} + +fn first_string(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .map(str::to_owned) +} + +fn first_u64(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_u64)) +} diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index b8ae5ee5a..ec1277d41 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -127,13 +127,15 @@ pub struct HarnessHistoryCapability { pub enum HarnessKind { OpenHandsAgentServer, CodexAppServer, + DevinCloudAgent, RustNative, } impl HarnessKind { - pub const ALL: [Self; 3] = [ + pub const ALL: [Self; 4] = [ Self::OpenHandsAgentServer, Self::CodexAppServer, + Self::DevinCloudAgent, Self::RustNative, ]; @@ -141,6 +143,7 @@ impl HarnessKind { match value { "openhands_agent_server" => Some(Self::OpenHandsAgentServer), "codex_app_server" => Some(Self::CodexAppServer), + "devin_cloud_agent" => Some(Self::DevinCloudAgent), "rust_native" => Some(Self::RustNative), _ => None, } @@ -150,6 +153,7 @@ impl HarnessKind { match self { Self::OpenHandsAgentServer => "openhands_agent_server", Self::CodexAppServer => "codex_app_server", + Self::DevinCloudAgent => "devin_cloud_agent", Self::RustNative => "rust_native", } } @@ -162,6 +166,7 @@ impl HarnessKind { match self { Self::OpenHandsAgentServer => HarnessCapability::openhands_agent_server(), Self::CodexAppServer => HarnessCapability::codex_app_server_local(), + Self::DevinCloudAgent => HarnessCapability::devin_cloud_future(), Self::RustNative => HarnessCapability::rust_native_future(), } } @@ -398,6 +403,87 @@ impl HarnessCapability { } } + /// Future remote Devin cloud harness shape. + /// + /// Devin executes in its own cloud-owned workspace, so this capability stays + /// unavailable until hosted-mode security (TLS pinning, authenticated event + /// streams, secret injection, tenant isolation) and event normalization have + /// hardening evidence. + pub fn devin_cloud_future() -> Self { + Self { + kind: "devin_cloud_agent".into(), + display_name: "Devin cloud agent".into(), + available: false, + adapter_contract_version: "harness-adapter-v1".into(), + runtime_contract_version: None, + actions: HarnessActionCapability { + start_run: true, + send_user_message: true, + retry: true, + cancel: true, + pause: false, + resume: false, + approve: false, + reject: false, + comment: false, + }, + event_streams: HarnessEventStreamCapability { + runtime_events: true, + terminal_frames: false, + replay_from_cursor: false, + raw_payload_refs: true, + delivery_modes: vec!["https_polling".into()], + }, + approvals: HarnessApprovalCapability { + tool_approval: false, + human_decision: false, + policy_metadata: false, + }, + model_settings: HarnessModelSettingsCapability { + api_compatible_settings: false, + subscription_credentials: false, + per_run_overrides: false, + credential_reference_kinds: vec!["env".into()], + }, + transport: HarnessTransportCapability { + protocol: "https".into(), + modes: vec!["rest".into()], + local: false, + remote: true, + }, + cancellation: HarnessCancellationCapability { + cancel_run: true, + force_stop: false, + acknowledges_cancel: false, + }, + pause_resume: HarnessPauseResumeCapability { + pause: false, + resume: false, + }, + history: HarnessHistoryCapability { + fetch_history: true, + reconcile_after_ready: false, + reconnect_and_replay: false, + preserve_unknown_events: true, + }, + notes: vec![ + "Remote implementation agent executing in a Devin-owned cloud workspace.".into(), + "OpenSymphony keeps a local issue workspace for manifests, journals, and evidence only; it is never the Devin execution cwd." + .into(), + ], + feature_gaps: vec![ + "Remote workspace ownership is unimplemented: repository binding to a Devin-owned workspace has no verified checkout, diff, or evidence contract." + .into(), + "Devin event normalization is unimplemented: recognized event mapping and unknown-event retention have no hardening evidence yet." + .into(), + "Authentication and secret handling are unimplemented: TLS posture, authenticated event streams, and API-token injection are not hardened." + .into(), + "Tenant isolation is unimplemented: per-org/per-issue session scoping and cross-tenant leak protections are not enforced." + .into(), + ], + } + } + pub fn rust_native_future() -> Self { Self { kind: "rust_native".into(), diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 8d263e8f8..2b73b20bb 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -796,13 +796,14 @@ fn harness_capability_roundtrips_future_adapters() { let caps = vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), + HarnessCapability::devin_cloud_future(), HarnessCapability::rust_native_future(), ]; let json = must_serialize(&caps); let back: Vec = must_deserialize(&json); - assert_eq!(back.len(), 3); + assert_eq!(back.len(), 4); assert!(back[0].available); assert_eq!(back[1].kind, "codex_app_server"); assert_eq!(back[1].transport.protocol, "json_rpc_2_0"); @@ -834,8 +835,44 @@ fn harness_capability_roundtrips_future_adapters() { .iter() .any(|gap| gap.contains("Hosted Codex worker pools")) ); - assert_eq!(back[2].kind, "rust_native"); - assert!(back[2].pause_resume.pause); + assert_eq!(back[2].kind, "devin_cloud_agent"); + assert!(!back[2].available); + assert_eq!(back[3].kind, "rust_native"); + assert!(back[3].pause_resume.pause); +} + +#[test] +fn devin_cloud_future_capability_shape_is_stable() { + let future = HarnessCapability::devin_cloud_future(); + let json = must_serialize(&future); + let back: HarnessCapability = must_deserialize(&json); + + assert_eq!(back.kind, "devin_cloud_agent"); + assert_eq!(back.display_name, "Devin cloud agent"); + assert!(!back.available); + assert_eq!(back.adapter_contract_version, "harness-adapter-v1"); + assert_eq!(back.runtime_contract_version, None); + assert_eq!(back.transport.protocol, "https"); + assert!(back.transport.remote); + assert!(!back.transport.local); + assert_eq!(back.transport.modes, vec!["rest"]); + assert!(back.actions.start_run); + assert!(back.actions.cancel); + assert!(!back.actions.approve); + assert!(back.history.preserve_unknown_events); + for expected_gap in [ + "Remote workspace ownership", + "event normalization", + "Authentication and secret handling", + "Tenant isolation", + ] { + assert!( + back.feature_gaps + .iter() + .any(|gap| gap.contains(expected_gap)), + "missing devin feature gap for `{expected_gap}`" + ); + } } #[test] diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index cd249b266..86049848e 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -1054,6 +1054,7 @@ fn build_capabilities() -> GatewayCapabilities { harnesses: vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), + HarnessCapability::devin_cloud_future(), HarnessCapability::rust_native_future(), ], features: vec![ diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index a04540160..cc42ec621 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -1344,6 +1344,17 @@ async fn gateway_serves_capabilities_and_dashboard_snapshot() { == Some("codex-app-server-json-rpc-v2") && harness.transport.modes == vec!["stdio"]) ); + assert!( + caps_response + .harnesses + .iter() + .any(|harness| harness.kind == "devin_cloud_agent" + && !harness.available + && harness.transport.protocol == "https" + && harness.transport.remote + && !harness.transport.local + && !harness.feature_gaps.is_empty()) + ); assert!( caps_response .features diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index 780547d35..b567d7386 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -109,6 +109,8 @@ mod tests { PromptTemplateError, TrackerKind, WorkflowConfigError, WorkflowDefinition, WorkflowLoadError, model::{ + DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, + DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, DEFAULT_MAX_CONCURRENT_AGENTS, DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_BASE_URL, DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, @@ -2542,6 +2544,122 @@ openhands: assert!(!resolved.extensions.openhands.local_server.enabled); } + #[test] + fn devin_routing_resolves_defaults_and_overrides() { + let defaults = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse"); + let env = env([("LINEAR_API_KEY", "linear-token")]); + + let resolved = defaults + .resolve(Path::new("/repo"), &env) + .expect("devin routing should resolve"); + + assert_eq!(resolved.config.routing.harness, "devin_cloud_agent"); + assert_eq!( + resolved.extensions.devin.api.base_url, + DEFAULT_DEVIN_API_BASE_URL + ); + assert_eq!( + resolved.extensions.devin.api.api_key_env, + DEFAULT_DEVIN_API_KEY_ENV + ); + assert_eq!( + resolved.extensions.devin.api.event_poll_interval_ms, + DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS + ); + assert_eq!( + resolved.extensions.devin.api.request_timeout_ms, + DEFAULT_DEVIN_REQUEST_TIMEOUT_MS + ); + + let overridden = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + base_url: https://devin.internal.example.com/api + api_key_env: DEVIN_TENANT_API_KEY + event_poll_interval_ms: 500 + request_timeout_ms: 15000 +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env) + .expect("devin overrides should resolve"); + + assert_eq!( + overridden.extensions.devin.api.base_url, + "https://devin.internal.example.com/api" + ); + assert_eq!( + overridden.extensions.devin.api.api_key_env, + "DEVIN_TENANT_API_KEY" + ); + assert_eq!(overridden.extensions.devin.api.event_poll_interval_ms, 500); + assert_eq!(overridden.extensions.devin.api.request_timeout_ms, 15_000); + } + + #[test] + fn devin_endpoint_must_be_https_without_embedded_credentials() { + let env = env([("LINEAR_API_KEY", "linear-token")]); + + for base_url in [ + "http://api.devin.ai/v1", + "https://user:secret@api.devin.ai/v1", + "https://api.devin.ai/v1?token=secret", + "not-a-url", + ] { + let workflow = WorkflowDefinition::parse(&format!( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + base_url: {base_url} +--- +{{{{ issue.identifier }}}} +"# + )) + .expect("workflow should parse"); + + assert!( + workflow.resolve(Path::new("/repo"), &env).is_err(), + "`{base_url}` should be rejected as a devin endpoint" + ); + } + } + #[test] fn selected_openhands_model_overrides_conversation_model() { let workflow = WorkflowDefinition::parse( diff --git a/crates/opensymphony-workflow/src/model.rs b/crates/opensymphony-workflow/src/model.rs index 54ced5831..af20990bf 100644 --- a/crates/opensymphony-workflow/src/model.rs +++ b/crates/opensymphony-workflow/src/model.rs @@ -30,6 +30,10 @@ pub const DEFAULT_OPENHANDS_QUERY_PARAM_NAME: &str = "session_api_key"; pub const DEFAULT_OPENHANDS_LLM_MODEL: &str = "openai/gpt-5.4"; pub const DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE: &str = "api_key"; pub const DEFAULT_ROUTING_HARNESS: &str = "openhands_agent_server"; +pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai/v1"; +pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "DEVIN_API_KEY"; +pub const DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS: u64 = 2_000; +pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; pub const DEFAULT_ROUTING_HARNESS_ENV: &str = "OPENSYMPHONY_HARNESS"; pub const DEFAULT_ROUTING_MODEL_ENV: &str = "OPENSYMPHONY_MODEL"; pub const DEFAULT_ROUTING_MODEL_PROFILE_ENV: &str = "OPENSYMPHONY_MODEL_PROFILE"; @@ -59,6 +63,8 @@ pub struct WorkflowFrontMatter { #[serde(default)] pub openhands: OpenHandsFrontMatter, #[serde(default)] + pub devin: DevinFrontMatter, + #[serde(default)] pub routing: RoutingFrontMatter, #[serde(default)] pub codex: Option>, @@ -127,6 +133,24 @@ pub struct RoutingFrontMatter { pub model_profile_env: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DevinFrontMatter { + #[serde(default)] + pub api: DevinApiFrontMatter, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DevinApiFrontMatter { + pub base_url: Option, + /// Environment variable holding the Devin API token. The token itself is + /// never stored in workflow configuration or run manifests. + pub api_key_env: Option, + pub event_poll_interval_ms: Option, + pub request_timeout_ms: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct OpenHandsFrontMatter { @@ -272,6 +296,20 @@ pub struct WorkflowConfig { #[derive(Debug, Clone, PartialEq)] pub struct WorkflowExtensions { pub openhands: OpenHandsConfig, + pub devin: DevinConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinConfig { + pub api: DevinApiConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinApiConfig { + pub base_url: String, + pub api_key_env: String, + pub event_poll_interval_ms: u64, + pub request_timeout_ms: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/opensymphony-workflow/src/resolve.rs b/crates/opensymphony-workflow/src/resolve.rs index 24bc5658c..a36281a53 100644 --- a/crates/opensymphony-workflow/src/resolve.rs +++ b/crates/opensymphony-workflow/src/resolve.rs @@ -9,19 +9,21 @@ use url::{Host, Url}; use super::{ error::WorkflowConfigError, model::{ - AgentConfig, AgentFrontMatter, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, - DEFAULT_MAX_CONCURRENT_AGENTS, DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, - DEFAULT_OPENHANDS_AGENT_KIND, DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_AUTH_MODE, - DEFAULT_OPENHANDS_BASE_URL, DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, - DEFAULT_OPENHANDS_CONDENSER_MAX_SIZE, DEFAULT_OPENHANDS_CONFIRMATION_POLICY_KIND, - DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE, DEFAULT_OPENHANDS_LLM_MODEL, - DEFAULT_OPENHANDS_MAX_ITERATIONS, DEFAULT_OPENHANDS_PERSISTENCE_DIR, - DEFAULT_OPENHANDS_QUERY_PARAM_NAME, DEFAULT_OPENHANDS_READINESS_PROBE_PATH, - DEFAULT_OPENHANDS_READY_TIMEOUT_MS, DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, - DEFAULT_OPENHANDS_RECONNECT_MAX_MS, DEFAULT_OPENHANDS_STARTUP_TIMEOUT_MS, - DEFAULT_POLL_INTERVAL_MS, DEFAULT_ROUTING_HARNESS, DEFAULT_ROUTING_HARNESS_ENV, - DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, DEFAULT_STALL_TIMEOUT_MS, - DEFAULT_WORKSPACE_ROOT, Environment, HooksConfig, HooksFrontMatter, IntegerLike, + AgentConfig, AgentFrontMatter, DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, + DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, DEFAULT_MAX_CONCURRENT_AGENTS, + DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, DEFAULT_OPENHANDS_AGENT_KIND, + DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_AUTH_MODE, DEFAULT_OPENHANDS_BASE_URL, + DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, DEFAULT_OPENHANDS_CONDENSER_MAX_SIZE, + DEFAULT_OPENHANDS_CONFIRMATION_POLICY_KIND, DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE, + DEFAULT_OPENHANDS_LLM_MODEL, DEFAULT_OPENHANDS_MAX_ITERATIONS, + DEFAULT_OPENHANDS_PERSISTENCE_DIR, DEFAULT_OPENHANDS_QUERY_PARAM_NAME, + DEFAULT_OPENHANDS_READINESS_PROBE_PATH, DEFAULT_OPENHANDS_READY_TIMEOUT_MS, + DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, DEFAULT_OPENHANDS_RECONNECT_MAX_MS, + DEFAULT_OPENHANDS_STARTUP_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_ROUTING_HARNESS, + DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, + DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, DevinApiConfig, DevinApiFrontMatter, + DevinConfig, DevinFrontMatter, Environment, HooksConfig, HooksFrontMatter, IntegerLike, OPENHANDS_LLM_CREDENTIAL_MODE_API_KEY, OPENHANDS_LLM_CREDENTIAL_MODE_OPENAI_SUBSCRIPTION, OpenHandsConfig, OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, OpenHandsConversationAgentConfig, OpenHandsConversationAgentFrontMatter, @@ -57,6 +59,7 @@ pub(crate) fn resolve_workflow( } else { default_inactive_openhands_config() }, + devin: resolve_devin(&workflow.front_matter.devin, env)?, }; apply_selected_model_to_openhands(&config.routing, &mut extensions.openhands); @@ -544,6 +547,90 @@ fn resolve_openhands( }) } +fn resolve_devin( + devin: &DevinFrontMatter, + env: &E, +) -> Result { + Ok(DevinConfig { + api: resolve_devin_api(&devin.api, env)?, + }) +} + +fn resolve_devin_api( + api: &DevinApiFrontMatter, + env: &E, +) -> Result { + let base_url = resolve_string_or_default( + api.base_url.as_deref(), + env, + "devin.api.base_url", + DEFAULT_DEVIN_API_BASE_URL, + )?; + validate_devin_base_url(&base_url)?; + + let api_key_env = resolve_string_or_default( + api.api_key_env.as_deref(), + env, + "devin.api.api_key_env", + DEFAULT_DEVIN_API_KEY_ENV, + )?; + validate_env_name(&api_key_env, "devin.api.api_key_env")?; + + Ok(DevinApiConfig { + base_url, + api_key_env, + event_poll_interval_ms: resolve_positive_u64( + api.event_poll_interval_ms.as_ref(), + "devin.api.event_poll_interval_ms", + DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, + )?, + request_timeout_ms: resolve_positive_u64( + api.request_timeout_ms.as_ref(), + "devin.api.request_timeout_ms", + DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + )?, + }) +} + +/// Devin is a hosted remote harness, so its endpoint must be https and must not +/// carry credentials, query strings, or fragments. +fn validate_devin_base_url(base_url: &str) -> Result<(), WorkflowConfigError> { + let parsed = Url::parse(base_url).map_err(|error| WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: format!("must be an absolute https URL: {error}"), + })?; + + if parsed.scheme() != "https" { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must use the https scheme for remote Devin routing".to_owned(), + }); + } + + if parsed.host().is_none() { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must include a host".to_owned(), + }); + } + + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must not embed credentials".to_owned(), + }); + } + + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must not include query or fragment suffixes".to_owned(), + }); + } + + Ok(()) +} + fn default_inactive_openhands_config() -> OpenHandsConfig { OpenHandsConfig { transport: OpenHandsTransportConfig { diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 5a68d6c52..2ad67b1a7 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -101,6 +101,44 @@ Known gaps: tokens in OpenSymphony workspaces or browser payloads. - Hosted Codex worker pools and remote routing remain future work. +## Devin Cloud Agent + +Devin is a remote, vendor-hosted implementation agent. The adapter lives in +`crates/opensymphony-devin` (source-included by the root crate, not a separate +Cargo package) and advertises `devin_cloud_agent` with `available: false` over +an HTTPS transport (`remote: true`, `local: false`). + +The module provides the boundary primitives a hosted route needs: validated +configuration (HTTPS-only endpoint, credential *environment variable name* +rather than a token), a request builder covering session create/resume, message +send, run start/cancel, and event retrieval, a client that reads the token from +the configured environment variable and injects it only on the wire, and event +normalization that maps recognized Devin events into journal records while +preserving every unknown payload as raw JSON. + +Devin owns its execution workspace. `DevinRemoteWorkspaceBinding` binds an issue +workspace key and repository URL to that remote workspace and keeps the local +OpenSymphony checkout as an evidence-only path; it is never used as Devin's +working directory. The CLI route therefore rejects `devin_cloud_agent` launches +with an explicit unavailability reason before any local workspace is prepared. + +Workflow configuration lives under the `devin.api` front-matter block +(`base_url`, `api_key_env`, `event_poll_interval_ms`, `request_timeout_ms`). +Endpoints must be absolute HTTPS URLs without embedded credentials, query, or +fragment; credential values are never serialized into workflow config, +manifests, or logs. + +Known gaps (all blocking `available: true`): + +- Remote workspace ownership: repository binding, checkout verification, and + artifact/evidence retrieval from the Devin-owned workspace are unimplemented. +- Event normalization: the mapping is a first pass against an unverified event + vocabulary and needs hardening evidence against the live API. +- Authentication and secret handling: TLS pinning, authenticated event streams, + and secret injection into remote sessions are unimplemented. +- Tenant isolation: per-tenant credential scoping and isolation guarantees are + unimplemented. + ## Rust-Native Harness A Rust-native or in-process harness fits the same contract by implementing diff --git a/docs/host-client-architecture.md b/docs/host-client-architecture.md index 5538dc907..107b5819d 100644 --- a/docs/host-client-architecture.md +++ b/docs/host-client-architecture.md @@ -753,7 +753,7 @@ ModelConfigurationProfile ├─ base_url ├─ model ├─ credential_ref -└─ harnesses: openhands_agent_server | codex_app_server | other +└─ harnesses: openhands_agent_server | codex_app_server | devin_cloud_agent | other ``` The configuration profile records settings that are directly meaningful to the @@ -789,6 +789,29 @@ loopback WebSocket production routing remain future work. See `docs/codex-app-server-harness.md` for the current contract evidence and benchmark procedure. +### 8.5 Devin cloud agent harness shape + +```text +DevinCloudAdapter +├─ transport: https rest, remote only +├─ auth: api key read from a configured environment variable name +├─ workspace: Devin-owned remote workspace, local checkout is evidence only +├─ entities: session, run, message, tool call, event +├─ streams: polled event pages with cursor +├─ actions: create session, resume session, send message, start run, cancel run +└─ normalization: Devin event to OpenSymphony event envelope, unknown events + retained as raw JSON +``` + +Unlike the local harnesses, OpenSymphony never owns Devin's execution +directory. `DevinRemoteWorkspaceBinding` maps an issue workspace key and +repository URL onto the remote workspace while the local issue workspace stays +evidence-only, so the workspace manager's local-checkout invariants are not +applied to remote runs. The capability is advertised as unavailable: TLS +pinning, authenticated event streams, secret injection, tenant isolation, +remote workspace ownership, and event-normalization hardening evidence are all +prerequisites before `available` can become true. + ## 9. Hosted mode architecture ### 9.1 Hosted components diff --git a/src/lib.rs b/src/lib.rs index df94739e7..a345852c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ pub mod opensymphony_code_intel; pub mod opensymphony_codex; #[path = "../crates/opensymphony-control/src/lib.rs"] pub mod opensymphony_control; +#[path = "../crates/opensymphony-devin/src/lib.rs"] +pub mod opensymphony_devin; #[path = "../crates/opensymphony-domain/src/lib.rs"] pub mod opensymphony_domain; #[path = "../crates/opensymphony-gateway/src/lib.rs"] diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs new file mode 100644 index 000000000..9aa5cdbda --- /dev/null +++ b/tests/devin_cloud_agent.rs @@ -0,0 +1,223 @@ +use std::path::PathBuf; + +use opensymphony::opensymphony_devin::{ + DEVIN_CLOUD_AGENT_KIND, DEVIN_REMOTE_CONTAINMENT, DevinCloudAdapter, DevinCloudClient, + DevinCloudConfig, DevinHttpMethod, DevinLifecycleRequest, DevinRemoteWorkspaceBinding, + DevinRequestBuilder, NormalizedDevinEventKind, devin_event_summary, normalize_devin_event, + normalize_event_page, normalized_event_to_journal_record, +}; +use opensymphony::opensymphony_domain::HarnessAdapter; +use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; +use serde_json::json; + +fn binding() -> DevinRemoteWorkspaceBinding { + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + "https://github.com/acme/api", + Some("main".to_owned()), + ) + .expect("binding") +} + +#[test] +fn adapter_exposes_stable_unavailable_capability() { + let adapter = DevinCloudAdapter::default(); + let capability = adapter.capabilities(); + + assert_eq!(adapter.harness_kind(), DEVIN_CLOUD_AGENT_KIND); + assert_eq!(capability.kind, DEVIN_CLOUD_AGENT_KIND); + assert!(!capability.available); + assert_eq!(capability.transport.protocol, "https"); + assert!(capability.transport.remote); + assert!(!capability.transport.local); + assert_eq!(capability.feature_gaps.len(), 4); + assert!( + adapter + .unavailability_reason() + .contains("advertised as unavailable") + ); +} + +#[test] +fn remote_binding_keeps_local_workspace_as_evidence_only() { + let binding = binding(); + + assert_eq!(binding.effective_containment(), DEVIN_REMOTE_CONTAINMENT); + assert_eq!( + binding.local_evidence_path, + PathBuf::from("/workspaces/acme-123") + ); + assert!( + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + "https://token@github.com/acme/api", + None, + ) + .is_err() + ); + assert!( + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + "git@github.com:acme/api.git", + None, + ) + .is_err() + ); +} + +#[test] +fn config_rejects_insecure_or_credentialed_endpoints() { + assert!(DevinCloudConfig::default().validate().is_ok()); + + let insecure = DevinCloudConfig { + base_url: "http://api.devin.ai/v1".into(), + ..DevinCloudConfig::default() + }; + assert!(insecure.validate().is_err()); + + let credentialed = DevinCloudConfig { + base_url: "https://user:secret@api.devin.ai/v1".into(), + ..DevinCloudConfig::default() + }; + assert!(credentialed.validate().is_err()); + + let bad_env = DevinCloudConfig { + api_key_env: "devin key".into(), + ..DevinCloudConfig::default() + }; + assert!(bad_env.validate().is_err()); +} + +#[test] +fn lifecycle_requests_cover_session_message_run_and_events() { + let builder = DevinRequestBuilder::new("https://api.devin.ai/v1"); + let binding = binding(); + + let create = builder.create_session("fix the flake", &binding, Some("run-1".into())); + assert_eq!(create.lifecycle, DevinLifecycleRequest::SessionCreate); + assert_eq!(create.method, DevinHttpMethod::Post); + assert_eq!(create.path, "/sessions"); + let body = create.body.expect("create body"); + assert_eq!(body["prompt"], json!("fix the flake")); + assert_eq!(body["repository"]["url"], json!(binding.repository_url)); + assert_eq!(body["repository"]["base_branch"], json!("main")); + assert_eq!(body["idempotency_key"], json!("run-1")); + + let resume = builder.resume_session("sess-1"); + assert_eq!(resume.lifecycle, DevinLifecycleRequest::SessionResume); + assert_eq!(resume.path, "/sessions/sess-1/resume"); + + let message = builder.send_message("sess-1", "please rerun the tests"); + assert_eq!(message.lifecycle, DevinLifecycleRequest::MessageSend); + assert_eq!(message.path, "/sessions/sess-1/messages"); + assert_eq!( + message.body.expect("message body")["message"], + json!("please rerun the tests") + ); + + let start = builder.start_run("sess-1", "continue"); + assert_eq!(start.lifecycle, DevinLifecycleRequest::RunStart); + assert_eq!(start.path, "/sessions/sess-1/runs"); + + let cancel = builder.cancel_run("sess-1", "run-9"); + assert_eq!(cancel.lifecycle, DevinLifecycleRequest::RunCancel); + assert_eq!(cancel.path, "/sessions/sess-1/runs/run-9/cancel"); + + let events = builder.fetch_events("sess-1", Some(42)); + assert_eq!(events.lifecycle, DevinLifecycleRequest::EventFetch); + assert_eq!(events.method, DevinHttpMethod::Get); + assert_eq!(events.path, "/sessions/sess-1/events?after=42"); + assert_eq!( + builder + .absolute_url(&events) + .expect("absolute url") + .as_str(), + "https://api.devin.ai/v1/sessions/sess-1/events?after=42" + ); +} + +#[test] +fn client_requires_the_configured_credential_environment_variable() { + let config = DevinCloudConfig::default(); + + assert!(DevinCloudClient::from_environment(&config, |_| None).is_err()); + assert!(DevinCloudClient::from_environment(&config, |_| Some(" ".into())).is_err()); + assert!( + DevinCloudClient::from_environment(&config, |name| { + (name == config.api_key_env).then(|| "devin-token".to_owned()) + }) + .is_ok() + ); +} + +#[test] +fn known_events_normalize_into_journal_records() { + let event = normalize_devin_event(json!({ + "type": "run.completed", + "session_id": "sess-1", + "run_id": "run-9", + "cursor": 7, + "status": "finished", + })) + .expect("normalized event"); + + assert_eq!(event.kind, NormalizedDevinEventKind::RunCompleted); + assert_eq!(event.session_id.as_deref(), Some("sess-1")); + assert_eq!(event.run_id.as_deref(), Some("run-9")); + assert_eq!(event.cursor, Some(7)); + assert_eq!(devin_event_summary(&event), "Devin run completed"); + + let record = normalized_event_to_journal_record("run-9", 7, &event); + assert_eq!(record.kind, EventKind::RunCompleted); + assert_eq!(record.actor.kind_label(), "harness"); + assert_eq!( + record.payload.expect("payload")["session_id"], + json!("sess-1") + ); + assert_eq!(record.raw_payload_ref.as_deref(), Some("devin:run-9:7")); +} + +#[test] +fn unknown_events_retain_their_raw_payload() { + let raw = json!({ + "type": "devin.future_event", + "session_id": "sess-1", + "payload": { "nested": [1, 2, 3] }, + }); + let event = normalize_devin_event(raw.clone()).expect("normalized event"); + + assert_eq!(event.kind, NormalizedDevinEventKind::Unknown); + assert_eq!(event.raw, raw); + + let record = normalized_event_to_journal_record("run-9", 3, &event); + assert_eq!( + record.kind, + EventKind::Unknown { + raw_kind: "devin.future_event".to_owned(), + } + ); + assert_eq!(record.payload.expect("payload")["raw_payload"], raw); +} + +#[test] +fn event_pages_accept_arrays_and_envelopes() { + let page = json!({ + "events": [ + { "type": "devin_message", "message": "starting work" }, + { "type": "tool_call" }, + { "missing_type": true }, + ] + }); + let normalized = normalize_event_page(&page); + + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].kind, NormalizedDevinEventKind::AgentMessage); + assert_eq!(devin_event_summary(&normalized[0]), "starting work"); + assert_eq!(normalized[1].kind, NormalizedDevinEventKind::ToolCall); + + let bare = json!([{ "type": "run.started" }]); + assert_eq!(normalize_event_page(&bare).len(), 1); +} From 5e5a207a5f9acf308f4b007432c0476183b3998a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:54:27 +0000 Subject: [PATCH 02/13] fix(devin): harden devin harness boundary from review findings - reject query/fragment suffixes in devin endpoint and repository URLs - disable reqwest redirects and validate config before bearer use - preserve events lacking a discriminator as unknown raw payloads - keep devin.api.api_key_env a literal env var name - resolve devin config only for devin-routed workflows - reject unavailable routes before ensure_workspace in dispatch - allowlist devin_cloud_agent in frontend model profiles - document devin.api in docs/configuration.md Co-Authored-By: Leonardo Gonzalez --- crates/opensymphony-devin/src/lib.rs | 46 +++++++-- .../src/scheduler.rs | 5 +- .../tests/scheduler.rs | 26 +++++ crates/opensymphony-workflow/src/lib.rs | 95 +++++++++++++++++++ crates/opensymphony-workflow/src/resolve.rs | 30 ++++-- docs/configuration.md | 39 ++++++++ packages/gateway-schema/src/model_config.ts | 1 + .../state/__tests__/model-profiles.test.ts | 26 +++++ packages/state/src/model-profiles.ts | 1 + tests/devin_cloud_agent.rs | 61 ++++++++++-- 10 files changed, 306 insertions(+), 24 deletions(-) diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 6dd16b527..fa52d7afa 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -34,6 +34,10 @@ pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "DEVIN_API_KEY"; pub const DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS: u64 = 2_000; pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; +/// Synthetic event type recorded when a Devin event carries no recognized +/// discriminator field, so the payload is preserved rather than dropped. +pub const DEVIN_UNDISCRIMINATED_EVENT_KIND: &str = "devin.undiscriminated_event"; + /// Effective containment label recorded for runs routed to Devin. /// /// The local workspace is never the execution directory for this harness. @@ -47,6 +51,8 @@ pub enum DevinConfigError { InsecureBaseUrl, #[error("devin.api.base_url must not embed credentials")] CredentialsInBaseUrl, + #[error("devin.api.base_url must not carry a query string or fragment")] + DecoratedBaseUrl, #[error("devin.api.api_key_env must be an environment variable name")] InvalidApiKeyEnv, #[error("devin repository binding must be an absolute https git URL: {0}")] @@ -70,6 +76,8 @@ pub enum DevinClientError { Status { status: u16, body: String }, #[error("failed to decode devin api response: {0}")] Decode(String), + #[error("invalid devin api configuration: {0}")] + InvalidConfig(String), } /// API token wrapper that never renders its secret value. @@ -124,6 +132,9 @@ impl DevinCloudConfig { if !parsed.username().is_empty() || parsed.password().is_some() { return Err(DevinConfigError::CredentialsInBaseUrl); } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(DevinConfigError::DecoratedBaseUrl); + } if !is_environment_name(&self.api_key_env) { return Err(DevinConfigError::InvalidApiKeyEnv); } @@ -176,6 +187,11 @@ impl DevinRemoteWorkspaceBinding { "must not embed credentials".into(), )); } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(DevinConfigError::InvalidRepositoryUrl( + "must not carry a query string or fragment".into(), + )); + } Ok(Self { issue_workspace_key: issue_workspace_key.into(), @@ -404,6 +420,9 @@ impl DevinCloudClient { config: &DevinCloudConfig, environment: impl Fn(&str) -> Option, ) -> Result { + config + .validate() + .map_err(|error| DevinClientError::InvalidConfig(error.to_string()))?; let token = environment(&config.api_key_env) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| DevinClientError::MissingCredential { @@ -412,6 +431,9 @@ impl DevinCloudClient { let http = reqwest::Client::builder() .timeout(config.request_timeout) .https_only(true) + // The bearer token is attached to every request, so a redirect to + // another host would hand the credential to that host. + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| DevinClientError::Transport(error.to_string()))?; @@ -504,8 +526,19 @@ pub struct NormalizedDevinEvent { /// /// Unrecognized event types are retained as [`NormalizedDevinEventKind::Unknown`] /// with the full raw JSON so future Devin schema additions replay unchanged. -pub fn normalize_devin_event(raw: Value) -> Option { - let event_type = first_string(&raw, &["type", "event_type", "kind"])?; +pub fn normalize_devin_event(raw: Value) -> NormalizedDevinEvent { + let Some(event_type) = first_string(&raw, &["type", "event_type", "kind"]) else { + return NormalizedDevinEvent { + kind: NormalizedDevinEventKind::Unknown, + event_type: DEVIN_UNDISCRIMINATED_EVENT_KIND.to_owned(), + session_id: first_string(&raw, &["session_id", "sessionId"]), + run_id: first_string(&raw, &["run_id", "runId"]), + cursor: first_u64(&raw, &["cursor", "sequence", "seq"]), + message: first_string(&raw, &["message", "text", "content"]), + status: first_string(&raw, &["status", "status_enum"]), + raw, + }; + }; let kind = match event_type.as_str() { "session.created" | "session_created" => NormalizedDevinEventKind::SessionCreated, "session.resumed" | "session_resumed" => NormalizedDevinEventKind::SessionResumed, @@ -526,7 +559,7 @@ pub fn normalize_devin_event(raw: Value) -> Option { _ => NormalizedDevinEventKind::Unknown, }; - Some(NormalizedDevinEvent { + NormalizedDevinEvent { kind, event_type, session_id: first_string(&raw, &["session_id", "sessionId"]), @@ -535,7 +568,7 @@ pub fn normalize_devin_event(raw: Value) -> Option { message: first_string(&raw, &["message", "text", "content"]), status: first_string(&raw, &["status", "status_enum"]), raw, - }) + } } /// Normalizes an events page, accepting either a bare array or an object with @@ -547,10 +580,7 @@ pub fn normalize_event_page(response: &Value) -> Vec { .cloned() .unwrap_or_default(); - events - .into_iter() - .filter_map(normalize_devin_event) - .collect() + events.into_iter().map(normalize_devin_event).collect() } pub fn devin_event_summary(event: &NormalizedDevinEvent) -> String { diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 31b756f80..1dfccee6b 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -2691,6 +2691,10 @@ where } } + // Route first: an unavailable harness must fail before a workspace + // is materialized and workspace creation hooks run. + let route = decide_issue_route(&normalized, &self.config)?; + let workspace = self .workspace .ensure_workspace(&normalized, observed_at) @@ -2739,7 +2743,6 @@ where .and_then(RepositoryBindingOutcome::resolved_binding) .cloned(), ); - let route = decide_issue_route(&normalized, &self.config)?; let mut execution = self .remove_execution(&issue_id) diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 3d2850431..644544568 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -5294,6 +5294,32 @@ async fn repository_binding_outcome_blocks_before_workspace_creation() { assert!(scheduler.worker().launches.is_empty()); } +#[tokio::test] +async fn unavailable_harness_rejects_dispatch_before_workspace_creation() { + let tracker = FakeTracker { + active: vec![tracker_issue("lin-devin", "COE-DEVIN-1", "In Progress", 0)], + ..Default::default() + }; + let workspace = FakeWorkspace::default(); + let worker = FakeWorker::default(); + let mut config = scheduler_config(); + config.routing.harness = "devin_cloud_agent".into(); + config.stall_timeout_ms = None; + let mut scheduler = Scheduler::new(tracker, workspace, worker, config); + + let error = scheduler + .tick(ts(100)) + .await + .expect_err("unavailable devin routing should reject dispatch"); + + assert!(matches!( + error, + crate::opensymphony_orchestrator::SchedulerError::InvalidConfiguration { .. } + )); + assert!(scheduler.workspace().ensured.is_empty()); + assert!(scheduler.worker().launches.is_empty()); +} + #[tokio::test] async fn unlabeled_parent_remains_repository_neutral() { let mut issue = tracker_issue("lin-repo-parent", "COE-548-PARENT", "In Progress", 0); diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index b567d7386..037f626c2 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -2660,6 +2660,101 @@ devin: } } + #[test] + fn devin_api_key_env_stays_a_literal_variable_name() { + let env = env([ + ("LINEAR_API_KEY", "linear-token"), + ("DEVIN_API_KEY", "devin-secret-token"), + ]); + + let resolved = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + api_key_env: DEVIN_API_KEY +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env) + .expect("devin credential reference should resolve"); + + // The name is kept verbatim; env indirection here would store the token. + assert_eq!(resolved.extensions.devin.api.api_key_env, "DEVIN_API_KEY"); + + let substituted = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + api_key_env: ${DEVIN_API_KEY} +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env); + + assert!( + substituted.is_err(), + "a substitution reference must be rejected rather than resolved to the token" + ); + } + + #[test] + fn devin_settings_are_inert_for_other_harnesses() { + let env = env([("LINEAR_API_KEY", "linear-token")]); + + let resolved = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: codex_app_server +devin: + api: + base_url: http://api.devin.ai/${DEVIN_UNSET_TENANT} +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env) + .expect("parked devin settings must not fail a codex workflow"); + + assert_eq!( + resolved.extensions.devin.api.base_url, + DEFAULT_DEVIN_API_BASE_URL + ); + assert_eq!( + resolved.extensions.devin.api.api_key_env, + DEFAULT_DEVIN_API_KEY_ENV + ); + } + #[test] fn selected_openhands_model_overrides_conversation_model() { let workflow = WorkflowDefinition::parse( diff --git a/crates/opensymphony-workflow/src/resolve.rs b/crates/opensymphony-workflow/src/resolve.rs index a36281a53..7cdec465b 100644 --- a/crates/opensymphony-workflow/src/resolve.rs +++ b/crates/opensymphony-workflow/src/resolve.rs @@ -59,7 +59,11 @@ pub(crate) fn resolve_workflow( } else { default_inactive_openhands_config() }, - devin: resolve_devin(&workflow.front_matter.devin, env)?, + devin: if config.routing.harness == HarnessKind::DevinCloudAgent.as_str() { + resolve_devin(&workflow.front_matter.devin, env)? + } else { + default_inactive_devin_config() + }, }; apply_selected_model_to_openhands(&config.routing, &mut extensions.openhands); @@ -568,12 +572,10 @@ fn resolve_devin_api( )?; validate_devin_base_url(&base_url)?; - let api_key_env = resolve_string_or_default( - api.api_key_env.as_deref(), - env, - "devin.api.api_key_env", - DEFAULT_DEVIN_API_KEY_ENV, - )?; + // The credential reference is a literal variable name: resolving it through + // env indirection would store the token itself in resolved configuration. + let api_key_env = normalize_optional_literal(&api.api_key_env) + .unwrap_or_else(|| DEFAULT_DEVIN_API_KEY_ENV.to_owned()); validate_env_name(&api_key_env, "devin.api.api_key_env")?; Ok(DevinApiConfig { @@ -631,6 +633,20 @@ fn validate_devin_base_url(base_url: &str) -> Result<(), WorkflowConfigError> { Ok(()) } +/// Devin settings are only resolved for Devin-routed workflows, so an unset +/// substitution or parked endpoint in `devin.api` cannot reject an OpenHands or +/// Codex workflow. +fn default_inactive_devin_config() -> DevinConfig { + DevinConfig { + api: DevinApiConfig { + base_url: DEFAULT_DEVIN_API_BASE_URL.to_owned(), + api_key_env: DEFAULT_DEVIN_API_KEY_ENV.to_owned(), + event_poll_interval_ms: DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, + request_timeout_ms: DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + }, + } +} + fn default_inactive_openhands_config() -> OpenHandsConfig { OpenHandsConfig { transport: OpenHandsTransportConfig { diff --git a/docs/configuration.md b/docs/configuration.md index b2f8b0a66..23e01001e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -711,6 +711,45 @@ OpenSymphony forwards an OpenHands `LLMSummarizingCondenser` that reuses the conversation agent's LLM settings. The condenser is enabled by default with `max_size: 240` and `keep_first: 2`. To disable it, set `enabled: false`. +## Devin Cloud Harness (`devin.api`) + +`devin_cloud_agent` is advertised as an unavailable capability, so these +settings only describe the boundary a future hosted integration will use. They +are resolved **only** when `routing.harness: devin_cloud_agent`; for OpenHands, +Codex, or `rust_native` workflows the block is ignored entirely and an inert +default is used, so a parked or half-configured `devin.api` block cannot fail +an unrelated workflow. + +```yaml +routing: + harness: devin_cloud_agent + +devin: + api: + base_url: https://api.devin.ai/v1 + api_key_env: DEVIN_API_KEY + event_poll_interval_ms: 2000 + request_timeout_ms: 30000 +``` + +| Field | Default | Notes | +| --- | --- | --- | +| `base_url` | `https://api.devin.ai/v1` | Supports `${VAR}` env indirection. Must be an absolute `https` URL with a host, and must not embed credentials, a query string, or a fragment. | +| `api_key_env` | `DEVIN_API_KEY` | **Literal environment-variable name**, never a value. | +| `event_poll_interval_ms` | `2000` | Must be a positive integer. | +| `request_timeout_ms` | `30000` | Must be a positive integer. | + +`api_key_env` is deliberately not passed through env substitution: writing +`api_key_env: ${DEVIN_API_KEY}` would resolve the token itself into resolved +configuration, manifests, and debug output. The name is validated as an +environment-variable identifier and the token is read from the process +environment only at request time. + +Because `devin_cloud_agent` capability is `available: false`, selecting it +causes routing to reject issue dispatch before any local workspace is created: +the local issue workspace is evidence and manifest storage only and is never +Devin's execution directory. + ## Runtime Config `opensymphony init` also copies a starter `config.yaml` next to the target diff --git a/packages/gateway-schema/src/model_config.ts b/packages/gateway-schema/src/model_config.ts index d76533996..add21ec86 100644 --- a/packages/gateway-schema/src/model_config.ts +++ b/packages/gateway-schema/src/model_config.ts @@ -16,6 +16,7 @@ export type CredentialStorage = export type ModelHarnessKind = | "openhands_agent_server" | "codex_app_server" + | "devin_cloud_agent" | "rust_native" | (string & {}); diff --git a/packages/state/__tests__/model-profiles.test.ts b/packages/state/__tests__/model-profiles.test.ts index 0ff1834e5..a67954991 100644 --- a/packages/state/__tests__/model-profiles.test.ts +++ b/packages/state/__tests__/model-profiles.test.ts @@ -394,6 +394,32 @@ describe("createModelProfileStore", () => { ])); }); + it("retains the devin_cloud_agent harness kind through a storage round trip", async () => { + const storage = new MemoryStorage(); + const quarantineReasons: string[] = []; + const store = createModelProfileStore({ + storage, + onQuarantine: (reason) => quarantineReasons.push(reason), + }); + const profile = { + ...createModelProfile("api_key"), + id: "devin-profile", + harnesses: ["devin_cloud_agent"], + }; + + await store.storeProfile(profile); + const reloaded = createModelProfileStore({ + storage, + onQuarantine: (reason) => quarantineReasons.push(reason), + }); + const saved = (await reloaded.listProfiles()).find( + (candidate) => candidate.id === "devin-profile", + ); + + expect(saved?.harnesses).toEqual(["devin_cloud_agent"]); + expect(quarantineReasons).toEqual([]); + }); + it("warns and uses defaults when persisted harnesses are not an array", async () => { const storage = new MemoryStorage(); storage.setItem("opensymphony.modelProfiles.v1", JSON.stringify({ diff --git a/packages/state/src/model-profiles.ts b/packages/state/src/model-profiles.ts index f63cd7065..5a51ebfdf 100644 --- a/packages/state/src/model-profiles.ts +++ b/packages/state/src/model-profiles.ts @@ -162,6 +162,7 @@ const authMethods = new Set([ const harnessKinds = new Set([ "openhands_agent_server", "codex_app_server", + "devin_cloud_agent", "rust_native", ]); diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index 9aa5cdbda..8294b1286 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -1,10 +1,11 @@ use std::path::PathBuf; use opensymphony::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_REMOTE_CONTAINMENT, DevinCloudAdapter, DevinCloudClient, - DevinCloudConfig, DevinHttpMethod, DevinLifecycleRequest, DevinRemoteWorkspaceBinding, - DevinRequestBuilder, NormalizedDevinEventKind, devin_event_summary, normalize_devin_event, - normalize_event_page, normalized_event_to_journal_record, + DEVIN_CLOUD_AGENT_KIND, DEVIN_REMOTE_CONTAINMENT, DEVIN_UNDISCRIMINATED_EVENT_KIND, + DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, DevinHttpMethod, DevinLifecycleRequest, + DevinRemoteWorkspaceBinding, DevinRequestBuilder, NormalizedDevinEventKind, + devin_event_summary, normalize_devin_event, normalize_event_page, + normalized_event_to_journal_record, }; use opensymphony::opensymphony_domain::HarnessAdapter; use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; @@ -66,6 +67,24 @@ fn remote_binding_keeps_local_workspace_as_evidence_only() { ) .is_err() ); + assert!( + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + "https://github.com/acme/api?token=secret", + None, + ) + .is_err() + ); + assert!( + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + "https://github.com/acme/api#token=secret", + None, + ) + .is_err() + ); } #[test] @@ -89,6 +108,12 @@ fn config_rejects_insecure_or_credentialed_endpoints() { ..DevinCloudConfig::default() }; assert!(bad_env.validate().is_err()); + + let decorated = DevinCloudConfig { + base_url: "https://api.devin.ai/v1?token=secret".into(), + ..DevinCloudConfig::default() + }; + assert!(decorated.validate().is_err()); } #[test] @@ -151,18 +176,26 @@ fn client_requires_the_configured_credential_environment_variable() { }) .is_ok() ); + + // A bearer credential is attached to every request, so an invalid endpoint + // must be rejected before the token is ever handed to the HTTP client. + let invalid = DevinCloudConfig { + base_url: "http://api.devin.ai/v1".into(), + ..DevinCloudConfig::default() + }; + assert!(DevinCloudClient::from_environment(&invalid, |_| Some("devin-token".into())).is_err()); } #[test] fn known_events_normalize_into_journal_records() { let event = normalize_devin_event(json!({ "type": "run.completed", + "session_id": "sess-1", "run_id": "run-9", "cursor": 7, "status": "finished", - })) - .expect("normalized event"); + })); assert_eq!(event.kind, NormalizedDevinEventKind::RunCompleted); assert_eq!(event.session_id.as_deref(), Some("sess-1")); @@ -187,7 +220,7 @@ fn unknown_events_retain_their_raw_payload() { "session_id": "sess-1", "payload": { "nested": [1, 2, 3] }, }); - let event = normalize_devin_event(raw.clone()).expect("normalized event"); + let event = normalize_devin_event(raw.clone()); assert_eq!(event.kind, NormalizedDevinEventKind::Unknown); assert_eq!(event.raw, raw); @@ -202,6 +235,16 @@ fn unknown_events_retain_their_raw_payload() { assert_eq!(record.payload.expect("payload")["raw_payload"], raw); } +#[test] +fn events_without_a_discriminator_are_preserved_as_unknown() { + let raw = json!({ "session_id": "sess-1", "details": { "nested": true } }); + let event = normalize_devin_event(raw.clone()); + + assert_eq!(event.kind, NormalizedDevinEventKind::Unknown); + assert_eq!(event.event_type, DEVIN_UNDISCRIMINATED_EVENT_KIND); + assert_eq!(event.raw, raw); +} + #[test] fn event_pages_accept_arrays_and_envelopes() { let page = json!({ @@ -213,10 +256,12 @@ fn event_pages_accept_arrays_and_envelopes() { }); let normalized = normalize_event_page(&page); - assert_eq!(normalized.len(), 2); + assert_eq!(normalized.len(), 3); assert_eq!(normalized[0].kind, NormalizedDevinEventKind::AgentMessage); assert_eq!(devin_event_summary(&normalized[0]), "starting work"); assert_eq!(normalized[1].kind, NormalizedDevinEventKind::ToolCall); + assert_eq!(normalized[2].kind, NormalizedDevinEventKind::Unknown); + assert_eq!(normalized[2].raw, json!({ "missing_type": true })); let bare = json!([{ "type": "run.started" }]); assert_eq!(normalize_event_page(&bare).len(), 1); From d1ead0beb6b7145a71531cb75a98fb8b6b27cc0a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:14:06 +0000 Subject: [PATCH 03/13] feat(devin): rebuild devin harness on the official API v3 contract Summary: - Vendor the official Devin v3 OpenAPI document and rebuild the client request builder, response models, and lifecycle against it, replacing the previously invented endpoints and event vocabulary. - Address v3 organization-scoped routes: /v3/self, session create/get/list (qs query document), cursor-paginated messages, operator message send, delete/archive, attachments, tag replacement. - Drive sessions by polling with event_id dedupe, status/status-detail transitions, and PR/ACU/structured-output/attachment run evidence. - Replace v1-only workflow config with devin.api and devin.session blocks (org id, playbook, knowledge/secret ids, ACU cap, tags, title, devin_mode, platform, resumable) and validate them. - Add tests/devin_cloud_agent_live.rs, an opt-in live suite that runs the real API, plus contract tests asserting every request path and body against the vendored spec. - Document the live verification, its cost, and what remains unverified. Rationale: - The first pass guessed the API surface; the endpoints, payload shape and event discriminators did not exist. Only the published contract plus a real request/response round trip can establish compatibility. - v3 only, with no v1 fallback, so there is a single implementation. - Capability stays available: false. Live protocol compatibility is not hosted-security, tenancy, or remote-workspace evidence. Tests: - cargo fmt --all; cargo check --all-targets; cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent (11 passed) - cargo test --lib devin; cargo test --test scheduler unavailable; cargo test --test gateway; cargo test --test gateway_schema - OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored: identity/listing and a real session create, poll, message, terminate+archive against https://api.devin.ai Co-Authored-By: Leonardo Gonzalez --- .../src/orchestrator_run/backends.rs | 20 +- .../contracts/devin-v3-openapi.yaml | 1800 +++++++++++++++++ crates/opensymphony-devin/src/lib.rs | 1379 +++++++++++-- crates/opensymphony-workflow/src/lib.rs | 104 +- crates/opensymphony-workflow/src/model.rs | 58 +- crates/opensymphony-workflow/src/resolve.rs | 106 +- docs/configuration.md | 80 +- docs/harness-adapter-compatibility.md | 64 +- tests/devin_cloud_agent.rs | 685 +++++-- tests/devin_cloud_agent_live.rs | 147 ++ 10 files changed, 4055 insertions(+), 388 deletions(-) create mode 100644 crates/opensymphony-devin/contracts/devin-v3-openapi.yaml create mode 100644 tests/devin_cloud_agent_live.rs diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 875626cae..3cc8f60ac 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -19,7 +19,9 @@ use crate::opensymphony_codex::{ codex_approval_request_from_event, codex_event_summary, normalize_server_notification, turn_status, }; -use crate::opensymphony_devin::{DEVIN_CLOUD_AGENT_KIND, DevinCloudAdapter, DevinCloudConfig}; +use crate::opensymphony_devin::{ + DEVIN_CLOUD_AGENT_KIND, DevinCloudAdapter, DevinCloudConfig, DevinMode, DevinSessionOptions, +}; use crate::opensymphony_domain::{ ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, IssueIdentifier, IssueState, IssueStateCategory, NormalizedIssue, RepositoryBindingOutcome, RetryEntry, @@ -5524,11 +5526,25 @@ fn transport_port_override(url: &Url) -> Result { /// only ever read from the worker environment by the Devin client. fn devin_route_unavailable_detail(workflow: &ResolvedWorkflow) -> String { let api = &workflow.extensions.devin.api; + let session = &workflow.extensions.devin.session; let adapter = DevinCloudAdapter::new(DevinCloudConfig { base_url: api.base_url.clone(), api_key_env: api.api_key_env.clone(), - event_poll_interval: Duration::from_millis(api.event_poll_interval_ms), + org_id: api.org_id.clone(), + org_id_env: api.org_id_env.clone(), + poll_interval: Duration::from_millis(api.poll_interval_ms), request_timeout: Duration::from_millis(api.request_timeout_ms), + session: DevinSessionOptions { + playbook_id: session.playbook_id.clone(), + knowledge_ids: session.knowledge_ids.clone(), + secret_ids: session.secret_ids.clone(), + max_acu_limit: session.max_acu_limit, + tags: session.tags.clone(), + title: session.title.clone(), + devin_mode: session.devin_mode.as_deref().and_then(DevinMode::parse), + platform: session.platform.clone(), + resumable: session.resumable, + }, }); format!( "{} endpoint `{}`, credential env `{}`; the local issue workspace stays evidence-only because Devin owns the remote execution workspace", diff --git a/crates/opensymphony-devin/contracts/devin-v3-openapi.yaml b/crates/opensymphony-devin/contracts/devin-v3-openapi.yaml new file mode 100644 index 000000000..88ad2e0eb --- /dev/null +++ b/crates/opensymphony-devin/contracts/devin-v3-openapi.yaml @@ -0,0 +1,1800 @@ +# Vendored subset of the published Devin API v3 OpenAPI document. +# Source: https://docs.devin.ai/v3-openapi.yaml (Devin API v3, version 3.0.0). +# Contains only the session and identity operations this harness calls, plus +# every schema they reference. +openapi: 3.1.0 +info: + description: Devin v3 API with Service User authentication and RBAC + title: Devin API v3 + version: 3.0.0 +servers: [] +security: +- bearerAuth: [] +paths: + /v3/organizations/{org_id}/sessions: + get: + description: List sessions. + operationId: handle_get_sessions_v3_organizations__org_id__sessions_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - in: query + name: qs + required: true + schema: + $ref: '#/components/schemas/SessionsQueryParams' + - in: query + name: devin_id + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse_SessionResponse_' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: List Sessions + tags: + - sessions + post: + description: Create a new session + operationId: handle_create_session_v3_organizations__org_id__sessions_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - in: query + name: devin_id + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionCreateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Create Session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}: + delete: + description: Terminate session + operationId: handle_terminate_session_v3_organizations__org_id__sessions__devin_id__delete + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + - description: Whether to archive the Devin session + in: query + name: archive + required: false + schema: + default: false + description: Whether to archive the Devin session + title: Archive + type: boolean + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Terminate Session + tags: + - sessions + get: + description: Get details of a specific session. + operationId: handle_get_session_v3_organizations__org_id__sessions__devin_id__get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Get Session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/archive: + post: + description: Archive session and put it to sleep if currently running + operationId: handle_archive_session_v3_organizations__org_id__sessions__devin_id__archive_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Archive Session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/attachments: + get: + description: List all attachments for a session. + operationId: handle_get_session_attachments_v3_organizations__org_id__sessions__devin_id__attachments_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + items: + $ref: '#/components/schemas/SessionAttachment' + title: Response Handle Get Session Attachments V3 Organizations Org Id Sessions Devin + Id Attachments Get + type: array + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: List session attachments + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/messages: + get: + description: List all messages for a session with cursor-based pagination, ordered chronologically. + operationId: handle_get_session_messages_v3_organizations__org_id__sessions__devin_id__messages_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + - in: query + name: after + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: After + - in: query + name: first + required: false + schema: + default: 100 + maximum: 200 + minimum: 1 + title: First + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse_SessionMessage_' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: List session messages + tags: + - sessions + post: + description: Send a message to an active session. The session will be automatically resumed if suspended. + operationId: handle_post_message_v3_organizations__org_id__sessions__devin_id__messages_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionMessageCreateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Send a message to a session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/tags: + get: + description: Get the tags for a specific session. + operationId: handle_get_session_tags_v3_organizations__org_id__sessions__devin_id__tags_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Get session tags + tags: + - sessions + post: + description: Append tags to a session (deduplicating with existing tags). + operationId: handle_append_session_tags_v3_organizations__org_id__sessions__devin_id__tags_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsUpdateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Append session tags + tags: + - sessions + put: + description: Replace all tags on a session. + operationId: handle_replace_session_tags_v3_organizations__org_id__sessions__devin_id__tags_put + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsUpdateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Replace session tags + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/unarchive: + post: + description: Unarchive session (does not cascade to child sessions) + operationId: handle_unarchive_session_v3_organizations__org_id__sessions__devin_id__unarchive_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Unarchive Session + tags: + - sessions + /v3/self: + get: + operationId: handle_get_self_v3_self_get + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ServiceUserSelf' + - $ref: '#/components/schemas/PatUserSelf' + - $ref: '#/components/schemas/DevinBrainUserSelf' + - $ref: '#/components/schemas/WindsurfSessionUserSelf' + title: Response Handle Get Self V3 Self Get + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Get Self + tags: + - self +components: + schemas: + DevinBrainUserSelf: + description: Identity information for an authenticated Devin brain session. + properties: + creator_service_user_id: + anyOf: + - type: string + - type: 'null' + title: Creator Service User Id + devin_id: + title: Devin Id + type: string + org_id: + title: Org Id + type: string + principal_type: + const: devin_brain + title: Principal Type + type: string + user_id: + title: User Id + type: string + required: + - principal_type + - devin_id + - org_id + - user_id + title: DevinBrainUserSelf + type: object + PaginatedResponse_SessionMessage_: + properties: + end_cursor: + anyOf: + - type: string + - type: 'null' + description: Cursor to fetch the next page, or None if this is the last page. + title: End Cursor + has_next_page: + default: false + description: Whether there are more items available after this page. + title: Has Next Page + type: boolean + items: + items: + $ref: '#/components/schemas/SessionMessage' + title: Items + type: array + total: + anyOf: + - type: integer + - type: 'null' + description: Optional total count (can be omitted for performance). + title: Total + required: + - items + title: PaginatedResponse[SessionMessage] + type: object + PaginatedResponse_SessionResponse_: + properties: + end_cursor: + anyOf: + - type: string + - type: 'null' + description: Cursor to fetch the next page, or None if this is the last page. + title: End Cursor + has_next_page: + default: false + description: Whether there are more items available after this page. + title: Has Next Page + type: boolean + items: + items: + $ref: '#/components/schemas/SessionResponse' + title: Items + type: array + total: + anyOf: + - type: integer + - type: 'null' + description: Optional total count (can be omitted for performance). + title: Total + required: + - items + title: PaginatedResponse[SessionResponse] + type: object + PatUserSelf: + description: Identity information for an authenticated PAT user. + properties: + api_key_id: + title: Api Key Id + type: string + api_key_name: + title: Api Key Name + type: string + org_id: + anyOf: + - type: string + - type: 'null' + title: Org Id + principal_type: + const: pat_user + title: Principal Type + type: string + user_id: + title: User Id + type: string + user_name: + title: User Name + type: string + required: + - principal_type + - user_id + - user_name + - api_key_id + - api_key_name + title: PatUserSelf + type: object + ProblemDetail: + description: 'RFC 9457 application/problem+json error body for the v3 API. + + + detail is retained from the legacy {"detail": ...} body for back-compat; the + + other members are additive. errors carries field-level validation failures + + (422 only).' + properties: + detail: + anyOf: + - type: string + - type: 'null' + description: A human-readable explanation specific to this occurrence. + title: Detail + errors: + anyOf: + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + description: Field-level validation errors (422 responses only). + title: Errors + instance: + anyOf: + - type: string + - type: 'null' + description: A URI reference (the request path) for this occurrence. + title: Instance + status: + description: The HTTP status code. + title: Status + type: integer + title: + description: A short, human-readable summary of the problem type. + title: Title + type: string + type: + default: about:blank + description: A URI reference identifying the problem type. + title: Type + type: string + required: + - title + - status + title: ProblemDetail + type: object + ServiceUserSelf: + description: Identity information for an authenticated service user. + properties: + org_id: + anyOf: + - type: string + - type: 'null' + title: Org Id + principal_type: + const: service_user + title: Principal Type + type: string + service_user_id: + title: Service User Id + type: string + service_user_name: + title: Service User Name + type: string + required: + - principal_type + - service_user_id + - service_user_name + title: ServiceUserSelf + type: object + SessionAttachment: + description: An attachment sent during a session. + properties: + attachment_id: + title: Attachment Id + type: string + content_type: + anyOf: + - type: string + - type: 'null' + title: Content Type + name: + title: Name + type: string + source: + enum: + - devin + - user + title: Source + type: string + url: + title: Url + type: string + required: + - attachment_id + - name + - url + - source + title: SessionAttachment + type: object + SessionCreateRequest: + properties: + attachment_urls: + anyOf: + - items: + format: uri + maxLength: 2083 + minLength: 1 + type: string + type: array + - type: 'null' + title: Attachment Urls + bypass_approval: + anyOf: + - type: boolean + - type: 'null' + title: Bypass Approval + child_playbook_id: + anyOf: + - type: string + - type: 'null' + title: Child Playbook Id + create_as_user_id: + anyOf: + - type: string + - type: 'null' + title: Create As User Id + devin_mode: + anyOf: + - enum: + - normal + - fast + - lite + - ultra + - fusion + type: string + - type: 'null' + description: Override the Devin agent mode for the session. 'normal' uses the default Agent + mode, 'fast' uses Fast mode, 'lite' uses Devin Lite, 'ultra' uses Devin Ultra, and 'fusion' + uses Fusion. Preview modes are subject to the same feature flag and enterprise agent preview + restrictions as the web app. + title: Devin Mode + knowledge_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Knowledge Ids + max_acu_limit: + anyOf: + - type: integer + - type: 'null' + title: Max Acu Limit + platform: + anyOf: + - type: string + - type: 'null' + description: "Override the VM platform for the session (e.g. 'windows'), or the name of an outpost\ + \ (BYOB) pool to run the session on one of your own machines. When omitted (or set to 'inherit'),\ + \ a session created by a parent Devin inherits the parent's placement \u2014 both its platform\ + \ and its outpost pool, so an outpost-placed parent spawns children on the same pool; otherwise\ + \ the organization default is used. Any value must match either a platform configured for\ + \ your organization or an outpost pool name (case-insensitive) \u2014 built-in platforms take\ + \ priority when a name matches both. Unrecognized values are rejected with a 400 whose error\ + \ body lists the available platform labels and outpost pool names for the org." + title: Platform + playbook_id: + anyOf: + - type: string + - type: 'null' + title: Playbook Id + prompt: + title: Prompt + type: string + repos: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Repos + resumable: + default: true + description: Whether to preserve the session's VM state after it stops so the session can be + resumed. Set to false for disposable sessions. + title: Resumable + type: boolean + secret_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Secret Ids + session_links: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Session Links + session_secrets: + anyOf: + - items: + $ref: '#/components/schemas/SessionSecretInput' + type: array + - type: 'null' + title: Session Secrets + structured_output_required: + anyOf: + - type: boolean + - type: 'null' + description: "When true (default), the agent MUST call provide_structured_output with is_final=true\ + \ before its turn ends. When false, the tool is available but not required \u2014 it is not\ + \ guaranteed to be called in a given turn." + title: Structured Output Required + structured_output_schema: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + description: JSON Schema (Draft 7) for validating structured output. Max 64KB. Must be self-contained + (no external $ref). + title: Structured Output Schema + tags: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Tags + title: + anyOf: + - type: string + - type: 'null' + title: Title + required: + - prompt + title: SessionCreateRequest + type: object + SessionMessage: + description: A message in a Devin session. + properties: + created_at: + title: Created At + type: integer + event_id: + title: Event Id + type: string + message: + title: Message + type: string + source: + enum: + - devin + - user + title: Source + type: string + required: + - event_id + - source + - message + - created_at + title: SessionMessage + type: object + SessionMessageCreateRequest: + properties: + attachment_urls: + anyOf: + - items: + format: uri + maxLength: 2083 + minLength: 1 + type: string + type: array + - type: 'null' + title: Attachment Urls + message: + title: Message + type: string + message_as_user_id: + anyOf: + - type: string + - type: 'null' + title: Message As User Id + required: + - message + title: SessionMessageCreateRequest + type: object + SessionPullRequest: + properties: + pr_state: + anyOf: + - type: string + - type: 'null' + title: Pr State + pr_url: + title: Pr Url + type: string + required: + - pr_url + - pr_state + title: SessionPullRequest + type: object + SessionResponse: + properties: + acus_consumed: + title: Acus Consumed + type: number + automation_id: + anyOf: + - type: string + - type: 'null' + description: ID of the automation that created this session, if any. + title: Automation Id + category: + anyOf: + - enum: + - bug_fixing + - ci_cd_and_devops + - code_quality_and_security + - code_review + - code_review_and_analysis + - data_and_automation + - documentation_and_content + - feature_development + - migrations_and_upgrades + - other + - production_investigation + - refactoring_and_optimization + - research_and_exploration + - security + - unit_test_generation + type: string + - type: 'null' + description: The session's assigned use-case category, if categorisation has run. Only populated + on get/list endpoints. + title: Category + child_session_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Child Session Ids + created_at: + title: Created At + type: integer + devin_mode: + anyOf: + - enum: + - normal + - fast + - lite + - ultra + - fusion + type: string + - type: 'null' + description: The Devin agent mode the session was created with, after resolving org/user defaults + and parent-session inheritance. None when the session runs an agent preview that has no mode + equivalent. + title: Devin Mode + is_archived: + default: false + title: Is Archived + type: boolean + org_id: + title: Org Id + type: string + origin: + anyOf: + - enum: + - webapp + - slack + - teams + - api + - linear + - jira + - automation + - cli + - desktop + - code_scan + - other + type: string + - type: 'null' + description: The origin from which the session was created. + title: Origin + parent_session_id: + anyOf: + - type: string + - type: 'null' + title: Parent Session Id + playbook_id: + anyOf: + - type: string + - type: 'null' + title: Playbook Id + pull_requests: + items: + $ref: '#/components/schemas/SessionPullRequest' + title: Pull Requests + type: array + service_user_id: + anyOf: + - type: string + - type: 'null' + title: Service User Id + session_id: + title: Session Id + type: string + status: + enum: + - new + - claimed + - running + - exit + - error + - suspended + - resuming + title: Status + type: string + status_detail: + anyOf: + - enum: + - working + - waiting_for_user + - waiting_for_approval + - finished + - inactivity + - user_request + - usage_limit_exceeded + - out_of_credits + - out_of_quota + - no_quota_allocation + - payment_declined + - org_usage_limit_exceeded + - user_usage_limit_exceeded + - total_session_limit_exceeded + - error + type: string + - type: 'null' + description: 'Additional detail about the session''s current status. When status is ''running'': + ''working'' (actively working), ''waiting_for_user'' (needs user input), ''waiting_for_approval'' + (awaiting action approval in safe mode), or ''finished'' (task complete). When status is ''suspended'': + the reason for suspension such as ''inactivity'', ''user_request'', ''usage_limit_exceeded'', + ''out_of_credits'', ''out_of_quota'', ''no_quota_allocation'', ''payment_declined'', ''org_usage_limit_exceeded'', + ''user_usage_limit_exceeded'', ''total_session_limit_exceeded'', or ''error''. Only populated + on get/list endpoints.' + title: Status Detail + structured_output: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + description: Validated structured output from the session. Only populated on get/list endpoints. + title: Structured Output + subcategory: + anyOf: + - type: string + - type: 'null' + description: The session's assigned subcategory display name. 'Other' when a category is set + but no subcategory was assigned or resolved. Only populated on get/list endpoints. + title: Subcategory + tags: + items: + type: string + title: Tags + type: array + title: + anyOf: + - type: string + - type: 'null' + title: Title + updated_at: + title: Updated At + type: integer + url: + title: Url + type: string + user_id: + anyOf: + - type: string + - type: 'null' + title: User Id + required: + - session_id + - url + - status + - tags + - org_id + - created_at + - updated_at + - acus_consumed + - pull_requests + title: SessionResponse + type: object + SessionSecretInput: + description: Input model for a session secret provided via API. + properties: + key: + maxLength: 256 + minLength: 1 + title: Key + type: string + sensitive: + default: true + title: Sensitive + type: boolean + value: + maxLength: 65536 + title: Value + type: string + required: + - key + - value + title: SessionSecretInput + type: object + SessionTagsResponse: + properties: + tags: + items: + type: string + title: Tags + type: array + required: + - tags + title: SessionTagsResponse + type: object + SessionTagsUpdateRequest: + properties: + tags: + items: + type: string + maxItems: 50 + title: Tags + type: array + required: + - tags + title: SessionTagsUpdateRequest + type: object + SessionsQueryParams: + properties: + after: + anyOf: + - type: string + - type: 'null' + title: After + automation_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + description: Filter to sessions created by any of these automations + title: Automation Ids + category: + anyOf: + - enum: + - bug_fixing + - ci_cd_and_devops + - code_quality_and_security + - code_review + - code_review_and_analysis + - data_and_automation + - documentation_and_content + - feature_development + - migrations_and_upgrades + - other + - production_investigation + - refactoring_and_optimization + - research_and_exploration + - security + - unit_test_generation + type: string + - type: 'null' + title: Category + created_after: + anyOf: + - type: integer + - type: 'null' + title: Created After + created_before: + anyOf: + - type: integer + - type: 'null' + title: Created Before + first: + default: 100 + maximum: 200.0 + minimum: 1.0 + title: First + type: integer + is_archived: + anyOf: + - type: boolean + - type: 'null' + title: Is Archived + origins: + anyOf: + - items: + enum: + - webapp + - slack + - teams + - api + - linear + - jira + - automation + - cli + - desktop + - code_scan + - other + type: string + type: array + - type: 'null' + title: Origins + parent_session_id: + anyOf: + - type: string + - type: 'null' + description: Filter to child sessions spawned by this parent session + title: Parent Session Id + playbook_id: + anyOf: + - type: string + - type: 'null' + title: Playbook Id + repo_names: + anyOf: + - items: + type: string + type: array + - type: 'null' + description: Filter by repository names (e.g., 'owner/repo') + title: Repo Names + schedule_id: + anyOf: + - type: string + - type: 'null' + title: Schedule Id + service_user_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Service User Ids + session_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Session Ids + tags: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Tags + updated_after: + anyOf: + - type: integer + - type: 'null' + title: Updated After + updated_before: + anyOf: + - type: integer + - type: 'null' + title: Updated Before + user_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: User Ids + title: SessionsQueryParams + type: object + WindsurfSessionUserSelf: + description: Identity information for an authenticated Windsurf session user. + properties: + org_id: + title: Org Id + type: string + principal_type: + const: windsurf_session + title: Principal Type + type: string + user_id: + title: User Id + type: string + user_name: + anyOf: + - type: string + - type: 'null' + title: User Name + required: + - principal_type + - user_id + - org_id + title: WindsurfSessionUserSelf + type: object + securitySchemes: + bearerAuth: + description: 'Service User credential (prefix: cog_)' + scheme: bearer + type: http diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index fa52d7afa..8fb9f3fee 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -1,4 +1,4 @@ -//! Devin cloud harness helpers. +//! Devin cloud harness client. //! //! Devin is a remote implementation agent: the agent runtime, the checkout, and //! the shell all live in a Devin-owned cloud workspace. OpenSymphony therefore @@ -7,15 +7,36 @@ //! [`DevinRemoteWorkspaceBinding`] instead of handing the local checkout to the //! harness as an execution cwd. //! -//! The capability advertised by this adapter is intentionally unavailable: -//! remote workspace ownership, event normalization, auth/secret handling, and -//! tenant isolation still need hardening evidence before hosted routing can be +//! The wire contract implemented here is the published Devin API v3 +//! (`contracts/devin-v3-openapi.yaml`, a session/identity subset mirrored from +//! ). Consequences of the real contract: +//! +//! * Every session route is organization-scoped: +//! `/v3/organizations/{org_id}/sessions/...`, authenticated with a service +//! user credential (`cog_` prefix). The organization ID is not a secret and +//! may be configured directly or discovered through `GET /v3/self`. +//! * There is no run resource and no event stream. A session is created with a +//! prompt, progress is read from cursor-paginated +//! `GET .../sessions/{devin_id}/messages`, and lifecycle state is read from +//! `status` / `status_detail` on the session resource. +//! * Repository binding is a real request field (`repos`), expressed as +//! `owner/name` entries; branch intent still travels in the prompt because +//! the API has no branch field. +//! +//! The capability advertised by this adapter remains unavailable: hosted +//! security (secret injection, tenant isolation), remote artifact retrieval, +//! and live-run evidence still need hardening before scheduler routing can be //! presented as production-ready. -use std::{fmt, path::PathBuf, time::Duration}; +use std::{ + collections::HashSet, + fmt, + path::PathBuf, + time::{Duration, Instant}, +}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use url::Url; use crate::{ @@ -28,13 +49,23 @@ use crate::{ }; pub const DEVIN_CLOUD_AGENT_KIND: &str = "devin_cloud_agent"; -pub const DEVIN_CLOUD_API_CONTRACT: &str = "devin-cloud-api-v1"; -pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai/v1"; -pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "DEVIN_API_KEY"; -pub const DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS: u64 = 2_000; +/// Wire contract implemented by this module, pinned to the vendored spec. +pub const DEVIN_CLOUD_API_CONTRACT: &str = "devin-api-v3"; +/// Devin API origin. Version-prefixed paths are appended by the request builder. +pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai"; +/// Devin API v3 authenticates with a `cog_` service-user token. +pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "COG_SERVICE_USER_TOKEN"; +pub const DEFAULT_DEVIN_ORG_ID_ENV: &str = "DEVIN_ORG_ID"; +pub const DEFAULT_DEVIN_SESSION_POLL_INTERVAL_MS: u64 = 5_000; pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; - -/// Synthetic event type recorded when a Devin event carries no recognized +/// Maximum number of tags Devin accepts on a session. +pub const DEVIN_MAX_SESSION_TAGS: usize = 50; +/// Maximum page size accepted by the paginated v3 collection endpoints. +pub const DEVIN_MAX_PAGE_SIZE: u32 = 200; +/// Prefix used for the tag that correlates a Devin session with an issue. +pub const DEVIN_CORRELATION_TAG_PREFIX: &str = "opensymphony"; + +/// Synthetic event type recorded when a Devin payload carries no recognized /// discriminator field, so the payload is preserved rather than dropped. pub const DEVIN_UNDISCRIMINATED_EVENT_KIND: &str = "devin.undiscriminated_event"; @@ -55,6 +86,18 @@ pub enum DevinConfigError { DecoratedBaseUrl, #[error("devin.api.api_key_env must be an environment variable name")] InvalidApiKeyEnv, + #[error("devin.api.org_id must look like `org-...`")] + InvalidOrgId, + #[error("devin.api.org_id_env must be an environment variable name")] + InvalidOrgIdEnv, + #[error("the organization id read from the environment must look like `org-...`")] + InvalidOrgIdFromEnvironment, + #[error("devin.session.max_acu_limit must be greater than zero")] + InvalidAcuLimit, + #[error("devin sessions accept at most {DEVIN_MAX_SESSION_TAGS} tags")] + TooManyTags, + #[error("devin session tags must not be blank")] + BlankTag, #[error("devin repository binding must be an absolute https git URL: {0}")] InvalidRepositoryUrl(String), } @@ -63,6 +106,10 @@ pub enum DevinConfigError { pub enum DevinClientError { #[error("devin api credential `{env}` is not set in the worker environment")] MissingCredential { env: String }, + #[error( + "devin organization id is not configured and could not be resolved from the credential" + )] + MissingOrgId, #[error("failed to build devin api url from `{base_url}` and `{path}`: {source}")] InvalidUrl { base_url: String, @@ -72,12 +119,17 @@ pub enum DevinClientError { }, #[error("devin api transport error: {0}")] Transport(String), - #[error("devin api returned status {status}: {body}")] - Status { status: u16, body: String }, + #[error("devin api returned status {status}: {problem}")] + Status { status: u16, problem: String }, #[error("failed to decode devin api response: {0}")] Decode(String), #[error("invalid devin api configuration: {0}")] InvalidConfig(String), + #[error("devin session `{session_id}` did not settle within {elapsed:?}")] + PollTimeout { + session_id: String, + elapsed: Duration, + }, } /// API token wrapper that never renders its secret value. @@ -100,12 +152,55 @@ impl fmt::Debug for DevinApiToken { } } +/// Session creation options that map onto documented `SessionCreateRequest` +/// fields. Everything here is operator configuration, never credential values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinSessionOptions { + pub playbook_id: Option, + pub knowledge_ids: Option>, + /// References to organization secrets Devin injects into the session. The + /// values live in Devin, not in OpenSymphony configuration. + pub secret_ids: Option>, + pub max_acu_limit: Option, + pub tags: Vec, + pub title: Option, + pub devin_mode: Option, + /// VM platform or outpost pool override. + pub platform: Option, + /// Preserve VM state after the session stops so it can be resumed. + pub resumable: bool, +} + +impl Default for DevinSessionOptions { + fn default() -> Self { + Self { + playbook_id: None, + knowledge_ids: None, + secret_ids: None, + max_acu_limit: None, + tags: Vec::new(), + title: None, + devin_mode: None, + platform: None, + // The documented `resumable` default in the v3 contract. + resumable: true, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevinCloudConfig { pub base_url: String, pub api_key_env: String, - pub event_poll_interval: Duration, + /// Organization that owns the sessions. Resolved from `GET /v3/self` when + /// unset; this is an identifier, not a credential. + pub org_id: Option, + /// Environment variable consulted for the organization id when `org_id` is + /// unset. + pub org_id_env: String, + pub poll_interval: Duration, pub request_timeout: Duration, + pub session: DevinSessionOptions, } impl Default for DevinCloudConfig { @@ -113,8 +208,11 @@ impl Default for DevinCloudConfig { Self { base_url: DEFAULT_DEVIN_API_BASE_URL.to_owned(), api_key_env: DEFAULT_DEVIN_API_KEY_ENV.to_owned(), - event_poll_interval: Duration::from_millis(DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS), + org_id: None, + org_id_env: DEFAULT_DEVIN_ORG_ID_ENV.to_owned(), + poll_interval: Duration::from_millis(DEFAULT_DEVIN_SESSION_POLL_INTERVAL_MS), request_timeout: Duration::from_millis(DEFAULT_DEVIN_REQUEST_TIMEOUT_MS), + session: DevinSessionOptions::default(), } } } @@ -138,6 +236,23 @@ impl DevinCloudConfig { if !is_environment_name(&self.api_key_env) { return Err(DevinConfigError::InvalidApiKeyEnv); } + if let Some(org_id) = self.org_id.as_deref() + && !is_org_id(org_id) + { + return Err(DevinConfigError::InvalidOrgId); + } + if !self.org_id_env.is_empty() && !is_environment_name(&self.org_id_env) { + return Err(DevinConfigError::InvalidOrgIdEnv); + } + if self.session.max_acu_limit == Some(0) { + return Err(DevinConfigError::InvalidAcuLimit); + } + if self.session.tags.len() > DEVIN_MAX_SESSION_TAGS { + return Err(DevinConfigError::TooManyTags); + } + if self.session.tags.iter().any(|tag| tag.trim().is_empty()) { + return Err(DevinConfigError::BlankTag); + } Ok(()) } @@ -154,11 +269,23 @@ fn is_environment_name(value: &str) -> bool { .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) } +fn is_org_id(value: &str) -> bool { + value.len() > "org-".len() + && value.starts_with("org-") + && value + .chars() + .all(|character| character == '-' || character.is_ascii_alphanumeric()) +} + /// Repository binding for a Devin-owned remote workspace. /// /// `local_evidence_path` is the sanitized OpenSymphony issue workspace. It holds /// run manifests, journals, and evidence for the run; it is *not* the directory /// Devin executes in and must never be advertised as such. +/// +/// The v3 create-session request carries repositories as `owner/name` entries, +/// so the binding renders both the request field and the prompt context that +/// communicates branch intent (which the API has no field for). #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevinRemoteWorkspaceBinding { pub issue_workspace_key: String, @@ -205,6 +332,48 @@ impl DevinRemoteWorkspaceBinding { pub fn effective_containment(&self) -> &'static str { DEVIN_REMOTE_CONTAINMENT } + + /// `owner/name` form expected by the `repos` request field. + pub fn repo_name(&self) -> Option { + let parsed = Url::parse(&self.repository_url).ok()?; + let segments: Vec<&str> = parsed + .path_segments()? + .filter(|segment| !segment.is_empty()) + .collect(); + let [.., owner, name] = segments.as_slice() else { + return None; + }; + Some(format!("{owner}/{}", name.trim_end_matches(".git"))) + } + + /// Tag that correlates a Devin session with the OpenSymphony issue, used to + /// recover an in-flight session through the tag filter on list sessions. + pub fn correlation_tag(&self) -> String { + format!( + "{DEVIN_CORRELATION_TAG_PREFIX}:{}", + self.issue_workspace_key + ) + } + + /// Repository and branch context rendered into the session prompt. The + /// create-session request has no branch field, so branch intent is stated + /// here. + pub fn prompt_context(&self) -> String { + let mut context = format!("Repository: {}\n", self.repository_url); + if let Some(branch) = self.base_branch.as_deref() { + context.push_str(&format!("Base branch: {branch}\n")); + } + context.push_str(&format!( + "OpenSymphony issue workspace: {} (orchestrator-side evidence only; work in your own Devin workspace)\n", + self.issue_workspace_key + )); + context + } + + /// Prompt for a Devin session: repository context followed by the task. + pub fn compose_prompt(&self, task_prompt: &str) -> String { + format!("{}\n{}", self.prompt_context(), task_prompt.trim()) + } } #[derive(Debug, Clone, Default)] @@ -221,10 +390,6 @@ impl DevinCloudAdapter { &self.config } - pub fn requests(&self) -> DevinRequestBuilder { - DevinRequestBuilder::new(self.config.base_url.clone()) - } - /// Feature gaps that block hosted Devin routing today. pub fn unavailability_reason(&self) -> String { let capability = self.capabilities(); @@ -246,10 +411,318 @@ impl HarnessAdapter for DevinCloudAdapter { } } +// --------------------------------------------------------------------------- +// Wire models (Devin API v3) +// --------------------------------------------------------------------------- + +/// `devin_mode` from `SessionCreateRequest`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevinMode { + Normal, + Fast, + Lite, + Ultra, + Fusion, +} + +impl DevinMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Fast => "fast", + Self::Lite => "lite", + Self::Ultra => "ultra", + Self::Fusion => "fusion", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "normal" => Some(Self::Normal), + "fast" => Some(Self::Fast), + "lite" => Some(Self::Lite), + "ultra" => Some(Self::Ultra), + "fusion" => Some(Self::Fusion), + _ => None, + } + } +} + +/// `SessionCreateRequest` from the Devin v3 OpenAPI document. +/// +/// Fields OpenSymphony does not drive (`session_secrets`, `create_as_user_id`, +/// `bypass_approval`, attachment uploads) are deliberately omitted: they are +/// part of the hosted-security work that still needs hardening evidence. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SessionCreateRequest { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub repos: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub playbook_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub knowledge_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub secret_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub max_acu_limit: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub devin_mode: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub resumable: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub structured_output_schema: Option, +} + +/// `status` from `SessionResponse`. Unknown values are kept so a newer Devin +/// status never fails decoding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevinSessionStatus { + New, + Claimed, + Running, + Exit, + Error, + Suspended, + Resuming, + #[serde(untagged)] + Other(String), +} + +impl DevinSessionStatus { + pub fn as_str(&self) -> &str { + match self { + Self::New => "new", + Self::Claimed => "claimed", + Self::Running => "running", + Self::Exit => "exit", + Self::Error => "error", + Self::Suspended => "suspended", + Self::Resuming => "resuming", + Self::Other(value) => value, + } + } + + /// Statuses Devin will not leave without operator or platform action. + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Exit | Self::Error | Self::Suspended) + } +} + +/// `status_detail` from `SessionResponse`. Kept as a string so unlisted detail +/// values survive decoding; the interesting ones are exposed as predicates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DevinStatusDetail(pub String); + +impl DevinStatusDetail { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Devin finished the task but the session is still running. + pub fn is_finished(&self) -> bool { + self.0 == "finished" + } + + /// Devin needs operator input or an action approval before continuing. + pub fn is_waiting_on_operator(&self) -> bool { + matches!(self.0.as_str(), "waiting_for_user" | "waiting_for_approval") + } +} + +/// `SessionPullRequest` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPullRequest { + pub pr_url: String, + pub pr_state: Option, +} + +/// `SessionResponse` from the Devin v3 OpenAPI document. Unknown fields are +/// retained so newer Devin payloads replay unchanged. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionResponse { + pub session_id: String, + pub url: String, + pub status: DevinSessionStatus, + #[serde(default)] + pub status_detail: Option, + #[serde(default)] + pub tags: Vec, + pub org_id: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(default)] + pub acus_consumed: f64, + #[serde(default)] + pub pull_requests: Vec, + #[serde(default)] + pub structured_output: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub is_archived: bool, + #[serde(default)] + pub devin_mode: Option, + #[serde(default)] + pub parent_session_id: Option, + #[serde(default)] + pub playbook_id: Option, + #[serde(flatten, default)] + pub extra: Map, +} + +impl SessionResponse { + /// The session is done as far as the orchestrator is concerned: either the + /// VM stopped, or Devin reported the task finished. + pub fn is_settled(&self) -> bool { + self.status.is_terminal() + || self + .status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_finished) + } + + pub fn is_waiting_on_operator(&self) -> bool { + self.status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_waiting_on_operator) + } +} + +/// `SessionMessage` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionMessage { + pub event_id: String, + pub source: String, + pub message: String, + pub created_at: i64, + #[serde(flatten, default)] + pub extra: Map, +} + +/// `SessionMessageCreateRequest` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMessageCreateRequest { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub attachment_urls: Option>, +} + +/// `PaginatedResponse[T]` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct PaginatedResponse { + pub items: Vec, + #[serde(default)] + pub end_cursor: Option, + #[serde(default)] + pub has_next_page: bool, + #[serde(default)] + pub total: Option, +} + +/// `SessionAttachment` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionAttachment { + pub attachment_id: String, + pub name: String, + pub url: String, + pub source: String, + #[serde(default)] + pub content_type: Option, +} + +/// `SessionTagsUpdateRequest` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionTagsUpdateRequest { + pub tags: Vec, +} + +/// Subset of the `GET /v3/self` response that identifies the calling principal. +/// +/// The four documented principal shapes all optionally carry `org_id`, which is +/// the only field this harness needs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinSelfResponse { + #[serde(default)] + pub principal_type: Option, + #[serde(default)] + pub org_id: Option, + #[serde(default)] + pub service_user_id: Option, + #[serde(default)] + pub service_user_name: Option, +} + +/// `ProblemDetail` (RFC 9457) error body returned by every v3 route. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevinProblemDetail { + pub title: String, + pub status: i64, + #[serde(default)] + pub detail: Option, + #[serde(default, rename = "type")] + pub problem_type: Option, + #[serde(default)] + pub instance: Option, + #[serde(default)] + pub errors: Option>>, +} + +impl fmt::Display for DevinProblemDetail { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.detail.as_deref() { + Some(detail) => write!(formatter, "{}: {detail}", self.title), + None => formatter.write_str(&self.title), + } + } +} + +/// Filters for `GET /v3/organizations/{org_id}/sessions`, serialized into the +/// documented `qs` query parameter. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionsQueryParams { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub session_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub repo_names: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub is_archived: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub first: Option, +} + +impl SessionsQueryParams { + pub fn by_tag(tag: impl Into) -> Self { + Self { + tags: Some(vec![tag.into()]), + ..Self::default() + } + } +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DevinHttpMethod { Get, Post, + Put, + Delete, } impl DevinHttpMethod { @@ -257,18 +730,41 @@ impl DevinHttpMethod { match self { Self::Get => "GET", Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", } } } +/// Documented operations this client uses, named after the OpenAPI `summary` +/// values they map to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DevinLifecycleRequest { - SessionCreate, - SessionResume, - MessageSend, - RunStart, - RunCancel, - EventFetch, +pub enum DevinOperation { + GetSelf, + CreateSession, + GetSession, + ListSessions, + ListSessionMessages, + SendSessionMessage, + DeleteSession, + ListSessionAttachments, + UpdateSessionTags, +} + +impl DevinOperation { + pub fn as_str(self) -> &'static str { + match self { + Self::GetSelf => "get_self", + Self::CreateSession => "create_session", + Self::GetSession => "get_session", + Self::ListSessions => "list_sessions", + Self::ListSessionMessages => "list_session_messages", + Self::SendSessionMessage => "send_session_message", + Self::DeleteSession => "delete_session", + Self::ListSessionAttachments => "list_session_attachments", + Self::UpdateSessionTags => "update_session_tags", + } + } } /// Transport-agnostic description of a Devin cloud API call. @@ -277,21 +773,24 @@ pub enum DevinLifecycleRequest { /// descriptions stay safe to log, snapshot, and unit test. #[derive(Debug, Clone, PartialEq)] pub struct DevinRequest { - pub lifecycle: DevinLifecycleRequest, + pub operation: DevinOperation, pub method: DevinHttpMethod, pub path: String, pub body: Option, } +/// Builds organization-scoped v3 request descriptions. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevinRequestBuilder { base_url: String, + org_id: String, } impl DevinRequestBuilder { - pub fn new(base_url: impl Into) -> Self { + pub fn new(base_url: impl Into, org_id: impl Into) -> Self { Self { base_url: base_url.into(), + org_id: org_id.into(), } } @@ -299,90 +798,140 @@ impl DevinRequestBuilder { &self.base_url } - pub fn create_session( - &self, - prompt: impl Into, - binding: &DevinRemoteWorkspaceBinding, - idempotency_key: Option, - ) -> DevinRequest { - let mut body = json!({ - "prompt": prompt.into(), - "repository": { - "url": binding.repository_url, - "base_branch": binding.base_branch, - }, - }); - if let Some(key) = idempotency_key - && let Some(object) = body.as_object_mut() - { - object.insert("idempotency_key".into(), Value::String(key)); + pub fn org_id(&self) -> &str { + &self.org_id + } + + fn sessions_path(&self) -> String { + format!( + "/v3/organizations/{}/sessions", + encode_path_segment(&self.org_id) + ) + } + + fn session_path(&self, devin_id: &str) -> String { + format!("{}/{}", self.sessions_path(), encode_path_segment(devin_id)) + } + + /// `GET /v3/self` + pub fn get_self(&self) -> DevinRequest { + DevinRequest { + operation: DevinOperation::GetSelf, + method: DevinHttpMethod::Get, + path: "/v3/self".into(), + body: None, } + } + /// `POST /v3/organizations/{org_id}/sessions` + pub fn create_session(&self, request: &SessionCreateRequest) -> DevinRequest { DevinRequest { - lifecycle: DevinLifecycleRequest::SessionCreate, + operation: DevinOperation::CreateSession, method: DevinHttpMethod::Post, - path: "/sessions".into(), - body: Some(body), + path: self.sessions_path(), + body: Some(serde_json::to_value(request).unwrap_or_else(|_| json!({}))), } } - pub fn resume_session(&self, session_id: &str) -> DevinRequest { + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}` + pub fn get_session(&self, devin_id: &str) -> DevinRequest { DevinRequest { - lifecycle: DevinLifecycleRequest::SessionResume, - method: DevinHttpMethod::Post, - path: format!("/sessions/{}/resume", encode_path_segment(session_id)), - body: Some(json!({})), + operation: DevinOperation::GetSession, + method: DevinHttpMethod::Get, + path: self.session_path(devin_id), + body: None, } } - pub fn send_message(&self, session_id: &str, message: impl Into) -> DevinRequest { + /// `GET /v3/organizations/{org_id}/sessions?qs=` + /// + /// The documented `qs` parameter is a single object-valued query parameter, + /// so the filter set is JSON-encoded into it. + pub fn list_sessions(&self, query: &SessionsQueryParams) -> DevinRequest { + let encoded = serde_json::to_string(query).unwrap_or_else(|_| "{}".to_owned()); DevinRequest { - lifecycle: DevinLifecycleRequest::MessageSend, - method: DevinHttpMethod::Post, - path: format!("/sessions/{}/messages", encode_path_segment(session_id)), - body: Some(json!({ "message": message.into() })), + operation: DevinOperation::ListSessions, + method: DevinHttpMethod::Get, + path: format!( + "{}?qs={}", + self.sessions_path(), + encode_query_value(&encoded) + ), + body: None, } } - pub fn start_run(&self, session_id: &str, prompt: impl Into) -> DevinRequest { + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}/messages` + pub fn list_messages( + &self, + devin_id: &str, + after: Option<&str>, + first: Option, + ) -> DevinRequest { + let mut path = format!("{}/messages", self.session_path(devin_id)); + let mut separator = '?'; + if let Some(after) = after { + path.push_str(&format!("{separator}after={}", encode_query_value(after))); + separator = '&'; + } + if let Some(first) = first { + let first = first.clamp(1, DEVIN_MAX_PAGE_SIZE); + path.push_str(&format!("{separator}first={first}")); + } + DevinRequest { - lifecycle: DevinLifecycleRequest::RunStart, - method: DevinHttpMethod::Post, - path: format!("/sessions/{}/runs", encode_path_segment(session_id)), - body: Some(json!({ "prompt": prompt.into() })), + operation: DevinOperation::ListSessionMessages, + method: DevinHttpMethod::Get, + path, + body: None, } } - pub fn cancel_run(&self, session_id: &str, run_id: &str) -> DevinRequest { + /// `POST /v3/organizations/{org_id}/sessions/{devin_id}/messages` + pub fn send_message(&self, devin_id: &str, message: impl Into) -> DevinRequest { + let body = SessionMessageCreateRequest { + message: message.into(), + attachment_urls: None, + }; DevinRequest { - lifecycle: DevinLifecycleRequest::RunCancel, + operation: DevinOperation::SendSessionMessage, method: DevinHttpMethod::Post, - path: format!( - "/sessions/{}/runs/{}/cancel", - encode_path_segment(session_id), - encode_path_segment(run_id) - ), - body: Some(json!({})), + path: format!("{}/messages", self.session_path(devin_id)), + body: Some(serde_json::to_value(&body).unwrap_or_else(|_| json!({}))), } } - pub fn fetch_events(&self, session_id: &str, after_cursor: Option) -> DevinRequest { - let path = match after_cursor { - Some(cursor) => format!( - "/sessions/{}/events?after={cursor}", - encode_path_segment(session_id) - ), - None => format!("/sessions/{}/events", encode_path_segment(session_id)), - }; + /// `DELETE /v3/organizations/{org_id}/sessions/{devin_id}` + pub fn delete_session(&self, devin_id: &str, archive: bool) -> DevinRequest { + DevinRequest { + operation: DevinOperation::DeleteSession, + method: DevinHttpMethod::Delete, + path: format!("{}?archive={archive}", self.session_path(devin_id)), + body: None, + } + } + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}/attachments` + pub fn list_attachments(&self, devin_id: &str) -> DevinRequest { DevinRequest { - lifecycle: DevinLifecycleRequest::EventFetch, + operation: DevinOperation::ListSessionAttachments, method: DevinHttpMethod::Get, - path, + path: format!("{}/attachments", self.session_path(devin_id)), body: None, } } + /// `PUT /v3/organizations/{org_id}/sessions/{devin_id}/tags` + pub fn replace_tags(&self, devin_id: &str, tags: Vec) -> DevinRequest { + let body = SessionTagsUpdateRequest { tags }; + DevinRequest { + operation: DevinOperation::UpdateSessionTags, + method: DevinHttpMethod::Put, + path: format!("{}/tags", self.session_path(devin_id)), + body: Some(serde_json::to_value(&body).unwrap_or_else(|_| json!({}))), + } + } + pub fn absolute_url(&self, request: &DevinRequest) -> Result { let joined = format!("{}{}", self.base_url.trim_end_matches('/'), request.path); Url::parse(&joined).map_err(|source| DevinClientError::InvalidUrl { @@ -393,6 +942,35 @@ impl DevinRequestBuilder { } } +/// Builds the documented create-session payload for an issue run. +pub fn session_create_request( + task_prompt: &str, + binding: &DevinRemoteWorkspaceBinding, + options: &DevinSessionOptions, +) -> SessionCreateRequest { + let mut tags = options.tags.clone(); + let correlation = binding.correlation_tag(); + if !tags.iter().any(|tag| tag == &correlation) { + tags.push(correlation); + } + tags.truncate(DEVIN_MAX_SESSION_TAGS); + + SessionCreateRequest { + prompt: binding.compose_prompt(task_prompt), + repos: binding.repo_name().map(|repo| vec![repo]), + tags: Some(tags), + title: options.title.clone(), + playbook_id: options.playbook_id.clone(), + knowledge_ids: options.knowledge_ids.clone(), + secret_ids: options.secret_ids.clone(), + max_acu_limit: options.max_acu_limit, + devin_mode: options.devin_mode, + platform: options.platform.clone(), + resumable: Some(options.resumable), + structured_output_schema: None, + } +} + fn encode_path_segment(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.bytes() { @@ -405,12 +983,22 @@ fn encode_path_segment(value: &str) -> String { encoded } +fn encode_query_value(value: &str) -> String { + encode_path_segment(value) +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + /// HTTPS client for the Devin cloud API. #[derive(Debug, Clone)] pub struct DevinCloudClient { http: reqwest::Client, - requests: DevinRequestBuilder, + base_url: String, + org_id: Option, token: DevinApiToken, + poll_interval: Duration, } impl DevinCloudClient { @@ -428,6 +1016,23 @@ impl DevinCloudClient { .ok_or_else(|| DevinClientError::MissingCredential { env: config.api_key_env.clone(), })?; + // The organization identifier is not a credential; it is read from the + // environment only so operators can keep it beside the token. + let org_id = match config.org_id.clone() { + Some(org_id) => Some(org_id), + None if config.org_id_env.is_empty() => None, + None => environment(&config.org_id_env) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + }; + if let Some(org_id) = org_id.as_deref() + && !is_org_id(org_id) + { + return Err(DevinClientError::InvalidConfig( + DevinConfigError::InvalidOrgIdFromEnvironment.to_string(), + )); + } + let http = reqwest::Client::builder() .timeout(config.request_timeout) .https_only(true) @@ -439,20 +1044,59 @@ impl DevinCloudClient { Ok(Self { http, - requests: DevinRequestBuilder::new(config.base_url.clone()), + base_url: config.base_url.clone(), + org_id, token: DevinApiToken::new(token.trim()), + poll_interval: config.poll_interval, }) } - pub fn requests(&self) -> &DevinRequestBuilder { - &self.requests + pub fn org_id(&self) -> Option<&str> { + self.org_id.as_deref() + } + + pub fn poll_interval(&self) -> Duration { + self.poll_interval + } + + /// Request builder bound to the resolved organization. + pub fn requests(&self) -> Result { + let org_id = self + .org_id + .as_deref() + .ok_or(DevinClientError::MissingOrgId)?; + Ok(DevinRequestBuilder::new(self.base_url.clone(), org_id)) + } + + /// Resolves the organization from the credential when it was not + /// configured, so operators only need to supply the service user key. + pub async fn resolve_org_id(mut self) -> Result { + if self.org_id.is_some() { + return Ok(self); + } + let identity: DevinSelfResponse = self + .send_typed(&DevinRequestBuilder::new(self.base_url.clone(), "").get_self()) + .await?; + self.org_id = Some(identity.org_id.ok_or(DevinClientError::MissingOrgId)?); + Ok(self) + } + + pub async fn identity(&self) -> Result { + self.send_typed(&DevinRequestBuilder::new(self.base_url.clone(), "").get_self()) + .await } pub async fn send(&self, request: &DevinRequest) -> Result { - let url = self.requests.absolute_url(request)?; + let url = DevinRequestBuilder::new( + self.base_url.clone(), + self.org_id.clone().unwrap_or_default(), + ) + .absolute_url(request)?; let mut builder = match request.method { DevinHttpMethod::Get => self.http.get(url), DevinHttpMethod::Post => self.http.post(url), + DevinHttpMethod::Put => self.http.put(url), + DevinHttpMethod::Delete => self.http.delete(url), } .bearer_auth(self.token.expose()); if let Some(body) = request.body.as_ref() { @@ -471,7 +1115,7 @@ impl DevinCloudClient { if !status.is_success() { return Err(DevinClientError::Status { status: status.as_u16(), - body, + problem: describe_problem(&body), }); } if body.trim().is_empty() { @@ -481,32 +1125,100 @@ impl DevinCloudClient { serde_json::from_str(&body).map_err(|error| DevinClientError::Decode(error.to_string())) } - pub async fn fetch_events( + async fn send_typed( &self, - session_id: &str, - after_cursor: Option, - ) -> Result, DevinClientError> { - let response = self - .send(&self.requests.fetch_events(session_id, after_cursor)) - .await?; - Ok(normalize_event_page(&response)) + request: &DevinRequest, + ) -> Result { + let value = self.send(request).await?; + serde_json::from_value(value).map_err(|error| DevinClientError::Decode(error.to_string())) + } + + pub async fn create_session( + &self, + request: &SessionCreateRequest, + ) -> Result { + self.send_typed(&self.requests()?.create_session(request)) + .await + } + + pub async fn get_session(&self, devin_id: &str) -> Result { + self.send_typed(&self.requests()?.get_session(devin_id)) + .await + } + + pub async fn list_sessions( + &self, + query: &SessionsQueryParams, + ) -> Result, DevinClientError> { + self.send_typed(&self.requests()?.list_sessions(query)) + .await + } + + /// Fetches one page of session messages. `after` is the `end_cursor` of the + /// previous page. + pub async fn list_messages( + &self, + devin_id: &str, + after: Option<&str>, + first: Option, + ) -> Result, DevinClientError> { + self.send_typed(&self.requests()?.list_messages(devin_id, after, first)) + .await + } + + pub async fn send_message( + &self, + devin_id: &str, + message: impl Into, + ) -> Result<(), DevinClientError> { + self.send(&self.requests()?.send_message(devin_id, message)) + .await + .map(|_| ()) + } + + /// Stops a session, optionally archiving it. + pub async fn delete_session( + &self, + devin_id: &str, + archive: bool, + ) -> Result<(), DevinClientError> { + self.send(&self.requests()?.delete_session(devin_id, archive)) + .await + .map(|_| ()) + } + + pub async fn list_attachments( + &self, + devin_id: &str, + ) -> Result, DevinClientError> { + self.send_typed(&self.requests()?.list_attachments(devin_id)) + .await } } +/// Renders an error body as a problem description, falling back to the raw body +/// when Devin returns something other than `application/problem+json`. +fn describe_problem(body: &str) -> String { + serde_json::from_str::(body) + .map(|problem| problem.to_string()) + .unwrap_or_else(|_| body.trim().to_owned()) +} + +// --------------------------------------------------------------------------- +// Event normalization +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NormalizedDevinEventKind { SessionCreated, - SessionResumed, - RunStarted, - RunCompleted, - RunFailed, - RunCancelled, - AgentMessage, + DevinMessage, UserMessage, - ToolCall, - ToolResult, StatusChanged, - Error, + SessionFinished, + SessionBlocked, + SessionFailed, + SessionSuspended, + SessionTerminated, Unknown, } @@ -514,98 +1226,194 @@ pub enum NormalizedDevinEventKind { pub struct NormalizedDevinEvent { pub kind: NormalizedDevinEventKind, pub event_type: String, + pub event_id: Option, pub session_id: Option, - pub run_id: Option, - pub cursor: Option, + pub timestamp: Option, pub message: Option, pub status: Option, pub raw: Value, } -/// Normalizes one Devin event payload. +/// Normalizes one documented `SessionMessage`. /// -/// Unrecognized event types are retained as [`NormalizedDevinEventKind::Unknown`] +/// Unrecognized sources are retained as [`NormalizedDevinEventKind::Unknown`] /// with the full raw JSON so future Devin schema additions replay unchanged. -pub fn normalize_devin_event(raw: Value) -> NormalizedDevinEvent { - let Some(event_type) = first_string(&raw, &["type", "event_type", "kind"]) else { - return NormalizedDevinEvent { - kind: NormalizedDevinEventKind::Unknown, - event_type: DEVIN_UNDISCRIMINATED_EVENT_KIND.to_owned(), - session_id: first_string(&raw, &["session_id", "sessionId"]), - run_id: first_string(&raw, &["run_id", "runId"]), - cursor: first_u64(&raw, &["cursor", "sequence", "seq"]), - message: first_string(&raw, &["message", "text", "content"]), - status: first_string(&raw, &["status", "status_enum"]), - raw, - }; +pub fn normalize_session_message( + session_id: &str, + message: &SessionMessage, +) -> NormalizedDevinEvent { + let kind = match message.source.as_str() { + "devin" => NormalizedDevinEventKind::DevinMessage, + "user" => NormalizedDevinEventKind::UserMessage, + _ => NormalizedDevinEventKind::Unknown, }; - let kind = match event_type.as_str() { - "session.created" | "session_created" => NormalizedDevinEventKind::SessionCreated, - "session.resumed" | "session_resumed" => NormalizedDevinEventKind::SessionResumed, - "run.started" | "run_started" => NormalizedDevinEventKind::RunStarted, - "run.completed" | "run_completed" => NormalizedDevinEventKind::RunCompleted, - "run.failed" | "run_failed" => NormalizedDevinEventKind::RunFailed, - "run.cancelled" | "run.canceled" | "run_cancelled" => { - NormalizedDevinEventKind::RunCancelled - } - "devin_message" | "agent_message" | "message.agent" => { - NormalizedDevinEventKind::AgentMessage - } - "user_message" | "message.user" => NormalizedDevinEventKind::UserMessage, - "tool_call" | "tool.call" => NormalizedDevinEventKind::ToolCall, - "tool_result" | "tool.result" => NormalizedDevinEventKind::ToolResult, - "status_update" | "session.status" => NormalizedDevinEventKind::StatusChanged, - "error" => NormalizedDevinEventKind::Error, + let raw = serde_json::to_value(message).unwrap_or_else(|_| json!({})); + + NormalizedDevinEvent { + kind, + event_type: format!("session.message.{}", message.source), + event_id: Some(message.event_id.clone()), + session_id: Some(session_id.to_owned()), + timestamp: Some(message.created_at.to_string()), + message: Some(message.message.clone()), + status: None, + raw, + } +} + +/// Normalizes a raw JSON payload that could not be decoded into a +/// [`SessionMessage`], preserving it verbatim. +pub fn normalize_devin_event(raw: Value) -> NormalizedDevinEvent { + let source = first_string(&raw, &["source", "type", "event_type", "kind"]); + let kind = match source.as_deref() { + Some("devin") => NormalizedDevinEventKind::DevinMessage, + Some("user") => NormalizedDevinEventKind::UserMessage, _ => NormalizedDevinEventKind::Unknown, }; NormalizedDevinEvent { kind, - event_type, - session_id: first_string(&raw, &["session_id", "sessionId"]), - run_id: first_string(&raw, &["run_id", "runId"]), - cursor: first_u64(&raw, &["cursor", "sequence", "seq"]), + event_type: source.unwrap_or_else(|| DEVIN_UNDISCRIMINATED_EVENT_KIND.to_owned()), + event_id: first_string(&raw, &["event_id", "eventId"]), + session_id: first_string(&raw, &["session_id", "sessionId", "devin_id"]), + timestamp: first_string(&raw, &["created_at", "timestamp"]), message: first_string(&raw, &["message", "text", "content"]), - status: first_string(&raw, &["status", "status_enum"]), + status: first_string(&raw, &["status", "status_detail"]), raw, } } -/// Normalizes an events page, accepting either a bare array or an object with -/// an `events` array. -pub fn normalize_event_page(response: &Value) -> Vec { - let events = response - .as_array() - .or_else(|| response.get("events").and_then(Value::as_array)) - .cloned() - .unwrap_or_default(); +/// Synthesizes the status event Devin does not send: status is session state, +/// not a message, so transitions are derived while polling. +pub fn status_event(session: &SessionResponse) -> NormalizedDevinEvent { + let detail = session.status_detail.as_ref(); + let kind = match &session.status { + DevinSessionStatus::Error => NormalizedDevinEventKind::SessionFailed, + DevinSessionStatus::Suspended => NormalizedDevinEventKind::SessionSuspended, + DevinSessionStatus::Exit => NormalizedDevinEventKind::SessionFinished, + _ if detail.is_some_and(DevinStatusDetail::is_finished) => { + NormalizedDevinEventKind::SessionFinished + } + _ if detail.is_some_and(DevinStatusDetail::is_waiting_on_operator) => { + NormalizedDevinEventKind::SessionBlocked + } + _ => NormalizedDevinEventKind::StatusChanged, + }; + let status = match detail { + Some(detail) => format!("{}:{}", session.status.as_str(), detail.as_str()), + None => session.status.as_str().to_owned(), + }; + + NormalizedDevinEvent { + kind, + event_type: format!("session.status.{status}"), + event_id: None, + session_id: Some(session.session_id.clone()), + timestamp: Some(session.updated_at.to_string()), + message: None, + status: Some(status), + raw: json!({ + "session_id": session.session_id, + "status": session.status.as_str(), + "status_detail": detail.map(DevinStatusDetail::as_str), + }), + } +} - events.into_iter().map(normalize_devin_event).collect() +pub fn session_created_event(session: &SessionResponse) -> NormalizedDevinEvent { + NormalizedDevinEvent { + kind: NormalizedDevinEventKind::SessionCreated, + event_type: "session.created".into(), + event_id: None, + session_id: Some(session.session_id.clone()), + timestamp: Some(session.created_at.to_string()), + message: Some(session.url.clone()), + status: Some(session.status.as_str().to_owned()), + raw: serde_json::to_value(session).unwrap_or_else(|_| json!({})), + } +} + +/// Tracks the message cursor and last observed status for one polled session. +/// +/// Messages are paginated by `end_cursor`, but the cursor is only advanced +/// after the page is consumed and every `event_id` is also remembered, so a +/// replayed or overlapping page cannot emit an event twice. +#[derive(Debug, Clone, Default)] +pub struct DevinMessageCursor { + seen: HashSet, + cursor: Option, + last_status: Option, +} + +impl DevinMessageCursor { + pub fn new() -> Self { + Self::default() + } + + pub fn cursor(&self) -> Option<&str> { + self.cursor.as_deref() + } + + /// Consumes one message page, returning only messages not seen before. + pub fn ingest_messages( + &mut self, + session_id: &str, + page: &PaginatedResponse, + ) -> Vec { + let mut fresh = Vec::new(); + for message in &page.items { + if self.seen.insert(message.event_id.clone()) { + fresh.push(normalize_session_message(session_id, message)); + } + } + if let Some(end_cursor) = page.end_cursor.as_ref() { + self.cursor = Some(end_cursor.clone()); + } + + fresh + } + + /// Emits a status event when the session's status or status detail changed. + pub fn ingest_status(&mut self, session: &SessionResponse) -> Option { + let current = match session.status_detail.as_ref() { + Some(detail) => format!("{}:{}", session.status.as_str(), detail.as_str()), + None => session.status.as_str().to_owned(), + }; + if self.last_status.as_deref() == Some(current.as_str()) { + return None; + } + self.last_status = Some(current); + + Some(status_event(session)) + } + + pub fn last_status(&self) -> Option<&str> { + self.last_status.as_deref() + } } pub fn devin_event_summary(event: &NormalizedDevinEvent) -> String { match event.kind { - NormalizedDevinEventKind::SessionCreated => "Devin session created".into(), - NormalizedDevinEventKind::SessionResumed => "Devin session resumed".into(), - NormalizedDevinEventKind::RunStarted => "Devin run started".into(), - NormalizedDevinEventKind::RunCompleted => "Devin run completed".into(), - NormalizedDevinEventKind::RunFailed => "Devin run failed".into(), - NormalizedDevinEventKind::RunCancelled => "Devin run cancelled".into(), - NormalizedDevinEventKind::AgentMessage => event + NormalizedDevinEventKind::SessionCreated => match event.message.as_deref() { + Some(url) => format!("Devin session created: {url}"), + None => "Devin session created".into(), + }, + NormalizedDevinEventKind::DevinMessage => event .message .clone() - .unwrap_or_else(|| "Devin agent message".into()), + .unwrap_or_else(|| "Devin message".into()), NormalizedDevinEventKind::UserMessage => "Operator message forwarded to Devin".into(), - NormalizedDevinEventKind::ToolCall => "Devin tool call".into(), - NormalizedDevinEventKind::ToolResult => "Devin tool result".into(), NormalizedDevinEventKind::StatusChanged => match event.status.as_deref() { Some(status) => format!("Devin status changed to {status}"), None => "Devin status changed".into(), }, - NormalizedDevinEventKind::Error => event - .message - .clone() - .unwrap_or_else(|| "Devin reported an error".into()), + NormalizedDevinEventKind::SessionFinished => "Devin session finished".into(), + NormalizedDevinEventKind::SessionBlocked => { + "Devin session is waiting on the operator".into() + } + NormalizedDevinEventKind::SessionFailed => "Devin session errored".into(), + NormalizedDevinEventKind::SessionSuspended => "Devin session was suspended".into(), + NormalizedDevinEventKind::SessionTerminated => "Devin session terminated".into(), NormalizedDevinEventKind::Unknown => { format!("Unknown Devin event `{}`", event.event_type) } @@ -616,8 +1424,8 @@ pub fn devin_event_payload(event: &NormalizedDevinEvent) -> Value { json!({ "source_kind": event.event_type, "session_id": event.session_id, - "run_id": event.run_id, - "cursor": event.cursor, + "event_id": event.event_id, + "timestamp": event.timestamp, "status": event.status, "message": event.message, "raw_payload": event.raw, @@ -631,14 +1439,10 @@ pub fn normalized_event_to_journal_record( ) -> EventRecord { let run_id = run_id.into(); let kind = match event.kind { - NormalizedDevinEventKind::RunStarted => EventKind::RunStarted, - NormalizedDevinEventKind::RunCompleted => EventKind::RunCompleted, - NormalizedDevinEventKind::RunFailed | NormalizedDevinEventKind::Error => { - EventKind::RunFailed - } - NormalizedDevinEventKind::RunCancelled => EventKind::RunCancelled, - NormalizedDevinEventKind::ToolCall => EventKind::HarnessToolCall, - NormalizedDevinEventKind::ToolResult => EventKind::HarnessToolResult, + NormalizedDevinEventKind::SessionCreated => EventKind::RunStarted, + NormalizedDevinEventKind::SessionFinished => EventKind::RunCompleted, + NormalizedDevinEventKind::SessionFailed => EventKind::RunFailed, + NormalizedDevinEventKind::SessionTerminated => EventKind::RunCancelled, NormalizedDevinEventKind::Unknown => EventKind::Unknown { raw_kind: event.event_type.clone(), }, @@ -664,7 +1468,224 @@ fn first_string(value: &Value, keys: &[&str]) -> Option { .map(str::to_owned) } -fn first_u64(value: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| value.get(*key).and_then(Value::as_u64)) +// --------------------------------------------------------------------------- +// Session lifecycle +// --------------------------------------------------------------------------- + +/// How a polled Devin session ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DevinRunOutcome { + Finished, + WaitingOnOperator, + Suspended, + Failed, + Terminated, +} + +impl DevinRunOutcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Finished => "finished", + Self::WaitingOnOperator => "waiting_on_operator", + Self::Suspended => "suspended", + Self::Failed => "failed", + Self::Terminated => "terminated", + } + } +} + +/// Evidence collected from a Devin session, stored in the local issue workspace. +#[derive(Debug, Clone, PartialEq)] +pub struct DevinRunReport { + pub session_id: String, + pub session_url: String, + pub outcome: DevinRunOutcome, + pub status: DevinSessionStatus, + pub status_detail: Option, + pub acus_consumed: f64, + pub pull_requests: Vec, + pub structured_output: Option, + pub attachments: Vec, +} + +/// Options for [`DevinSessionRunner::run`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinRunOptions { + /// Stop polling once Devin asks for operator input instead of waiting. + pub stop_when_waiting_on_operator: bool, + /// Upper bound on total polling time. + pub max_duration: Option, + /// Fetch the session attachment list once the session settles. + pub collect_attachments: bool, + /// Page size for message polling. + pub message_page_size: u32, +} + +impl Default for DevinRunOptions { + fn default() -> Self { + Self { + stop_when_waiting_on_operator: true, + max_duration: None, + collect_attachments: true, + message_page_size: 100, + } + } +} + +/// Drives one Devin session: create, poll, normalize, settle. +/// +/// The API exposes no event stream, so progress is polled from the session +/// resource and its cursor-paginated message collection at the configured +/// interval. +#[derive(Debug, Clone)] +pub struct DevinSessionRunner { + client: DevinCloudClient, + options: DevinRunOptions, +} + +impl DevinSessionRunner { + pub fn new(client: DevinCloudClient, options: DevinRunOptions) -> Self { + Self { client, options } + } + + pub fn client(&self) -> &DevinCloudClient { + &self.client + } + + /// Creates a session and polls it to a settled state, handing every fresh + /// normalized event to `observer` as it is observed. + pub async fn run( + &self, + request: &SessionCreateRequest, + mut observer: impl FnMut(NormalizedDevinEvent), + ) -> Result { + let created = self.client.create_session(request).await?; + observer(session_created_event(&created)); + self.follow(&created.session_id, observer).await + } + + /// Polls an existing session to a settled state. + pub async fn follow( + &self, + devin_id: &str, + mut observer: impl FnMut(NormalizedDevinEvent), + ) -> Result { + let started = Instant::now(); + let mut cursor = DevinMessageCursor::new(); + + loop { + let session = self.client.get_session(devin_id).await?; + self.drain_messages(devin_id, &mut cursor, &mut observer) + .await?; + if let Some(event) = cursor.ingest_status(&session) { + observer(event); + } + + if let Some(outcome) = self.settled_outcome(&session) { + return self.report(session, outcome).await; + } + + if let Some(max_duration) = self.options.max_duration + && started.elapsed() >= max_duration + { + return Err(DevinClientError::PollTimeout { + session_id: devin_id.to_owned(), + elapsed: started.elapsed(), + }); + } + + tokio::time::sleep(self.client.poll_interval()).await; + } + } + + /// Sends operator input to a running session. + pub async fn send_message( + &self, + devin_id: &str, + message: impl Into, + ) -> Result<(), DevinClientError> { + self.client.send_message(devin_id, message).await + } + + /// Stops a session and reports it as terminated. + pub async fn terminate( + &self, + devin_id: &str, + archive: bool, + ) -> Result { + self.client.delete_session(devin_id, archive).await?; + let session = self.client.get_session(devin_id).await?; + self.report(session, DevinRunOutcome::Terminated).await + } + + fn settled_outcome(&self, session: &SessionResponse) -> Option { + match &session.status { + DevinSessionStatus::Error => Some(DevinRunOutcome::Failed), + DevinSessionStatus::Suspended => Some(DevinRunOutcome::Suspended), + DevinSessionStatus::Exit => Some(DevinRunOutcome::Finished), + _ if session + .status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_finished) => + { + Some(DevinRunOutcome::Finished) + } + _ if self.options.stop_when_waiting_on_operator && session.is_waiting_on_operator() => { + Some(DevinRunOutcome::WaitingOnOperator) + } + _ => None, + } + } + + /// Reads every message page produced since the last poll. + async fn drain_messages( + &self, + devin_id: &str, + cursor: &mut DevinMessageCursor, + observer: &mut impl FnMut(NormalizedDevinEvent), + ) -> Result<(), DevinClientError> { + loop { + let page = self + .client + .list_messages( + devin_id, + cursor.cursor(), + Some(self.options.message_page_size), + ) + .await?; + let previous = cursor.cursor().map(str::to_owned); + for event in cursor.ingest_messages(devin_id, &page) { + observer(event); + } + // A page that reports more data but does not advance the cursor + // would otherwise be requested forever. + if !page.has_next_page || cursor.cursor().map(str::to_owned) == previous { + return Ok(()); + } + } + } + + async fn report( + &self, + session: SessionResponse, + outcome: DevinRunOutcome, + ) -> Result { + let attachments = if self.options.collect_attachments { + self.client.list_attachments(&session.session_id).await? + } else { + Vec::new() + }; + + Ok(DevinRunReport { + session_id: session.session_id, + session_url: session.url, + outcome, + status: session.status, + status_detail: session.status_detail, + acus_consumed: session.acus_consumed, + pull_requests: session.pull_requests, + structured_output: session.structured_output, + attachments, + }) + } } diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index 037f626c2..12111a8f8 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -109,8 +109,8 @@ mod tests { PromptTemplateError, TrackerKind, WorkflowConfigError, WorkflowDefinition, WorkflowLoadError, model::{ - DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, - DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, DEFAULT_DEVIN_ORG_ID_ENV, + DEFAULT_DEVIN_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, DEFAULT_MAX_CONCURRENT_AGENTS, DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_BASE_URL, DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, @@ -118,7 +118,7 @@ mod tests { DEFAULT_OPENHANDS_PERSISTENCE_DIR, DEFAULT_OPENHANDS_QUERY_PARAM_NAME, DEFAULT_OPENHANDS_READY_TIMEOUT_MS, DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, DEFAULT_OPENHANDS_RECONNECT_MAX_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_PROMPT_TEMPLATE, - DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, + DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, DEVIN_MAX_SESSION_TAGS, }, }; @@ -2578,13 +2578,21 @@ routing: DEFAULT_DEVIN_API_KEY_ENV ); assert_eq!( - resolved.extensions.devin.api.event_poll_interval_ms, - DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS + resolved.extensions.devin.api.poll_interval_ms, + DEFAULT_DEVIN_POLL_INTERVAL_MS ); assert_eq!( resolved.extensions.devin.api.request_timeout_ms, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS ); + assert_eq!( + resolved.extensions.devin.api.org_id_env, + DEFAULT_DEVIN_ORG_ID_ENV + ); + assert!(resolved.extensions.devin.api.org_id.is_none()); + assert!(resolved.extensions.devin.session.tags.is_empty()); + // Matches the documented `resumable` default in the v3 contract. + assert!(resolved.extensions.devin.session.resumable); let overridden = WorkflowDefinition::parse( r#"--- @@ -2601,8 +2609,23 @@ devin: api: base_url: https://devin.internal.example.com/api api_key_env: DEVIN_TENANT_API_KEY - event_poll_interval_ms: 500 + org_id: org-tenant123 + org_id_env: DEVIN_TENANT_ORG_ID + poll_interval_ms: 500 request_timeout_ms: 15000 + session: + playbook_id: playbook-1 + knowledge_ids: + - note-1 + secret_ids: + - secret-1 + max_acu_limit: 25 + tags: + - team:core + title: Implement the issue + devin_mode: fast + platform: linux + resumable: true --- {{ issue.identifier }} "#, @@ -2619,8 +2642,75 @@ devin: overridden.extensions.devin.api.api_key_env, "DEVIN_TENANT_API_KEY" ); - assert_eq!(overridden.extensions.devin.api.event_poll_interval_ms, 500); + assert_eq!(overridden.extensions.devin.api.poll_interval_ms, 500); assert_eq!(overridden.extensions.devin.api.request_timeout_ms, 15_000); + assert_eq!( + overridden.extensions.devin.api.org_id.as_deref(), + Some("org-tenant123") + ); + assert_eq!( + overridden.extensions.devin.api.org_id_env, + "DEVIN_TENANT_ORG_ID" + ); + + let session = &overridden.extensions.devin.session; + assert_eq!(session.playbook_id.as_deref(), Some("playbook-1")); + assert_eq!( + session.knowledge_ids.as_deref(), + Some(&["note-1".to_owned()][..]) + ); + assert_eq!( + session.secret_ids.as_deref(), + Some(&["secret-1".to_owned()][..]) + ); + assert_eq!(session.max_acu_limit, Some(25)); + assert_eq!(session.tags, vec!["team:core".to_owned()]); + assert_eq!(session.title.as_deref(), Some("Implement the issue")); + assert_eq!(session.devin_mode.as_deref(), Some("fast")); + assert_eq!(session.platform.as_deref(), Some("linux")); + assert!(session.resumable); + } + + #[test] + fn devin_session_options_are_validated_against_the_documented_v3_contract() { + let env = env([("LINEAR_API_KEY", "linear-token")]); + let tags = (0..(DEVIN_MAX_SESSION_TAGS + 1)) + .map(|index| format!(" - tag-{index}")) + .collect::>() + .join("\n"); + + for devin_block in [ + " api:\n org_id: acme".to_owned(), + " api:\n org_id_env: devin org".to_owned(), + " session:\n devin_mode: turbo".to_owned(), + " session:\n max_acu_limit: 0".to_owned(), + format!(" session:\n tags:\n{tags}"), + " session:\n tags:\n - ' '".to_owned(), + ] { + let workflow = WorkflowDefinition::parse(&format!( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: +{devin_block} +--- +{{{{ issue.identifier }}}} +"# + )) + .expect("workflow should parse"); + + assert!( + workflow.resolve(Path::new("/repo"), &env).is_err(), + "`{devin_block}` should be rejected" + ); + } } #[test] diff --git a/crates/opensymphony-workflow/src/model.rs b/crates/opensymphony-workflow/src/model.rs index af20990bf..d324633db 100644 --- a/crates/opensymphony-workflow/src/model.rs +++ b/crates/opensymphony-workflow/src/model.rs @@ -30,9 +30,13 @@ pub const DEFAULT_OPENHANDS_QUERY_PARAM_NAME: &str = "session_api_key"; pub const DEFAULT_OPENHANDS_LLM_MODEL: &str = "openai/gpt-5.4"; pub const DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE: &str = "api_key"; pub const DEFAULT_ROUTING_HARNESS: &str = "openhands_agent_server"; -pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai/v1"; -pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "DEVIN_API_KEY"; -pub const DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS: u64 = 2_000; +pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai"; +/// Devin API v3 authenticates with a `cog_` service-user token. +pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "COG_SERVICE_USER_TOKEN"; +pub const DEFAULT_DEVIN_ORG_ID_ENV: &str = "DEVIN_ORG_ID"; +pub const DEFAULT_DEVIN_POLL_INTERVAL_MS: u64 = 5_000; +/// Maximum number of tags the Devin API accepts on a session. +pub const DEVIN_MAX_SESSION_TAGS: usize = 50; pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; pub const DEFAULT_ROUTING_HARNESS_ENV: &str = "OPENSYMPHONY_HARNESS"; pub const DEFAULT_ROUTING_MODEL_ENV: &str = "OPENSYMPHONY_MODEL"; @@ -138,6 +142,8 @@ pub struct RoutingFrontMatter { pub struct DevinFrontMatter { #[serde(default)] pub api: DevinApiFrontMatter, + #[serde(default)] + pub session: DevinSessionFrontMatter, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -147,10 +153,36 @@ pub struct DevinApiFrontMatter { /// Environment variable holding the Devin API token. The token itself is /// never stored in workflow configuration or run manifests. pub api_key_env: Option, - pub event_poll_interval_ms: Option, + /// Organization that owns the sessions (`org-...`). + pub org_id: Option, + /// Environment variable holding the organization id when it is not set + /// inline. When neither is present the client resolves it from + /// `GET /v3/self`. + pub org_id_env: Option, + pub poll_interval_ms: Option, pub request_timeout_ms: Option, } +/// Documented `SessionCreateRequest` options carried into every Devin session +/// this workflow starts. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DevinSessionFrontMatter { + pub playbook_id: Option, + pub knowledge_ids: Option>, + /// References to organization secrets Devin injects remotely. Values live + /// in Devin, never in workflow configuration. + pub secret_ids: Option>, + pub max_acu_limit: Option, + pub tags: Option>, + pub title: Option, + /// `normal`, `fast`, `lite`, `ultra`, or `fusion`. + pub devin_mode: Option, + /// VM platform or outpost pool override. + pub platform: Option, + pub resumable: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct OpenHandsFrontMatter { @@ -302,16 +334,32 @@ pub struct WorkflowExtensions { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevinConfig { pub api: DevinApiConfig, + pub session: DevinSessionConfig, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevinApiConfig { pub base_url: String, pub api_key_env: String, - pub event_poll_interval_ms: u64, + pub org_id: Option, + pub org_id_env: String, + pub poll_interval_ms: u64, pub request_timeout_ms: u64, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DevinSessionConfig { + pub playbook_id: Option, + pub knowledge_ids: Option>, + pub secret_ids: Option>, + pub max_acu_limit: Option, + pub tags: Vec, + pub title: Option, + pub devin_mode: Option, + pub platform: Option, + pub resumable: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrackerKind { Linear, diff --git a/crates/opensymphony-workflow/src/resolve.rs b/crates/opensymphony-workflow/src/resolve.rs index 7cdec465b..6ea359133 100644 --- a/crates/opensymphony-workflow/src/resolve.rs +++ b/crates/opensymphony-workflow/src/resolve.rs @@ -10,7 +10,7 @@ use super::{ error::WorkflowConfigError, model::{ AgentConfig, AgentFrontMatter, DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, - DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + DEFAULT_DEVIN_ORG_ID_ENV, DEFAULT_DEVIN_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, DEFAULT_MAX_CONCURRENT_AGENTS, DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, DEFAULT_OPENHANDS_AGENT_KIND, DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_AUTH_MODE, DEFAULT_OPENHANDS_BASE_URL, @@ -22,8 +22,9 @@ use super::{ DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, DEFAULT_OPENHANDS_RECONNECT_MAX_MS, DEFAULT_OPENHANDS_STARTUP_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_ROUTING_HARNESS, DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, - DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, DevinApiConfig, DevinApiFrontMatter, - DevinConfig, DevinFrontMatter, Environment, HooksConfig, HooksFrontMatter, IntegerLike, + DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, DEVIN_MAX_SESSION_TAGS, DevinApiConfig, + DevinApiFrontMatter, DevinConfig, DevinFrontMatter, DevinSessionConfig, + DevinSessionFrontMatter, Environment, HooksConfig, HooksFrontMatter, IntegerLike, OPENHANDS_LLM_CREDENTIAL_MODE_API_KEY, OPENHANDS_LLM_CREDENTIAL_MODE_OPENAI_SUBSCRIPTION, OpenHandsConfig, OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, OpenHandsConversationAgentConfig, OpenHandsConversationAgentFrontMatter, @@ -557,6 +558,7 @@ fn resolve_devin( ) -> Result { Ok(DevinConfig { api: resolve_devin_api(&devin.api, env)?, + session: resolve_devin_session(&devin.session)?, }) } @@ -578,13 +580,35 @@ fn resolve_devin_api( .unwrap_or_else(|| DEFAULT_DEVIN_API_KEY_ENV.to_owned()); validate_env_name(&api_key_env, "devin.api.api_key_env")?; + // The organization identifier is not a credential, so env indirection is + // allowed here; an unset value is read from `org_id_env` at request time + // and otherwise resolved from `GET /v3/self`. + let org_id = match api.org_id.as_deref().and_then(normalize_optional) { + Some(value) => Some(resolve_string(&value, env, "devin.api.org_id")?), + None => None, + }; + if let Some(org_id) = org_id.as_deref() + && !is_devin_org_id(org_id) + { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.org_id", + message: "must be a devin organization id such as `org-abc123`".to_owned(), + }); + } + + let org_id_env = normalize_optional_literal(&api.org_id_env) + .unwrap_or_else(|| DEFAULT_DEVIN_ORG_ID_ENV.to_owned()); + validate_env_name(&org_id_env, "devin.api.org_id_env")?; + Ok(DevinApiConfig { base_url, api_key_env, - event_poll_interval_ms: resolve_positive_u64( - api.event_poll_interval_ms.as_ref(), - "devin.api.event_poll_interval_ms", - DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, + org_id, + org_id_env, + poll_interval_ms: resolve_positive_u64( + api.poll_interval_ms.as_ref(), + "devin.api.poll_interval_ms", + DEFAULT_DEVIN_POLL_INTERVAL_MS, )?, request_timeout_ms: resolve_positive_u64( api.request_timeout_ms.as_ref(), @@ -633,6 +657,69 @@ fn validate_devin_base_url(base_url: &str) -> Result<(), WorkflowConfigError> { Ok(()) } +fn is_devin_org_id(value: &str) -> bool { + value.len() > "org-".len() + && value.starts_with("org-") + && value + .chars() + .all(|character| character == '-' || character.is_ascii_alphanumeric()) +} + +/// Session options map onto documented `SessionCreateRequest` fields. They are +/// plain identifiers and flags: no credential values are accepted here. +fn resolve_devin_session( + session: &DevinSessionFrontMatter, +) -> Result { + let max_acu_limit = match session.max_acu_limit.as_ref() { + Some(value) => { + let parsed = resolve_positive_u64(Some(value), "devin.session.max_acu_limit", 0)?; + Some( + u32::try_from(parsed).map_err(|_| WorkflowConfigError::InvalidField { + field: "devin.session.max_acu_limit", + message: "must fit in a 32-bit unsigned integer".to_owned(), + })?, + ) + } + None => None, + }; + + let tags = session.tags.clone().unwrap_or_default(); + if tags.len() > DEVIN_MAX_SESSION_TAGS { + return Err(WorkflowConfigError::InvalidField { + field: "devin.session.tags", + message: format!("devin accepts at most {DEVIN_MAX_SESSION_TAGS} session tags"), + }); + } + if tags.iter().any(|tag| tag.trim().is_empty()) { + return Err(WorkflowConfigError::InvalidField { + field: "devin.session.tags", + message: "must not contain blank tags".to_owned(), + }); + } + + let devin_mode = normalize_optional_literal(&session.devin_mode); + if let Some(mode) = devin_mode.as_deref() + && !matches!(mode, "normal" | "fast" | "lite" | "ultra" | "fusion") + { + return Err(WorkflowConfigError::InvalidField { + field: "devin.session.devin_mode", + message: "must be one of `normal`, `fast`, `lite`, `ultra`, or `fusion`".to_owned(), + }); + } + + Ok(DevinSessionConfig { + playbook_id: normalize_optional_literal(&session.playbook_id), + knowledge_ids: session.knowledge_ids.clone(), + secret_ids: session.secret_ids.clone(), + max_acu_limit, + tags, + title: normalize_optional_literal(&session.title), + devin_mode, + platform: normalize_optional_literal(&session.platform), + resumable: session.resumable.unwrap_or(true), + }) +} + /// Devin settings are only resolved for Devin-routed workflows, so an unset /// substitution or parked endpoint in `devin.api` cannot reject an OpenHands or /// Codex workflow. @@ -641,9 +728,12 @@ fn default_inactive_devin_config() -> DevinConfig { api: DevinApiConfig { base_url: DEFAULT_DEVIN_API_BASE_URL.to_owned(), api_key_env: DEFAULT_DEVIN_API_KEY_ENV.to_owned(), - event_poll_interval_ms: DEFAULT_DEVIN_EVENT_POLL_INTERVAL_MS, + org_id: None, + org_id_env: DEFAULT_DEVIN_ORG_ID_ENV.to_owned(), + poll_interval_ms: DEFAULT_DEVIN_POLL_INTERVAL_MS, request_timeout_ms: DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, }, + session: DevinSessionConfig::default(), } } diff --git a/docs/configuration.md b/docs/configuration.md index 23e01001e..e7c708a1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -711,14 +711,20 @@ OpenSymphony forwards an OpenHands `LLMSummarizingCondenser` that reuses the conversation agent's LLM settings. The condenser is enabled by default with `max_size: 240` and `keep_first: 2`. To disable it, set `enabled: false`. -## Devin Cloud Harness (`devin.api`) +## Devin Cloud Harness (`devin`) + +The harness targets the **Devin API v3** contract vendored at +`crates/opensymphony-devin/contracts/devin-v3-openapi.yaml` (source: +`https://docs.devin.ai/v3-openapi.yaml`). v3 is organization-scoped: +requests go to `/v3/organizations/{org_id}/sessions...` with a `cog_` +service-user bearer token. `devin_cloud_agent` is advertised as an unavailable capability, so these -settings only describe the boundary a future hosted integration will use. They -are resolved **only** when `routing.harness: devin_cloud_agent`; for OpenHands, -Codex, or `rust_native` workflows the block is ignored entirely and an inert -default is used, so a parked or half-configured `devin.api` block cannot fail -an unrelated workflow. +settings describe the boundary rather than a runnable route. They are resolved +**only** when `routing.harness: devin_cloud_agent`; for OpenHands, Codex, or +`rust_native` workflows the block is ignored entirely and an inert default is +used, so a parked or half-configured `devin` block cannot fail an unrelated +workflow. ```yaml routing: @@ -726,21 +732,57 @@ routing: devin: api: - base_url: https://api.devin.ai/v1 - api_key_env: DEVIN_API_KEY - event_poll_interval_ms: 2000 + base_url: https://api.devin.ai + api_key_env: COG_SERVICE_USER_TOKEN + org_id_env: DEVIN_ORG_ID + poll_interval_ms: 5000 request_timeout_ms: 30000 + session: + playbook_id: playbook-abc123 + knowledge_ids: + - note-abc123 + secret_ids: + - secret-abc123 + max_acu_limit: 25 + tags: + - team:core + title: OpenSymphony run + devin_mode: normal + platform: linux + resumable: true ``` +`devin.api`: + | Field | Default | Notes | | --- | --- | --- | -| `base_url` | `https://api.devin.ai/v1` | Supports `${VAR}` env indirection. Must be an absolute `https` URL with a host, and must not embed credentials, a query string, or a fragment. | -| `api_key_env` | `DEVIN_API_KEY` | **Literal environment-variable name**, never a value. | -| `event_poll_interval_ms` | `2000` | Must be a positive integer. | +| `base_url` | `https://api.devin.ai` | Supports `${VAR}` env indirection. Must be an absolute `https` URL with a host, and must not embed credentials, a query string, or a fragment. | +| `api_key_env` | `COG_SERVICE_USER_TOKEN` | **Literal environment-variable name**, never a value. | +| `org_id` | unset | Optional inline organization id (`org-...`). Resolved from `org_id_env`, then `GET /v3/self`, when omitted. | +| `org_id_env` | `DEVIN_ORG_ID` | Literal environment-variable name holding the organization id. | +| `poll_interval_ms` | `5000` | Session/message poll interval. Must be a positive integer. | | `request_timeout_ms` | `30000` | Must be a positive integer. | +`devin.session` maps onto documented `SessionCreateRequest` fields: + +| Field | Default | Notes | +| --- | --- | --- | +| `playbook_id` | unset | Devin playbook to run. | +| `knowledge_ids` | unset | Knowledge notes to attach. | +| `secret_ids` | unset | *References* to Devin-held organization secrets; values never live in OpenSymphony config. | +| `max_acu_limit` | unset | Positive ACU cap for the session. | +| `tags` | `[]` | At most 50 tags; an `opensymphony:` correlation tag is appended automatically. | +| `title` | unset | Session title. | +| `devin_mode` | unset | One of `normal`, `fast`, `lite`, `ultra`, `fusion`. | +| `platform` | unset | VM platform / outpost pool override. | +| `resumable` | `true` | Matches the API default; set `false` for disposable sessions. | + +Repository binding is prompt-carried: v3 takes repositories as `owner/name` +entries in `repos` and has no branch field, so branch and issue context are +rendered into the session prompt. + `api_key_env` is deliberately not passed through env substitution: writing -`api_key_env: ${DEVIN_API_KEY}` would resolve the token itself into resolved +`api_key_env: ${COG_SERVICE_USER_TOKEN}` would resolve the token itself into resolved configuration, manifests, and debug output. The name is validated as an environment-variable identifier and the token is read from the process environment only at request time. @@ -750,6 +792,18 @@ causes routing to reject issue dispatch before any local workspace is created: the local issue workspace is evidence and manifest storage only and is never Devin's execution directory. +The client itself is verified against the live API by the opt-in suite in +`tests/devin_cloud_agent_live.rs`. It needs `COG_SERVICE_USER_TOKEN` (and +optionally `DEVIN_ORG_ID`) in the environment, and its lifecycle test creates a +real session, so it consumes ACUs: + +```bash +OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored --nocapture +``` + +See `docs/harness-adapter-compatibility.md` for what the last live run +confirmed and what is still unverified. + ## Runtime Config `opensymphony init` also copies a starter `config.yaml` next to the target diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 2ad67b1a7..8f1eeb290 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -108,13 +108,26 @@ Devin is a remote, vendor-hosted implementation agent. The adapter lives in Cargo package) and advertises `devin_cloud_agent` with `available: false` over an HTTPS transport (`remote: true`, `local: false`). -The module provides the boundary primitives a hosted route needs: validated -configuration (HTTPS-only endpoint, credential *environment variable name* -rather than a token), a request builder covering session create/resume, message -send, run start/cancel, and event retrieval, a client that reads the token from -the configured environment variable and injects it only on the wire, and event -normalization that maps recognized Devin events into journal records while -preserving every unknown payload as raw JSON. +The client targets the **Devin API v3** contract, vendored from +`https://docs.devin.ai/v3-openapi.yaml` into +`crates/opensymphony-devin/contracts/devin-v3-openapi.yaml` and asserted against +in `tests/devin_cloud_agent.rs`. v3 is organization-scoped and authenticated +with a `cog_` service-user bearer token, so every session route is +`/v3/organizations/{org_id}/sessions...` and the organization id is resolved +from config, `DEVIN_ORG_ID`, or `GET /v3/self`. + +The module provides: validated configuration (HTTPS-only endpoint, credential +*environment variable name* rather than a token, `org-` id shape, tag/ACU/mode +limits); a request builder covering `GET /v3/self`, session create/get/list, +cursor-paginated messages, operator message send, delete/archive, attachments, +and tag replacement; a client that reads the token from the configured +environment variable, disables redirects, and injects the bearer only on the +wire; a poll-driven session lifecycle that drains messages by cursor, +deduplicates by `event_id`, emits status/status-detail transitions, and collects +pull requests, ACU usage, structured output, and attachments as run evidence; +and normalization that maps documented message sources and statuses into journal +records while preserving every unknown payload as raw JSON (unknown statuses and +sources decode rather than fail). Devin owns its execution workspace. `DevinRemoteWorkspaceBinding` binds an issue workspace key and repository URL to that remote workspace and keeps the local @@ -122,18 +135,41 @@ OpenSymphony checkout as an evidence-only path; it is never used as Devin's working directory. The CLI route therefore rejects `devin_cloud_agent` launches with an explicit unavailability reason before any local workspace is prepared. -Workflow configuration lives under the `devin.api` front-matter block -(`base_url`, `api_key_env`, `event_poll_interval_ms`, `request_timeout_ms`). -Endpoints must be absolute HTTPS URLs without embedded credentials, query, or -fragment; credential values are never serialized into workflow config, -manifests, or logs. +Workflow configuration lives under the `devin.api` and `devin.session` +front-matter blocks (see `docs/configuration.md`). Endpoints must be absolute +HTTPS URLs without embedded credentials, query, or fragment; credential values +are never serialized into workflow config, manifests, or logs. + +### Live contract evidence + +`tests/devin_cloud_agent_live.rs` exercises the same client against +`https://api.devin.ai` and is opt-in (`#[ignore]` plus +`OPENSYMPHONY_DEVIN_LIVE=1`) because the lifecycle test creates a real session +and consumes ACUs: + +```bash +OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored --nocapture +``` + +A run on 2026-09-09 against a service-user token confirmed, end to end: +`GET /v3/self` (`principal_type: service_user`, `org-` id), organization-scoped +session listing including the `qs` query document, session create with `repos`, +tags, title, `max_acu_limit` and `resumable`, cursor-paginated message polling, +the `claimed -> running/working` status-detail transitions, operator message +send, attachment listing, and `DELETE ...?archive=true` (the session settled at +`status: exit`, `is_archived: true`, with the `opensymphony:` correlation +tag applied). The normalized event sequence observed was `SessionCreated`, +`UserMessage`, two `StatusChanged`, `DevinMessage`, `SessionBlocked`. + +Live protocol compatibility is not availability evidence: the hosted-security +and tenancy gaps below still gate `available: true`. Known gaps (all blocking `available: true`): - Remote workspace ownership: repository binding, checkout verification, and artifact/evidence retrieval from the Devin-owned workspace are unimplemented. -- Event normalization: the mapping is a first pass against an unverified event - vocabulary and needs hardening evidence against the live API. +- Event normalization: verified against one live happy-path session only; + failure, suspension, resume, and long-running sessions are unexercised. - Authentication and secret handling: TLS pinning, authenticated event streams, and secret injection into remote sessions are unimplemented. - Tenant isolation: per-tenant credential scoping and isolation guarantees are diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index 8294b1286..a77421c0c 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -1,15 +1,27 @@ -use std::path::PathBuf; +//! Devin cloud harness boundary tests. +//! +//! Request shapes are checked against the vendored Devin API v3 OpenAPI subset +//! in `crates/opensymphony-devin/contracts/devin-v3-openapi.yaml`, and response +//! decoding is exercised with payloads shaped like the documented schemas. +//! Nothing here talks to the live API. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; use opensymphony::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_REMOTE_CONTAINMENT, DEVIN_UNDISCRIMINATED_EVENT_KIND, - DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, DevinHttpMethod, DevinLifecycleRequest, - DevinRemoteWorkspaceBinding, DevinRequestBuilder, NormalizedDevinEventKind, - devin_event_summary, normalize_devin_event, normalize_event_page, - normalized_event_to_journal_record, + DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_REMOTE_CONTAINMENT, + DEVIN_UNDISCRIMINATED_EVENT_KIND, DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, + DevinHttpMethod, DevinMessageCursor, DevinMode, DevinOperation, DevinProblemDetail, + DevinRemoteWorkspaceBinding, DevinRequest, DevinRequestBuilder, DevinSessionOptions, + DevinSessionStatus, DevinStatusDetail, NormalizedDevinEventKind, PaginatedResponse, + SessionMessage, SessionResponse, SessionsQueryParams, devin_event_summary, + normalize_devin_event, normalized_event_to_journal_record, session_create_request, }; use opensymphony::opensymphony_domain::HarnessAdapter; use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; -use serde_json::json; +use serde_json::{Value, json}; + +const ORG_ID: &str = "org-abc123"; fn binding() -> DevinRemoteWorkspaceBinding { DevinRemoteWorkspaceBinding::new( @@ -21,6 +33,74 @@ fn binding() -> DevinRemoteWorkspaceBinding { .expect("binding") } +fn builder() -> DevinRequestBuilder { + DevinRequestBuilder::new("https://api.devin.ai", ORG_ID) +} + +fn contract() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("crates/opensymphony-devin/contracts/devin-v3-openapi.yaml"); + let text = std::fs::read_to_string(path).expect("vendored devin v3 contract"); + serde_yaml::from_str(&text).expect("contract parses as yaml") +} + +/// Path with the concrete organization and session identifiers replaced by the +/// OpenAPI template variables, and query suffix removed. +fn as_path_template(path: &str) -> String { + path.split('?') + .next() + .unwrap_or(path) + .replace(ORG_ID, "{org_id}") + .replace("devin-1", "{devin_id}") +} + +fn session_json() -> Value { + json!({ + "session_id": "devin-1", + "url": "https://app.devin.ai/sessions/1", + "status": "running", + "status_detail": "working", + "tags": ["opensymphony:acme-123"], + "org_id": ORG_ID, + "created_at": 1_757_000_000, + "updated_at": 1_757_000_600, + "acus_consumed": 1.5, + "pull_requests": [{ "pr_url": "https://github.com/acme/api/pull/7", "pr_state": "open" }], + "structured_output": { "summary": "done" }, + "title": "Fix the flake", + "is_archived": false, + "devin_mode": "fast", + "a_field_devin_added_later": { "nested": true }, + }) +} + +fn session(status: &str, status_detail: Option<&str>) -> SessionResponse { + let mut value = session_json(); + value["status"] = json!(status); + value["status_detail"] = match status_detail { + Some(detail) => json!(detail), + None => Value::Null, + }; + serde_json::from_value(value).expect("session decodes") +} + +fn message_page(messages: &[(&str, &str, &str)], end_cursor: Option<&str>, more: bool) -> Value { + json!({ + "items": messages + .iter() + .map(|(event_id, source, message)| json!({ + "event_id": event_id, + "source": source, + "message": message, + "created_at": 1_757_000_100_i64, + })) + .collect::>(), + "end_cursor": end_cursor, + "has_next_page": more, + "total": messages.len(), + }) +} + #[test] fn adapter_exposes_stable_unavailable_capability() { let adapter = DevinCloudAdapter::default(); @@ -49,220 +129,505 @@ fn remote_binding_keeps_local_workspace_as_evidence_only() { binding.local_evidence_path, PathBuf::from("/workspaces/acme-123") ); - assert!( - DevinRemoteWorkspaceBinding::new( - "acme-123", - PathBuf::from("/workspaces/acme-123"), - "https://token@github.com/acme/api", - None, - ) - .is_err() + assert_eq!(binding.repo_name().as_deref(), Some("acme/api")); + assert_eq!(binding.correlation_tag(), "opensymphony:acme-123"); + // The API has no branch field, so branch intent has to reach Devin through + // the prompt. + let prompt = binding.compose_prompt("fix the flake"); + assert!(prompt.contains("Base branch: main")); + assert!(prompt.contains("evidence only")); + assert!(prompt.trim_end().ends_with("fix the flake")); + + for rejected in [ + "https://token@github.com/acme/api", + "git@github.com:acme/api.git", + "https://github.com/acme/api?token=secret", + "https://github.com/acme/api#token=secret", + ] { + assert!( + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + rejected, + None, + ) + .is_err(), + "{rejected} must be rejected" + ); + } +} + +#[test] +fn config_rejects_insecure_credentialed_or_malformed_settings() { + assert!(DevinCloudConfig::default().validate().is_ok()); + + for invalid in [ + DevinCloudConfig { + base_url: "http://api.devin.ai".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + base_url: "https://user:secret@api.devin.ai".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + base_url: "https://api.devin.ai?token=secret".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + api_key_env: "devin key".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + org_id_env: "devin org".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + org_id: Some("acme".into()), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + session: DevinSessionOptions { + max_acu_limit: Some(0), + ..DevinSessionOptions::default() + }, + ..DevinCloudConfig::default() + }, + ] { + assert!(invalid.validate().is_err(), "{invalid:?} must be rejected"); + } +} + +#[test] +fn request_paths_match_the_vendored_v3_contract() { + let contract = contract(); + let paths = contract["paths"] + .as_object() + .expect("contract paths") + .keys() + .cloned() + .collect::>(); + let builder = builder(); + + let requests: Vec<(DevinRequest, DevinHttpMethod, DevinOperation)> = vec![ + ( + builder.get_self(), + DevinHttpMethod::Get, + DevinOperation::GetSelf, + ), + ( + builder.create_session(&session_create_request( + "fix the flake", + &binding(), + &DevinSessionOptions::default(), + )), + DevinHttpMethod::Post, + DevinOperation::CreateSession, + ), + ( + builder.get_session("devin-1"), + DevinHttpMethod::Get, + DevinOperation::GetSession, + ), + ( + builder.list_sessions(&SessionsQueryParams::by_tag("opensymphony:acme-123")), + DevinHttpMethod::Get, + DevinOperation::ListSessions, + ), + ( + builder.list_messages("devin-1", Some("cursor-1"), Some(50)), + DevinHttpMethod::Get, + DevinOperation::ListSessionMessages, + ), + ( + builder.send_message("devin-1", "please rerun the tests"), + DevinHttpMethod::Post, + DevinOperation::SendSessionMessage, + ), + ( + builder.delete_session("devin-1", true), + DevinHttpMethod::Delete, + DevinOperation::DeleteSession, + ), + ( + builder.list_attachments("devin-1"), + DevinHttpMethod::Get, + DevinOperation::ListSessionAttachments, + ), + ( + builder.replace_tags("devin-1", vec!["opensymphony:acme-123".into()]), + DevinHttpMethod::Put, + DevinOperation::UpdateSessionTags, + ), + ]; + + for (request, method, operation) in requests { + let template = as_path_template(&request.path); + assert!( + paths.contains(&template), + "`{template}` is not a documented v3 path" + ); + let documented = &contract["paths"][&template][method.as_str().to_lowercase()]; + assert!( + documented.is_object(), + "`{} {template}` is not documented", + method.as_str() + ); + assert_eq!(request.method, method); + assert_eq!(request.operation, operation); + } + + assert_eq!(DEVIN_CLOUD_API_CONTRACT, "devin-api-v3"); +} + +#[test] +fn session_requests_carry_documented_query_and_body_shapes() { + let builder = builder(); + + let messages = builder.list_messages("devin-1", Some("cursor 1"), Some(500)); + assert_eq!( + messages.path, + format!("/v3/organizations/{ORG_ID}/sessions/devin-1/messages?after=cursor%201&first=200") ); - assert!( - DevinRemoteWorkspaceBinding::new( - "acme-123", - PathBuf::from("/workspaces/acme-123"), - "git@github.com:acme/api.git", - None, + assert_eq!( + builder + .absolute_url(&messages) + .expect("absolute url") + .as_str(), + format!( + "https://api.devin.ai/v3/organizations/{ORG_ID}/sessions/devin-1/messages?after=cursor%201&first=200" ) - .is_err() ); - assert!( - DevinRemoteWorkspaceBinding::new( - "acme-123", - PathBuf::from("/workspaces/acme-123"), - "https://github.com/acme/api?token=secret", - None, - ) - .is_err() + + let listed = builder.list_sessions(&SessionsQueryParams::by_tag("opensymphony:acme-123")); + let query = listed + .path + .split_once("?qs=") + .map(|(_, encoded)| encoded.to_owned()) + .expect("qs parameter"); + let decoded = percent_decode(&query); + assert_eq!( + serde_json::from_str::(&decoded).expect("qs is json"), + json!({ "tags": ["opensymphony:acme-123"] }) ); - assert!( - DevinRemoteWorkspaceBinding::new( - "acme-123", - PathBuf::from("/workspaces/acme-123"), - "https://github.com/acme/api#token=secret", - None, - ) - .is_err() + + let terminate = builder.delete_session("devin-1", false); + assert!(terminate.path.ends_with("?archive=false")); + + assert_eq!( + builder.send_message("devin-1", "rerun").body, + Some(json!({ "message": "rerun" })) + ); + assert_eq!( + builder.replace_tags("devin-1", vec!["a".into()]).body, + Some(json!({ "tags": ["a"] })) ); } -#[test] -fn config_rejects_insecure_or_credentialed_endpoints() { - assert!(DevinCloudConfig::default().validate().is_ok()); +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).expect("hex digits"); + decoded.push(u8::from_str_radix(hex, 16).expect("hex byte")); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).expect("utf-8") +} - let insecure = DevinCloudConfig { - base_url: "http://api.devin.ai/v1".into(), - ..DevinCloudConfig::default() +#[test] +fn create_session_request_maps_binding_and_options_onto_v3_fields() { + let options = DevinSessionOptions { + playbook_id: Some("playbook-1".into()), + knowledge_ids: Some(vec!["note-1".into()]), + secret_ids: Some(vec!["secret-1".into()]), + max_acu_limit: Some(20), + tags: vec!["team:core".into()], + title: Some("Fix the flake".into()), + devin_mode: Some(DevinMode::Fast), + platform: Some("windows".into()), + resumable: true, }; - assert!(insecure.validate().is_err()); + let request = session_create_request("fix the flake", &binding(), &options); - let credentialed = DevinCloudConfig { - base_url: "https://user:secret@api.devin.ai/v1".into(), - ..DevinCloudConfig::default() - }; - assert!(credentialed.validate().is_err()); + assert_eq!(request.repos, Some(vec!["acme/api".to_owned()])); + assert_eq!( + request.tags, + Some(vec![ + "team:core".to_owned(), + "opensymphony:acme-123".to_owned() + ]) + ); + assert_eq!(request.devin_mode, Some(DevinMode::Fast)); + assert_eq!(request.resumable, Some(true)); + assert!( + request + .prompt + .contains("Repository: https://github.com/acme/api") + ); - let bad_env = DevinCloudConfig { - api_key_env: "devin key".into(), - ..DevinCloudConfig::default() - }; - assert!(bad_env.validate().is_err()); + // Secret *references* are forwarded; values are never part of the request. + assert_eq!(request.secret_ids, Some(vec!["secret-1".to_owned()])); - let decorated = DevinCloudConfig { - base_url: "https://api.devin.ai/v1?token=secret".into(), - ..DevinCloudConfig::default() + let body = serde_json::to_value(&request).expect("serializes"); + assert!(body.get("structured_output_schema").is_none()); + assert_eq!(body["max_acu_limit"], json!(20)); + + let crowded = DevinSessionOptions { + tags: (0..60).map(|index| format!("tag-{index}")).collect(), + ..DevinSessionOptions::default() }; - assert!(decorated.validate().is_err()); + let request = session_create_request("fix the flake", &binding(), &crowded); + assert_eq!(request.tags.expect("tags").len(), 50); } #[test] -fn lifecycle_requests_cover_session_message_run_and_events() { - let builder = DevinRequestBuilder::new("https://api.devin.ai/v1"); - let binding = binding(); +fn documented_payloads_decode_and_retain_unknown_fields() { + let session: SessionResponse = serde_json::from_value(session_json()).expect("session decodes"); - let create = builder.create_session("fix the flake", &binding, Some("run-1".into())); - assert_eq!(create.lifecycle, DevinLifecycleRequest::SessionCreate); - assert_eq!(create.method, DevinHttpMethod::Post); - assert_eq!(create.path, "/sessions"); - let body = create.body.expect("create body"); - assert_eq!(body["prompt"], json!("fix the flake")); - assert_eq!(body["repository"]["url"], json!(binding.repository_url)); - assert_eq!(body["repository"]["base_branch"], json!("main")); - assert_eq!(body["idempotency_key"], json!("run-1")); - - let resume = builder.resume_session("sess-1"); - assert_eq!(resume.lifecycle, DevinLifecycleRequest::SessionResume); - assert_eq!(resume.path, "/sessions/sess-1/resume"); - - let message = builder.send_message("sess-1", "please rerun the tests"); - assert_eq!(message.lifecycle, DevinLifecycleRequest::MessageSend); - assert_eq!(message.path, "/sessions/sess-1/messages"); + assert_eq!(session.session_id, "devin-1"); + assert_eq!(session.status, DevinSessionStatus::Running); assert_eq!( - message.body.expect("message body")["message"], - json!("please rerun the tests") + session.status_detail, + Some(DevinStatusDetail("working".into())) + ); + assert_eq!(session.acus_consumed, 1.5); + assert_eq!( + session.pull_requests[0].pr_url, + "https://github.com/acme/api/pull/7" + ); + assert_eq!( + session.extra.get("a_field_devin_added_later"), + Some(&json!({ "nested": true })) + ); + assert!(!session.is_settled()); + + // A status Devin adds later must not fail decoding. + let future: SessionResponse = serde_json::from_value(json!({ + "session_id": "devin-1", + "url": "https://app.devin.ai/sessions/1", + "status": "hibernating", + "org_id": ORG_ID, + "created_at": 1, + "updated_at": 2, + })) + .expect("unknown status decodes"); + assert_eq!( + future.status, + DevinSessionStatus::Other("hibernating".into()) ); - let start = builder.start_run("sess-1", "continue"); - assert_eq!(start.lifecycle, DevinLifecycleRequest::RunStart); - assert_eq!(start.path, "/sessions/sess-1/runs"); + let page: PaginatedResponse = serde_json::from_value(message_page( + &[("event-1", "devin", "starting work")], + Some("cursor-1"), + true, + )) + .expect("page decodes"); + assert_eq!(page.items[0].event_id, "event-1"); + assert!(page.has_next_page); + assert_eq!(page.end_cursor.as_deref(), Some("cursor-1")); + + let problem: DevinProblemDetail = serde_json::from_value(json!({ + "title": "Not Found", + "status": 404, + "detail": "session devin-1 does not exist", + })) + .expect("problem decodes"); + assert_eq!( + problem.to_string(), + "Not Found: session devin-1 does not exist" + ); +} - let cancel = builder.cancel_run("sess-1", "run-9"); - assert_eq!(cancel.lifecycle, DevinLifecycleRequest::RunCancel); - assert_eq!(cancel.path, "/sessions/sess-1/runs/run-9/cancel"); +#[test] +fn message_cursor_deduplicates_replayed_pages_and_tracks_status() { + let mut cursor = DevinMessageCursor::new(); + let first: PaginatedResponse = serde_json::from_value(message_page( + &[ + ("event-1", "devin", "starting work"), + ("event-2", "user", "please rerun"), + ], + Some("cursor-1"), + true, + )) + .expect("page decodes"); + + let events = cursor.ingest_messages("devin-1", &first); + assert_eq!(events.len(), 2); + assert_eq!(events[0].kind, NormalizedDevinEventKind::DevinMessage); + assert_eq!(events[1].kind, NormalizedDevinEventKind::UserMessage); + assert_eq!(cursor.cursor(), Some("cursor-1")); + + // Devin replays the last page plus one new message. + let replayed: PaginatedResponse = serde_json::from_value(message_page( + &[ + ("event-2", "user", "please rerun"), + ("event-3", "devin", "rerunning"), + ], + Some("cursor-2"), + false, + )) + .expect("page decodes"); + let events = cursor.ingest_messages("devin-1", &replayed); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id.as_deref(), Some("event-3")); + assert_eq!(cursor.cursor(), Some("cursor-2")); + + // A source Devin adds later is preserved rather than dropped. + let unknown_source: PaginatedResponse = serde_json::from_value(message_page( + &[("event-4", "system", "vm resized")], + Some("cursor-3"), + false, + )) + .expect("page decodes"); + let events = cursor.ingest_messages("devin-1", &unknown_source); + assert_eq!(events[0].kind, NormalizedDevinEventKind::Unknown); + assert_eq!(events[0].raw["source"], json!("system")); - let events = builder.fetch_events("sess-1", Some(42)); - assert_eq!(events.lifecycle, DevinLifecycleRequest::EventFetch); - assert_eq!(events.method, DevinHttpMethod::Get); - assert_eq!(events.path, "/sessions/sess-1/events?after=42"); assert_eq!( - builder - .absolute_url(&events) - .expect("absolute url") - .as_str(), - "https://api.devin.ai/v1/sessions/sess-1/events?after=42" + cursor + .ingest_status(&session("running", Some("working"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::StatusChanged) + ); + assert!( + cursor + .ingest_status(&session("running", Some("working"))) + .is_none(), + "an unchanged status must not re-emit" + ); + assert_eq!( + cursor + .ingest_status(&session("running", Some("waiting_for_user"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionBlocked) + ); + assert_eq!( + cursor + .ingest_status(&session("running", Some("finished"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionFinished) + ); + assert_eq!( + cursor + .ingest_status(&session("error", Some("blocked_error"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionFailed) + ); + assert_eq!( + cursor + .ingest_status(&session("suspended", None)) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionSuspended) ); } #[test] -fn client_requires_the_configured_credential_environment_variable() { - let config = DevinCloudConfig::default(); +fn client_requires_credentials_and_a_resolvable_organization() { + let config = DevinCloudConfig { + org_id: Some(ORG_ID.into()), + ..DevinCloudConfig::default() + }; assert!(DevinCloudClient::from_environment(&config, |_| None).is_err()); assert!(DevinCloudClient::from_environment(&config, |_| Some(" ".into())).is_err()); - assert!( - DevinCloudClient::from_environment(&config, |name| { - (name == config.api_key_env).then(|| "devin-token".to_owned()) + + let client = DevinCloudClient::from_environment(&config, |name| { + (name == config.api_key_env).then(|| "cog_token".to_owned()) + }) + .expect("client"); + assert_eq!(client.org_id(), Some(ORG_ID)); + assert!(format!("{client:?}").contains("redacted")); + assert!(!format!("{client:?}").contains("cog_token")); + + // The organization can also be supplied beside the credential. + let discovered = + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some(ORG_ID.to_owned()), + _ => None, }) - .is_ok() - ); + .expect("client"); + assert_eq!(discovered.org_id(), Some(ORG_ID)); + + // Without an organization, requests cannot be built until `/v3/self` + // resolves one. + let unresolved = DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| { + (name == "COG_SERVICE_USER_TOKEN").then(|| "cog_token".to_owned()) + }) + .expect("client"); + assert!(unresolved.org_id().is_none()); + assert!(unresolved.requests().is_err()); // A bearer credential is attached to every request, so an invalid endpoint - // must be rejected before the token is ever handed to the HTTP client. + // must be rejected before the token is read. let invalid = DevinCloudConfig { - base_url: "http://api.devin.ai/v1".into(), + base_url: "http://api.devin.ai".into(), ..DevinCloudConfig::default() }; - assert!(DevinCloudClient::from_environment(&invalid, |_| Some("devin-token".into())).is_err()); + assert!(DevinCloudClient::from_environment(&invalid, |_| Some("cog_token".into())).is_err()); + + // A malformed organization from the environment is rejected too. + let bad_org = + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some("acme".to_owned()), + _ => None, + }); + assert!(bad_org.is_err()); } #[test] -fn known_events_normalize_into_journal_records() { - let event = normalize_devin_event(json!({ - "type": "run.completed", - - "session_id": "sess-1", - "run_id": "run-9", - "cursor": 7, - "status": "finished", - })); - - assert_eq!(event.kind, NormalizedDevinEventKind::RunCompleted); - assert_eq!(event.session_id.as_deref(), Some("sess-1")); - assert_eq!(event.run_id.as_deref(), Some("run-9")); - assert_eq!(event.cursor, Some(7)); - assert_eq!(devin_event_summary(&event), "Devin run completed"); +fn messages_normalize_into_journal_records() { + let session = session("exit", Some("finished")); + let mut cursor = DevinMessageCursor::new(); + let event = cursor.ingest_status(&session).expect("status event"); + + assert_eq!(event.kind, NormalizedDevinEventKind::SessionFinished); + assert_eq!(devin_event_summary(&event), "Devin session finished"); let record = normalized_event_to_journal_record("run-9", 7, &event); assert_eq!(record.kind, EventKind::RunCompleted); assert_eq!(record.actor.kind_label(), "harness"); assert_eq!( record.payload.expect("payload")["session_id"], - json!("sess-1") + json!("devin-1") ); assert_eq!(record.raw_payload_ref.as_deref(), Some("devin:run-9:7")); } #[test] -fn unknown_events_retain_their_raw_payload() { +fn undecodable_payloads_are_preserved_as_unknown() { let raw = json!({ - "type": "devin.future_event", - "session_id": "sess-1", + "source": "devin", + "session_id": "devin-1", "payload": { "nested": [1, 2, 3] }, }); let event = normalize_devin_event(raw.clone()); + assert_eq!(event.kind, NormalizedDevinEventKind::DevinMessage); + assert_eq!(event.raw, raw); + let undiscriminated = json!({ "session_id": "devin-1", "details": { "nested": true } }); + let event = normalize_devin_event(undiscriminated.clone()); assert_eq!(event.kind, NormalizedDevinEventKind::Unknown); - assert_eq!(event.raw, raw); + assert_eq!(event.event_type, DEVIN_UNDISCRIMINATED_EVENT_KIND); + assert_eq!(event.raw, undiscriminated); let record = normalized_event_to_journal_record("run-9", 3, &event); assert_eq!( record.kind, EventKind::Unknown { - raw_kind: "devin.future_event".to_owned(), + raw_kind: DEVIN_UNDISCRIMINATED_EVENT_KIND.to_owned(), } ); - assert_eq!(record.payload.expect("payload")["raw_payload"], raw); -} - -#[test] -fn events_without_a_discriminator_are_preserved_as_unknown() { - let raw = json!({ "session_id": "sess-1", "details": { "nested": true } }); - let event = normalize_devin_event(raw.clone()); - - assert_eq!(event.kind, NormalizedDevinEventKind::Unknown); - assert_eq!(event.event_type, DEVIN_UNDISCRIMINATED_EVENT_KIND); - assert_eq!(event.raw, raw); -} - -#[test] -fn event_pages_accept_arrays_and_envelopes() { - let page = json!({ - "events": [ - { "type": "devin_message", "message": "starting work" }, - { "type": "tool_call" }, - { "missing_type": true }, - ] - }); - let normalized = normalize_event_page(&page); - - assert_eq!(normalized.len(), 3); - assert_eq!(normalized[0].kind, NormalizedDevinEventKind::AgentMessage); - assert_eq!(devin_event_summary(&normalized[0]), "starting work"); - assert_eq!(normalized[1].kind, NormalizedDevinEventKind::ToolCall); - assert_eq!(normalized[2].kind, NormalizedDevinEventKind::Unknown); - assert_eq!(normalized[2].raw, json!({ "missing_type": true })); - - let bare = json!([{ "type": "run.started" }]); - assert_eq!(normalize_event_page(&bare).len(), 1); + assert_eq!( + record.payload.expect("payload")["raw_payload"], + undiscriminated + ); } diff --git a/tests/devin_cloud_agent_live.rs b/tests/devin_cloud_agent_live.rs new file mode 100644 index 000000000..abf85c8a9 --- /dev/null +++ b/tests/devin_cloud_agent_live.rs @@ -0,0 +1,147 @@ +//! Live Devin API v3 verification. +//! +//! These tests talk to `https://api.devin.ai` with a real service-user token and +//! are opt-in: they are `#[ignore]`d and additionally skip themselves unless +//! `OPENSYMPHONY_DEVIN_LIVE=1` is set along with the credential environment +//! variables. `devin_live_session_lifecycle` creates a real session and +//! therefore consumes ACUs; it terminates and archives the session it creates. +//! +//! ```bash +//! OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored --nocapture +//! ``` + +use std::path::PathBuf; +use std::time::Duration; + +use opensymphony::opensymphony_devin::{ + DevinCloudClient, DevinCloudConfig, DevinRemoteWorkspaceBinding, DevinRunOptions, + DevinRunOutcome, DevinSessionOptions, DevinSessionRunner, SessionsQueryParams, + devin_event_summary, session_create_request, +}; + +const LIVE_ENV: &str = "OPENSYMPHONY_DEVIN_LIVE"; + +fn live_config() -> Option { + if std::env::var(LIVE_ENV).ok().as_deref() != Some("1") { + eprintln!("skipping: set {LIVE_ENV}=1 to run live Devin API tests"); + return None; + } + Some(DevinCloudConfig::default()) +} + +async fn live_client(config: &DevinCloudConfig) -> DevinCloudClient { + DevinCloudClient::from_environment(config, |name| std::env::var(name).ok()) + .expect("credentials from the environment") + .resolve_org_id() + .await + .expect("organization resolves") +} + +#[tokio::test] +#[ignore = "live Devin API call"] +async fn devin_live_identity_and_session_listing() { + let Some(config) = live_config() else { return }; + let client = live_client(&config).await; + + let identity = client.identity().await.expect("GET /v3/self"); + assert_eq!(identity.principal_type.as_deref(), Some("service_user")); + let org_id = identity.org_id.as_deref().expect("org_id from /v3/self"); + assert!(org_id.starts_with("org-"), "unexpected org id shape"); + assert_eq!(client.org_id(), Some(org_id)); + + // `qs` carries the session query as a JSON document. + let sessions = client + .list_sessions(&SessionsQueryParams::default()) + .await + .expect("GET /v3/organizations/{org_id}/sessions"); + for session in &sessions.items { + assert_eq!(session.org_id, org_id); + assert!(!session.session_id.is_empty()); + assert!(session.url.starts_with("https://")); + } + println!( + "listed {} session(s); has_next_page={}", + sessions.items.len(), + sessions.has_next_page + ); +} + +/// Creates a real session, polls it through the normalization path, sends an +/// operator message, then terminates and archives it. This consumes ACUs. +#[tokio::test] +#[ignore = "live Devin API call that consumes ACUs"] +async fn devin_live_session_lifecycle() { + let Some(mut config) = live_config() else { + return; + }; + config.poll_interval = Duration::from_secs(5); + config.session = DevinSessionOptions { + max_acu_limit: Some(2), + title: Some("OpenSymphony harness live contract check".into()), + tags: vec!["opensymphony-live-check".into()], + resumable: false, + ..DevinSessionOptions::default() + }; + + let client = live_client(&config).await; + let runner = DevinSessionRunner::new( + client, + DevinRunOptions { + stop_when_waiting_on_operator: true, + max_duration: Some(Duration::from_secs(240)), + collect_attachments: true, + message_page_size: 100, + }, + ); + + let binding = DevinRemoteWorkspaceBinding::new( + "opensymphony-live-check", + PathBuf::from("/tmp/opensymphony-live-check"), + "https://github.com/kumanday/OpenSymphony", + Some("main".to_owned()), + ) + .expect("binding"); + let request = session_create_request( + "Reply with the single word ACK and stop. Do not read the repository, \ + do not run commands, do not make changes, and do not open a pull request.", + &binding, + &config.session, + ); + + let mut events = Vec::new(); + let report = runner + .run(&request, |event| { + println!("[{:?}] {}", event.kind, devin_event_summary(&event)); + events.push(event); + }) + .await; + + let report = match report { + Ok(report) => report, + Err(error) => panic!("live session run failed: {error}"), + }; + + println!( + "session {} outcome={:?} status={} acus={}", + report.session_id, + report.outcome, + report.status.as_str(), + report.acus_consumed + ); + assert!(!events.is_empty(), "no normalized events were observed"); + assert!(matches!( + report.outcome, + DevinRunOutcome::Finished | DevinRunOutcome::WaitingOnOperator + )); + + runner + .send_message(&report.session_id, "Thanks, that is all.") + .await + .expect("POST .../messages"); + + let terminated = runner + .terminate(&report.session_id, true) + .await + .expect("DELETE .../sessions/{devin_id}?archive=true"); + assert_eq!(terminated.outcome, DevinRunOutcome::Terminated); +} From b20fee6e14e06ef2fcd2d749f1fe8839d3f681be Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:46:08 +0000 Subject: [PATCH 04/13] feat(devin): make the devin cloud harness a live, available route Summary: - Wire `devin_cloud_agent` through the CLI orchestrator route: bind tenancy, resolve secret references, create the session, stream normalized events, import evidence, and settle the worker outcome. - Bind every client to one organization via `GET /v3/self`, rejecting a configured foreign org and any session/list/message payload owned by another org. - Inject secrets by organization-scoped Devin secret id or key resolved from the bound org; raw values never enter OpenSymphony. - Import remote evidence into the issue workspace under `.opensymphony/devin/` (session, event journal, manifest, attachments) with origin, size, count, and path-containment limits. - Terminate and archive the remote session on poll timeout, transport failure, scheduler interrupt, and worker task drop. - Flip the capability to available with `devin-api-v3` and explicit feature gaps; update gateway, desktop, scheduler, and schema tests. - Rewrite the Devin sections of AGENTS.md and the configuration, harness-compatibility, and host-client-architecture docs. Rationale: - The harness was a boundary-only scaffold: the route rejected dispatch, so nothing exercised the client. The hosted gaps (tenancy, secrets, artifact retrieval) had to close before the route could be honest. - Availability is now backed by live evidence rather than fixtures, and the remaining limitations are contract limits of Devin API v3, so they belong in `feature_gaps` rather than blocking the capability. - A Devin session outlives the worker process and bills ACUs, so every abandoned path had to terminate and archive explicitly. Tests: - cargo fmt --all - cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent (17), --test gateway_schema (58), --test gateway (77), --test scheduler (94), --lib devin (5) - cargo test --lib: 1237 passed, 1 pre-existing sandbox failure (memory::tests::configured_memory_checkout_must_match_canonical_remote, git remotes rewritten to a proxy URL in this environment) - OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored: 4 passed against api.devin.ai (identity/listing, tenancy + secret scope, full lifecycle with evidence import, poll-timeout cleanup verified archived) Co-Authored-By: Leonardo Gonzalez --- AGENTS.md | 39 +- apps/desktop/src-tauri/src/commands.rs | 4 +- .../src/orchestrator_run/backends.rs | 497 ++++++++++++- crates/opensymphony-devin/src/lib.rs | 684 +++++++++++++++++- .../src/capability.rs | 35 +- .../tests/gateway_schema.rs | 28 +- crates/opensymphony-gateway/src/lib.rs | 2 +- crates/opensymphony-gateway/tests/gateway.rs | 3 +- .../tests/scheduler.rs | 40 +- crates/opensymphony-workspace/src/manager.rs | 8 + docs/configuration.md | 50 +- docs/harness-adapter-compatibility.md | 98 ++- docs/host-client-architecture.md | 23 +- tests/devin_cloud_agent.rs | 274 ++++++- tests/devin_cloud_agent_live.rs | 151 +++- 15 files changed, 1791 insertions(+), 145 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b9b3b8129..8db0883d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,13 +171,38 @@ pools, remote routing, or loopback WebSocket as production-ready until those paths have their own hardening evidence. The remote Devin cloud harness uses the `opensymphony_devin` internal module -boundary and is advertised as an unavailable HTTPS capability with explicit -feature gaps. Devin owns its own remote execution workspace: never treat a local -issue workspace path as Devin's working directory, and keep credentials as -environment-variable references rather than values in workflow config, -manifests, or logs. Do not flip `devin_cloud_agent` to available until hosted -security (TLS, authenticated streams, secret injection, tenant isolation), -remote workspace ownership, and event-normalization hardening evidence exist. +boundary and is advertised as an available HTTPS capability against the Devin +API v3 contract (`devin-api-v3`), with explicit feature gaps for the properties +the hosted API does not provide: cursor-paginated polling instead of a push +stream, cancellation as session termination instead of a mid-run interrupt, no +pause/resume or approval flow, and model selection fixed at session creation. +Devin owns its own remote execution workspace: never treat a local issue +workspace path as Devin's working directory. The scheduler-bound issue workspace +holds run manifests, journals, and imported evidence only. + +Devin hardening rules that must hold for any change to this harness: + +- Every client binds to exactly one organization through `GET /v3/self` before + any session call, and session, listing, and message payloads owned by another + organization are rejected rather than journaled. +- Credentials stay environment-variable references in workflow config, + manifests, and logs; the token is never serialized or logged. +- Secrets are injected by organization-scoped Devin `secret_ids` resolved from + the bound organization; raw secret values never enter OpenSymphony. +- Transport is HTTPS-only with TLS 1.2+, no redirects, and no credentials, + query, or fragment in the configured base URL. Certificate pinning is not + implemented; do not claim it. +- Evidence downloads are restricted to the authenticated API origin, bounded by + size and count limits, and written under sanitized names inside the issue + workspace. +- Every abandoned path (poll timeout, transport failure, scheduler interrupt, + worker task drop) must terminate and archive the remote session; an orphaned + Devin session keeps consuming ACUs. + +Changes to the route or the client must keep the opt-in live suite +(`OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- +--ignored`) passing; it is the only evidence that the hosted contract still +holds. ### Preserve forward compatibility diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 74e9adf6f..957e5baff 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1542,7 +1542,7 @@ pub async fn gateway_capabilities() -> CommandResult { harnesses: vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), - HarnessCapability::devin_cloud_future(), + HarnessCapability::devin_cloud_agent(), HarnessCapability::rust_native_future(), ], features: vec![ @@ -1789,7 +1789,7 @@ mod tests { vec![ ("openhands_agent_server", true), ("codex_app_server", true), - ("devin_cloud_agent", false), + ("devin_cloud_agent", true), ("rust_native", false), ] ); diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 3cc8f60ac..7b654b4a3 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -20,7 +20,11 @@ use crate::opensymphony_codex::{ turn_status, }; use crate::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DevinCloudAdapter, DevinCloudConfig, DevinMode, DevinSessionOptions, + DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_EVIDENCE_DIR_NAME, DevinCloudClient, + DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinMode, + DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, DevinRunReport, + DevinSessionOptions, DevinSessionRunner, devin_event_payload, devin_event_summary, + session_create_request, session_created_event, }; use crate::opensymphony_domain::{ ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, IssueIdentifier, @@ -171,6 +175,7 @@ pub(super) struct RuntimeWorkerBackend { codex_bin: String, codex_schema_validators: CodexSchemaValidatorCache, codex_interrupts: CodexInterruptRegistry, + devin_sessions: DevinSessionRegistry, launch_timeout: Duration, updates_tx: mpsc::UnboundedSender, updates_rx: mpsc::UnboundedReceiver, @@ -181,6 +186,9 @@ pub(super) struct RuntimeWorkerBackend { type CodexSchemaValidatorCache = Arc>>; type CodexInterruptRegistry = Arc>>>>; type CodexInterruptResponseRegistry = Arc>>>>; +/// Live Devin sessions keyed by session id, so a scheduler interrupt can stop +/// the remote session that a worker task is currently polling. +type DevinSessionRegistry = Arc>>; struct ActiveWorkerTask { handle: JoinHandle<()>, @@ -2088,6 +2096,7 @@ impl RuntimeWorkerBackend { codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), codex_schema_validators: Arc::new(AsyncMutex::new(HashMap::new())), codex_interrupts: Arc::new(Mutex::new(HashMap::new())), + devin_sessions: Arc::new(Mutex::new(HashMap::new())), launch_timeout: DEFAULT_WORKER_LAUNCH_TIMEOUT, updates_tx, updates_rx, @@ -2193,24 +2202,49 @@ impl RuntimeWorkerBackend { let codex_bin = self.codex_bin.clone(); let codex_schema_validators = Arc::clone(&self.codex_schema_validators); let codex_interrupts = Arc::clone(&self.codex_interrupts); + let devin_sessions = Arc::clone(&self.devin_sessions); let launch_worker_id = worker_id.clone(); let handle = tokio::spawn(async move { let mut launch_tx = Some(launch_tx); let run_id = format!("run-{launch_worker_id}"); if route.harness_kind == DEVIN_CLOUD_AGENT_KIND { - // Devin executes in a Devin-owned cloud workspace, so the local - // issue workspace is never prepared as an execution cwd for this - // route. Dispatch resolves the adapter first and refuses the run - // while the harness capability is advertised as unavailable. - let detail = devin_route_unavailable_detail(&workflow); - report_launch_failure(&mut launch_tx, detail.clone()); - let outcome = WorkerOutcomeRecord::from_run( - &run, - WorkerOutcomeKind::Failed, - now_timestamp(), - Some("devin cloud routing is not available".to_owned()), - Some(detail), - ); + // Devin executes in a Devin-owned cloud workspace, so this route + // never prepares a local execution cwd: `ensure_with_run_id` and + // checkout verification below are for local harnesses only. The + // scheduler-bound workspace is used purely to store evidence + // imported back from the remote session. + let result = run_devin_cloud_route(DevinRouteContext { + workflow: &workflow, + issue: &issue, + run: &run, + route: &route, + run_id: &run_id, + workspace_manager: &workspace_manager, + updates_tx: &updates_tx, + devin_sessions: &devin_sessions, + worker_id: &finished_worker_id, + launch_tx: &mut launch_tx, + }) + .await; + let outcome = match result { + Ok(settlement) => WorkerOutcomeRecord::from_run( + &run, + settlement.outcome, + now_timestamp(), + Some(settlement.summary), + settlement.error, + ), + Err(detail) => { + report_launch_failure(&mut launch_tx, detail.clone()); + WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("devin cloud run failed".to_owned()), + Some(detail), + ) + } + }; let _ = updates_tx.send(WorkerUpdate::Finished { worker_id: finished_worker_id.clone(), outcome, @@ -5519,15 +5553,14 @@ fn transport_port_override(url: &Url) -> Result { }) } -/// Devin adapter dispatch detail. +/// Devin client configuration resolved from workflow front matter. /// -/// The workflow-resolved endpoint and credential *reference* are echoed so -/// operators can confirm routing configuration; the credential value itself is -/// only ever read from the worker environment by the Devin client. -fn devin_route_unavailable_detail(workflow: &ResolvedWorkflow) -> String { +/// Only the credential *reference* travels through configuration; the value is +/// read from the worker environment by the Devin client itself. +fn devin_cloud_config(workflow: &ResolvedWorkflow) -> DevinCloudConfig { let api = &workflow.extensions.devin.api; let session = &workflow.extensions.devin.session; - let adapter = DevinCloudAdapter::new(DevinCloudConfig { + DevinCloudConfig { base_url: api.base_url.clone(), api_key_env: api.api_key_env.clone(), org_id: api.org_id.clone(), @@ -5545,13 +5578,389 @@ fn devin_route_unavailable_detail(workflow: &ResolvedWorkflow) -> String { platform: session.platform.clone(), resumable: session.resumable, }, + } +} + +/// Everything the Devin route needs from the worker task. +struct DevinRouteContext<'context> { + workflow: &'context ResolvedWorkflow, + issue: &'context NormalizedIssue, + run: &'context crate::opensymphony_domain::RunAttempt, + route: &'context crate::opensymphony_orchestrator::HarnessRouteDecision, + run_id: &'context str, + workspace_manager: &'context WorkspaceManager, + updates_tx: &'context mpsc::UnboundedSender, + devin_sessions: &'context DevinSessionRegistry, + worker_id: &'context crate::opensymphony_domain::WorkerId, + launch_tx: &'context mut Option>, +} + +/// How a Devin session settled, in scheduler terms. +struct DevinSettlement { + outcome: WorkerOutcomeKind, + summary: String, + error: Option, +} + +/// Stops the remote session if the worker task is dropped mid-run. +/// +/// Scheduler aborts drop the worker future, which for a local harness kills the +/// child process. Devin's session lives in Devin's cloud, so it has to be +/// stopped explicitly or it would keep burning ACUs unattended. +struct DevinSessionGuard { + client: Option, + session_id: String, + registry: DevinSessionRegistry, +} + +impl DevinSessionGuard { + fn new(client: DevinCloudClient, session_id: String, registry: DevinSessionRegistry) -> Self { + if let Ok(mut sessions) = registry.lock() { + sessions.insert(session_id.clone(), client.clone()); + } + Self { + client: Some(client), + session_id, + registry, + } + } + + /// The session settled on its own; nothing left to stop. + fn disarm(&mut self) { + self.client = None; + } +} + +impl Drop for DevinSessionGuard { + fn drop(&mut self) { + if let Ok(mut sessions) = self.registry.lock() { + sessions.remove(&self.session_id); + } + let Some(client) = self.client.take() else { + return; + }; + let session_id = self.session_id.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + if let Err(error) = client.delete_session(&session_id, true).await { + tracing::warn!( + session_id, + error = %error, + "failed to stop devin session after worker abort" + ); + } + }); + } + } +} + +/// Runs one issue on Devin: bind tenancy, create the remote session, stream +/// normalized events to the orchestrator, then import evidence locally. +async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result { + let DevinRouteContext { + workflow, + issue, + run, + route, + run_id, + workspace_manager, + updates_tx, + devin_sessions, + worker_id, + launch_tx, + } = context; + + let config = devin_cloud_config(workflow); + let binding = devin_remote_binding(issue, run, run_id, workspace_manager)?; + let attempt = run.attempt.map(|attempt| attempt.get()).unwrap_or(1); + let task_prompt = devin_task_prompt(workflow, issue, attempt, route)?; + + let client = DevinCloudClient::from_environment(&config, |name| ProcessEnvironment.get(name)) + .map_err(|error| format!("devin client configuration failed: {error}"))?; + // No session traffic before the credential's organization is verified. + let client = client + .bind_tenant() + .await + .map_err(|error| format!("devin tenant binding failed: {error}"))?; + + let mut session_options = config.session.clone(); + if let Some(references) = session_options.secret_ids.as_ref() { + let resolved = client + .resolve_secret_ids(references) + .await + .map_err(|error| format!("devin secret resolution failed: {error}"))?; + session_options.secret_ids = Some(resolved); + } + + let request = session_create_request(&task_prompt, &binding, &session_options); + let created = client + .create_session(&request) + .await + .map_err(|error| format!("devin session creation failed: {error}"))?; + let mut guard = DevinSessionGuard::new( + client.clone(), + created.session_id.clone(), + Arc::clone(devin_sessions), + ); + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation(Box::new( + devin_conversation_metadata(&created.session_id, &config.base_url, route), + ))); + } + + let mut collector = DevinEvidenceCollector::new( + binding.local_evidence_path.clone(), + DevinEvidenceLimits::default(), + ); + let created_event = session_created_event(&created); + collector.record(&created_event); + publish_devin_event(updates_tx, worker_id, &created_event); + + let runner = DevinSessionRunner::new( + client.clone(), + DevinRunOptions { + stop_when_waiting_on_operator: true, + max_duration: None, + collect_attachments: true, + message_page_size: 100, + }, + ); + let report = runner + .follow(&created.session_id, |event| { + collector.record(&event); + publish_devin_event(updates_tx, worker_id, &event); + }) + .await; + let report = match report { + Ok(report) => { + guard.disarm(); + report + } + Err(error) => { + // Polling gave up (timeout, transport failure) while the remote + // session is still live, so it has to be stopped explicitly rather + // than left burning ACUs in Devin's cloud. + let stop = client.delete_session(&created.session_id, true).await; + guard.disarm(); + let stop_detail = match stop { + Ok(()) => "remote session terminated".to_owned(), + Err(stop_error) => { + tracing::warn!( + session_id = %created.session_id, + error = %stop_error, + "failed to stop devin session after polling failure" + ); + format!("remote session termination failed: {stop_error}") + } + }; + return Err(format!( + "devin session polling failed: {error} ({stop_detail})" + )); + } + }; + + let manifest = collector + .persist(&client, &report) + .await + .map_err(|error| format!("devin evidence import failed: {error}"))?; + let _ = updates_tx.send(WorkerUpdate::RuntimeEvent { + worker_id: worker_id.clone(), + observed_at: now_timestamp(), + event_id: Some(format!("{}::evidence", report.session_id)), + event_kind: Some("devin.evidence_imported".to_owned()), + summary: Some(format!( + "Imported Devin evidence for {} ({} events, {} attachments)", + report.session_id, + manifest.event_count, + manifest.attachments.len() + )), + payload: serde_json::to_value(&manifest).ok(), }); - format!( - "{} endpoint `{}`, credential env `{}`; the local issue workspace stays evidence-only because Devin owns the remote execution workspace", - adapter.unavailability_reason(), - api.base_url, - api.api_key_env + + Ok(devin_settlement(&report)) +} + +/// Maps a settled Devin session onto a scheduler outcome. +fn devin_settlement(report: &DevinRunReport) -> DevinSettlement { + let pull_requests = report + .pull_requests + .iter() + .map(|pull_request| pull_request.pr_url.as_str()) + .collect::>() + .join(", "); + let summary = format!( + "Devin session {} {} ({:.2} ACUs){}", + report.session_id, + report.outcome.as_str(), + report.acus_consumed, + if pull_requests.is_empty() { + String::new() + } else { + format!("; pull requests: {pull_requests}") + } + ); + let (outcome, error) = match report.outcome { + DevinRunOutcome::Finished => (WorkerOutcomeKind::Succeeded, None), + // Devin is waiting for a human, so the run cannot advance on its own. + DevinRunOutcome::WaitingOnOperator => ( + WorkerOutcomeKind::Stalled, + Some(format!( + "devin session {} is waiting on operator input: {}", + report.session_id, report.session_url + )), + ), + DevinRunOutcome::Suspended => ( + WorkerOutcomeKind::Stalled, + Some(format!("devin session {} was suspended", report.session_id)), + ), + DevinRunOutcome::Failed => ( + WorkerOutcomeKind::Failed, + Some(format!( + "devin session {} reported status `{}`", + report.session_id, + report.status.as_str() + )), + ), + DevinRunOutcome::Terminated => ( + WorkerOutcomeKind::Cancelled, + Some(format!( + "devin session {} was terminated", + report.session_id + )), + ), + }; + + DevinSettlement { + outcome, + summary, + error, + } +} + +fn publish_devin_event( + updates_tx: &mpsc::UnboundedSender, + worker_id: &crate::opensymphony_domain::WorkerId, + event: &crate::opensymphony_devin::NormalizedDevinEvent, +) { + let _ = updates_tx.send(WorkerUpdate::RuntimeEvent { + worker_id: worker_id.clone(), + observed_at: now_timestamp(), + event_id: event.event_id.clone(), + event_kind: Some(event.event_type.clone()), + summary: Some(devin_event_summary(event)), + payload: Some(devin_event_payload(event)), + }); +} + +/// Resolves the Devin-owned remote workspace binding for an issue. +/// +/// The local path handed to the binding is an evidence directory inside the +/// scheduler-bound issue workspace; it is never Devin's working directory. +fn devin_remote_binding( + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + run_id: &str, + workspace_manager: &WorkspaceManager, +) -> Result { + let repository_binding = run + .repository_binding + .as_ref() + .or_else(|| { + issue + .repository_binding + .as_ref() + .and_then(RepositoryBindingOutcome::resolved_binding) + }) + .ok_or_else(|| { + "devin routing requires a resolved repository binding for the issue".to_owned() + })?; + let repository = workspace_manager + .checkout_repository(repository_binding.repository_id().as_str()) + .ok_or_else(|| { + format!( + "repository `{}` has no configured checkout policy to bind a devin workspace", + repository_binding.repository_id() + ) + })?; + let evidence_path = run + .workspace_path + .join(".opensymphony") + .join(DEVIN_EVIDENCE_DIR_NAME) + .join(run_id); + + DevinRemoteWorkspaceBinding::new( + issue.identifier.to_string(), + evidence_path, + repository.remote.clone(), + Some(repository.target_branch.clone()), ) + .map_err(|error| format!("devin repository binding rejected: {error}")) +} + +/// Renders the workflow prompt plus the issue facts Devin needs. +fn devin_task_prompt( + workflow: &ResolvedWorkflow, + issue: &NormalizedIssue, + attempt: u32, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, +) -> Result { + let central_procedure = workflow + .render_prompt(issue, Some(attempt)) + .map_err(|error| format!("failed to render workflow prompt: {error}"))?; + + Ok(compose_terminal_prompt( + ¢ral_procedure, + &format!( + "Issue: {}\nTitle: {}\nAttempt: {}\nDescription:\n{}", + issue.identifier, + issue.title, + attempt, + issue + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + .unwrap_or("No tracker description provided."), + ), + "Work in your own Devin cloud workspace. The OpenSymphony workspace on \ + the orchestrator host stores evidence only and is not reachable from \ + your environment.", + None, + &format!( + "harness={} containment={}", + route.harness_kind, + crate::opensymphony_devin::DEVIN_REMOTE_CONTAINMENT + ), + )) +} + +fn devin_conversation_metadata( + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, +) -> ConversationMetadata { + ConversationMetadata { + conversation_id: ConversationId::new(session_id.to_owned()) + .expect("devin session id should not be empty"), + server_base_url: Some(base_url.to_owned()), + transport_target: Some(route.harness_kind.clone()), + http_auth_mode: Some("bearer".to_owned()), + websocket_auth_mode: None, + websocket_query_param_name: None, + fresh_conversation: true, + runtime_contract_version: Some(DEVIN_CLOUD_API_CONTRACT.to_owned()), + stream_state: RuntimeStreamState::Closed, + last_event_id: None, + last_event_kind: None, + last_event_at: None, + last_event_summary: Some(route.summary()), + recent_activity: Vec::new(), + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + runtime_seconds: 0, + next_activity_sequence: 0, + } } fn report_launch_failure( @@ -5761,6 +6170,9 @@ impl WorkerBackend for RuntimeWorkerBackend { if command.harness_kind == CODEX_APP_SERVER_KIND { return send_codex_stdio_interrupt(&self.codex_interrupts, &command).await; } + if command.harness_kind == DEVIN_CLOUD_AGENT_KIND { + return stop_devin_session(&self.devin_sessions, &command).await; + } if command.harness_kind != OPENHANDS_AGENT_SERVER_KIND { return Err(CliWorkerError::InterruptFailed(format!( "harness `{}` does not expose a scheduler-side interrupt channel", @@ -5802,6 +6214,41 @@ impl WorkerBackend for RuntimeWorkerBackend { } } +/// Stops the remote Devin session backing a conversation. +/// +/// Devin has no mid-turn interrupt; the documented control is terminating the +/// session, which is also what keeps a cancelled run from burning ACUs. +async fn stop_devin_session( + registry: &DevinSessionRegistry, + command: &crate::opensymphony_domain::HarnessInterruptCommand, +) -> Result { + let session_id = command.conversation_id.as_str(); + let client = registry + .lock() + .map_err(|_| { + CliWorkerError::InterruptFailed("Devin session registry lock poisoned".to_string()) + })? + .get(session_id) + .cloned(); + let Some(client) = client else { + return Err(CliWorkerError::InterruptFailed(format!( + "devin session `{session_id}` is not tracked by this worker backend" + ))); + }; + + client + .delete_session(session_id, true) + .await + .map_err(|error| CliWorkerError::InterruptFailed(error.to_string()))?; + Ok(WorkerInterruptAcknowledgement { + accepted: true, + detail: Some(format!( + "devin session `{session_id}` terminated and archived" + )), + timed_out: false, + }) +} + fn openhands_execution_stopped(status: &str) -> bool { matches!(status, "paused" | "idle" | "finished" | "error" | "stuck") } diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 8fb9f3fee..0aa615fb3 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -23,15 +23,27 @@ //! `owner/name` entries; branch intent still travels in the prompt because //! the API has no branch field. //! -//! The capability advertised by this adapter remains unavailable: hosted -//! security (secret injection, tenant isolation), remote artifact retrieval, -//! and live-run evidence still need hardening before scheduler routing can be -//! presented as production-ready. +//! Hosted-mode posture implemented here: +//! +//! * Transport is HTTPS-only with TLS 1.2 as the floor, redirects refused (the +//! bearer token would otherwise follow a redirect to another host), and the +//! credential read from the environment at request time only. +//! * Tenancy is bound before any session call through `GET /v3/self`: the +//! credential's organization must match the configured one, and every +//! session payload is re-checked against it so a response for another +//! organization is rejected rather than journaled. +//! * Secret injection references organization secrets by id or key; the +//! references are resolved against `GET /v3/organizations/{org_id}/secrets` +//! so a run cannot request a secret outside its tenant, and secret *values* +//! never enter OpenSymphony. +//! * Remote artifacts come back through [`DevinEvidence`]: session summary, +//! normalized event journal, pull requests, and same-origin attachment +//! downloads are written into the local evidence workspace. use std::{ collections::HashSet, fmt, - path::PathBuf, + path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -130,6 +142,29 @@ pub enum DevinClientError { session_id: String, elapsed: Duration, }, + #[error( + "devin credential belongs to organization `{credential}` but routing is configured for `{configured}`" + )] + TenantMismatch { + configured: String, + credential: String, + }, + #[error( + "devin returned a payload owned by organization `{observed}` while bound to `{expected}`" + )] + CrossTenantPayload { expected: String, observed: String }, + #[error("devin secret `{reference}` is not available to organization `{org_id}`")] + UnknownSecret { reference: String, org_id: String }, + #[error("devin attachment `{name}` is served from `{origin}`, which is not the api origin")] + ForeignAttachmentOrigin { name: String, origin: String }, + #[error("devin attachment `{name}` exceeds the {limit} byte evidence download limit")] + AttachmentTooLarge { name: String, limit: u64 }, + #[error("failed to persist devin evidence at {path}: {source}")] + Evidence { + path: PathBuf, + #[source] + source: std::io::Error, + }, } /// API token wrapper that never renders its secret value. @@ -390,14 +425,9 @@ impl DevinCloudAdapter { &self.config } - /// Feature gaps that block hosted Devin routing today. - pub fn unavailability_reason(&self) -> String { - let capability = self.capabilities(); - format!( - "harness `{}` is advertised as unavailable: {}", - capability.kind, - capability.feature_gaps.join(" ") - ) + /// Remaining feature gaps for hosted Devin routing. + pub fn feature_gaps(&self) -> Vec { + self.capabilities().feature_gaps } } @@ -407,7 +437,7 @@ impl HarnessAdapter for DevinCloudAdapter { } fn capabilities(&self) -> HarnessCapability { - HarnessCapability::devin_cloud_future() + HarnessCapability::devin_cloud_agent() } } @@ -640,6 +670,21 @@ pub struct SessionAttachment { pub content_type: Option, } +/// `SecretResponse` from the Devin v3 OpenAPI document. +/// +/// Secret *values* are never returned by the API and never enter OpenSymphony: +/// this record only carries the identifiers used to scope injection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecretResponse { + pub secret_id: String, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub access_type: Option, + #[serde(default)] + pub secret_type: Option, +} + /// `SessionTagsUpdateRequest` from the Devin v3 OpenAPI document. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionTagsUpdateRequest { @@ -749,6 +794,7 @@ pub enum DevinOperation { DeleteSession, ListSessionAttachments, UpdateSessionTags, + ListSecrets, } impl DevinOperation { @@ -763,6 +809,7 @@ impl DevinOperation { Self::DeleteSession => "delete_session", Self::ListSessionAttachments => "list_session_attachments", Self::UpdateSessionTags => "update_session_tags", + Self::ListSecrets => "list_secrets", } } } @@ -932,6 +979,33 @@ impl DevinRequestBuilder { } } + /// `GET /v3/organizations/{org_id}/secrets` + /// + /// Used to scope secret injection: a run may only reference secrets that + /// exist in its own organization. + pub fn list_secrets(&self, after: Option<&str>, first: Option) -> DevinRequest { + let mut path = format!( + "/v3/organizations/{}/secrets", + encode_path_segment(&self.org_id) + ); + let mut separator = '?'; + if let Some(after) = after { + path.push_str(&format!("{separator}after={}", encode_query_value(after))); + separator = '&'; + } + if let Some(first) = first { + let first = first.clamp(1, DEVIN_MAX_PAGE_SIZE); + path.push_str(&format!("{separator}first={first}")); + } + + DevinRequest { + operation: DevinOperation::ListSecrets, + method: DevinHttpMethod::Get, + path, + body: None, + } + } + pub fn absolute_url(&self, request: &DevinRequest) -> Result { let joined = format!("{}{}", self.base_url.trim_end_matches('/'), request.path); Url::parse(&joined).map_err(|source| DevinClientError::InvalidUrl { @@ -991,6 +1065,21 @@ fn encode_query_value(value: &str) -> String { // Client // --------------------------------------------------------------------------- +/// Verified identity of the credential driving a Devin run. +/// +/// Produced by [`DevinCloudClient::bind_tenant`], which is the only path that +/// marks a client as usable for session traffic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinTenancy { + pub org_id: String, + #[serde(default)] + pub principal_type: Option, + #[serde(default)] + pub service_user_id: Option, + #[serde(default)] + pub service_user_name: Option, +} + /// HTTPS client for the Devin cloud API. #[derive(Debug, Clone)] pub struct DevinCloudClient { @@ -999,6 +1088,7 @@ pub struct DevinCloudClient { org_id: Option, token: DevinApiToken, poll_interval: Duration, + tenancy: Option, } impl DevinCloudClient { @@ -1036,6 +1126,7 @@ impl DevinCloudClient { let http = reqwest::Client::builder() .timeout(config.request_timeout) .https_only(true) + .min_tls_version(reqwest::tls::Version::TLS_1_2) // The bearer token is attached to every request, so a redirect to // another host would hand the credential to that host. .redirect(reqwest::redirect::Policy::none()) @@ -1048,6 +1139,7 @@ impl DevinCloudClient { org_id, token: DevinApiToken::new(token.trim()), poll_interval: config.poll_interval, + tenancy: None, }) } @@ -1068,17 +1160,39 @@ impl DevinCloudClient { Ok(DevinRequestBuilder::new(self.base_url.clone(), org_id)) } + /// Verified tenancy, once [`Self::bind_tenant`] has run. + pub fn tenancy(&self) -> Option<&DevinTenancy> { + self.tenancy.as_ref() + } + + /// Binds the client to exactly one organization. + /// + /// The credential's own organization is read from `GET /v3/self`. When an + /// organization was configured, the two must agree: a credential minted for + /// another tenant is refused rather than silently driving sessions there. + /// When none was configured, the credential's organization is adopted. + pub async fn bind_tenant(mut self) -> Result { + let identity = self.identity().await?; + let tenancy = reconcile_tenant(self.org_id.as_deref(), identity)?; + self.org_id = Some(tenancy.org_id.clone()); + self.tenancy = Some(tenancy); + + Ok(self) + } + /// Resolves the organization from the credential when it was not /// configured, so operators only need to supply the service user key. - pub async fn resolve_org_id(mut self) -> Result { - if self.org_id.is_some() { - return Ok(self); - } - let identity: DevinSelfResponse = self - .send_typed(&DevinRequestBuilder::new(self.base_url.clone(), "").get_self()) - .await?; - self.org_id = Some(identity.org_id.ok_or(DevinClientError::MissingOrgId)?); - Ok(self) + pub async fn resolve_org_id(self) -> Result { + self.bind_tenant().await + } + + /// Rejects a session payload owned by a different organization. + fn ensure_tenant(&self, session: &SessionResponse) -> Result<(), DevinClientError> { + let expected = self + .org_id + .as_deref() + .ok_or(DevinClientError::MissingOrgId)?; + ensure_session_tenant(expected, session) } pub async fn identity(&self) -> Result { @@ -1137,21 +1251,32 @@ impl DevinCloudClient { &self, request: &SessionCreateRequest, ) -> Result { - self.send_typed(&self.requests()?.create_session(request)) - .await + let session: SessionResponse = self + .send_typed(&self.requests()?.create_session(request)) + .await?; + self.ensure_tenant(&session)?; + Ok(session) } pub async fn get_session(&self, devin_id: &str) -> Result { - self.send_typed(&self.requests()?.get_session(devin_id)) - .await + let session: SessionResponse = self + .send_typed(&self.requests()?.get_session(devin_id)) + .await?; + self.ensure_tenant(&session)?; + Ok(session) } pub async fn list_sessions( &self, query: &SessionsQueryParams, ) -> Result, DevinClientError> { - self.send_typed(&self.requests()?.list_sessions(query)) - .await + let page: PaginatedResponse = self + .send_typed(&self.requests()?.list_sessions(query)) + .await?; + for session in &page.items { + self.ensure_tenant(session)?; + } + Ok(page) } /// Fetches one page of session messages. `after` is the `end_cursor` of the @@ -1194,6 +1319,201 @@ impl DevinCloudClient { self.send_typed(&self.requests()?.list_attachments(devin_id)) .await } + + /// Every secret the bound organization exposes, walked to the last page. + pub async fn list_secrets(&self) -> Result, DevinClientError> { + let requests = self.requests()?; + let mut secrets = Vec::new(); + let mut after: Option = None; + loop { + let page: PaginatedResponse = self + .send_typed(&requests.list_secrets(after.as_deref(), Some(DEVIN_MAX_PAGE_SIZE))) + .await?; + let advanced = page + .end_cursor + .clone() + .filter(|cursor| Some(cursor) != after.as_ref()); + secrets.extend(page.items); + match (page.has_next_page, advanced) { + (true, Some(cursor)) => after = Some(cursor), + // A page that cannot advance its cursor would loop forever. + _ => return Ok(secrets), + } + } + } + + /// Resolves configured secret references to organization secret ids. + /// + /// A reference may be a secret id or a secret key, which keeps workflow + /// files readable. Anything the bound organization does not own is + /// rejected, so a workflow cannot inject another tenant's secret into a + /// session, and secret values never enter OpenSymphony. + pub async fn resolve_secret_ids( + &self, + references: &[String], + ) -> Result, DevinClientError> { + if references.is_empty() { + return Ok(Vec::new()); + } + let org_id = self.org_id.clone().ok_or(DevinClientError::MissingOrgId)?; + let available = self.list_secrets().await?; + resolve_secret_references(&org_id, &available, references) + } + + /// Downloads one session attachment for local evidence. + /// + /// Attachments are only fetched from the API origin the client is already + /// authenticated against: another origin would either receive the bearer + /// token or contribute unauthenticated bytes to run evidence. + pub async fn download_attachment( + &self, + attachment: &SessionAttachment, + limit: u64, + ) -> Result, DevinClientError> { + let url = attachment_download_url(&self.base_url, attachment)?; + + let response = self + .http + .get(url) + .bearer_auth(self.token.expose()) + .send() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(DevinClientError::Status { + status: status.as_u16(), + problem: describe_problem(&body), + }); + } + if response + .content_length() + .is_some_and(|length| length > limit) + { + return Err(DevinClientError::AttachmentTooLarge { + name: attachment.name.clone(), + limit, + }); + } + let bytes = response + .bytes() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + // `Content-Length` is advisory, so the decoded body is checked too. + if bytes.len() as u64 > limit { + return Err(DevinClientError::AttachmentTooLarge { + name: attachment.name.clone(), + limit, + }); + } + + Ok(bytes.to_vec()) + } +} + +/// Binds a credential identity to exactly one organization. +/// +/// A configured organization must match the credential's own organization: a +/// credential minted for another tenant is refused rather than silently driving +/// sessions there. When none was configured, the credential's organization is +/// adopted. +pub fn reconcile_tenant( + configured: Option<&str>, + identity: DevinSelfResponse, +) -> Result { + let credential_org = identity.org_id.ok_or(DevinClientError::MissingOrgId)?; + if let Some(configured) = configured + && configured != credential_org + { + return Err(DevinClientError::TenantMismatch { + configured: configured.to_owned(), + credential: credential_org, + }); + } + + Ok(DevinTenancy { + org_id: credential_org, + principal_type: identity.principal_type, + service_user_id: identity.service_user_id, + service_user_name: identity.service_user_name, + }) +} + +/// Rejects a session payload owned by a different organization. +pub fn ensure_session_tenant( + expected: &str, + session: &SessionResponse, +) -> Result<(), DevinClientError> { + if session.org_id != expected { + return Err(DevinClientError::CrossTenantPayload { + expected: expected.to_owned(), + observed: session.org_id.clone(), + }); + } + + Ok(()) +} + +/// Maps secret references onto ids owned by the bound organization. +/// +/// A reference may be a secret id or a secret key, which keeps workflow files +/// readable. Anything the organization does not own is rejected, so a workflow +/// cannot inject another tenant's secret into a session. +pub fn resolve_secret_references( + org_id: &str, + available: &[SecretResponse], + references: &[String], +) -> Result, DevinClientError> { + let mut resolved: Vec = Vec::with_capacity(references.len()); + for reference in references { + let reference = reference.trim(); + let matched = available + .iter() + .find(|secret| secret.secret_id == reference) + .or_else(|| { + available + .iter() + .find(|secret| secret.key.as_deref() == Some(reference)) + }) + .ok_or_else(|| DevinClientError::UnknownSecret { + reference: reference.to_owned(), + org_id: org_id.to_owned(), + })?; + if !resolved.contains(&matched.secret_id) { + resolved.push(matched.secret_id.clone()); + } + } + + Ok(resolved) +} + +/// Validates that an attachment lives on the authenticated API origin. +/// +/// Another origin would either receive the bearer token or contribute +/// unauthenticated bytes to run evidence. +pub fn attachment_download_url( + base_url: &str, + attachment: &SessionAttachment, +) -> Result { + let url = Url::parse(&attachment.url).map_err(|source| DevinClientError::InvalidUrl { + base_url: base_url.to_owned(), + path: attachment.attachment_id.clone(), + source, + })?; + let base = Url::parse(base_url).map_err(|source| DevinClientError::InvalidUrl { + base_url: base_url.to_owned(), + path: String::new(), + source, + })?; + if url.scheme() != base.scheme() || url.host_str() != base.host_str() { + return Err(DevinClientError::ForeignAttachmentOrigin { + name: attachment.name.clone(), + origin: url.origin().ascii_serialization(), + }); + } + + Ok(url) } /// Renders an error body as a problem description, falling back to the raw body @@ -1689,3 +2009,309 @@ impl DevinSessionRunner { }) } } + +// --------------------------------------------------------------------------- +// Remote evidence retrieval +// --------------------------------------------------------------------------- + +/// Directory, relative to the issue workspace metadata directory, that holds +/// evidence imported from Devin-owned remote workspaces. +pub const DEVIN_EVIDENCE_DIR_NAME: &str = "devin"; +/// Per-attachment ceiling for evidence downloads. +pub const DEVIN_MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; +/// Ceiling on how many attachments one session contributes to evidence. +pub const DEVIN_MAX_EVIDENCE_ATTACHMENTS: usize = 50; + +/// Bounds applied while importing remote artifacts into the local workspace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DevinEvidenceLimits { + pub max_attachment_bytes: u64, + pub max_attachments: usize, + /// Download attachment bytes, not just their metadata. + pub download_attachments: bool, +} + +impl Default for DevinEvidenceLimits { + fn default() -> Self { + Self { + max_attachment_bytes: DEVIN_MAX_ATTACHMENT_BYTES, + max_attachments: DEVIN_MAX_EVIDENCE_ATTACHMENTS, + download_attachments: true, + } + } +} + +/// One attachment that was imported into the local evidence directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinStoredAttachment { + pub attachment_id: String, + pub name: String, + pub source: String, + #[serde(default)] + pub content_type: Option, + /// Path relative to the evidence root. + pub stored_path: String, + pub bytes: u64, +} + +/// One attachment that was deliberately not imported, with the reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinSkippedAttachment { + pub attachment_id: String, + pub name: String, + pub reason: String, +} + +/// Index of everything written into the local evidence directory for a run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevinEvidenceManifest { + pub session_id: String, + pub session_url: String, + pub outcome: String, + pub status: String, + #[serde(default)] + pub status_detail: Option, + pub acus_consumed: f64, + pub pull_request_urls: Vec, + pub event_count: usize, + pub attachments: Vec, + pub skipped_attachments: Vec, + /// Absolute path of the evidence root inside the local issue workspace. + pub evidence_root: PathBuf, + pub containment: String, +} + +/// Collects normalized events during a run and imports remote artifacts into +/// the local issue workspace when the session settles. +/// +/// The evidence root is orchestrator-owned storage: Devin never executes there, +/// and nothing Devin returns is allowed to escape it (attachment names are +/// sanitized and the resolved path is re-checked against the root). +#[derive(Debug, Clone)] +pub struct DevinEvidenceCollector { + root: PathBuf, + events: Vec, + limits: DevinEvidenceLimits, +} + +impl DevinEvidenceCollector { + pub fn new(root: impl Into, limits: DevinEvidenceLimits) -> Self { + Self { + root: root.into(), + events: Vec::new(), + limits, + } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn event_count(&self) -> usize { + self.events.len() + } + + /// Records one normalized event for the journal written at settle time. + pub fn record(&mut self, event: &NormalizedDevinEvent) { + self.events.push(devin_event_payload(event)); + } + + /// Writes the session summary, event journal, and attachments, returning + /// the manifest that was also written to disk. + pub async fn persist( + &self, + client: &DevinCloudClient, + report: &DevinRunReport, + ) -> Result { + create_dir(&self.root).await?; + + let summary = json!({ + "session_id": report.session_id, + "session_url": report.session_url, + "outcome": report.outcome.as_str(), + "status": report.status.as_str(), + "status_detail": report.status_detail.as_ref().map(DevinStatusDetail::as_str), + "acus_consumed": report.acus_consumed, + "pull_requests": report.pull_requests, + "structured_output": report.structured_output, + "attachments": report.attachments, + "containment": DEVIN_REMOTE_CONTAINMENT, + }); + write_file(&self.root.join("session.json"), pretty(&summary).as_bytes()).await?; + + let mut journal = String::new(); + for event in &self.events { + journal.push_str(&serde_json::to_string(event).unwrap_or_else(|_| "{}".to_owned())); + journal.push('\n'); + } + write_file(&self.root.join("events.jsonl"), journal.as_bytes()).await?; + + let (attachments, skipped) = self.import_attachments(client, report).await?; + + let manifest = DevinEvidenceManifest { + session_id: report.session_id.clone(), + session_url: report.session_url.clone(), + outcome: report.outcome.as_str().to_owned(), + status: report.status.as_str().to_owned(), + status_detail: report + .status_detail + .as_ref() + .map(|detail| detail.as_str().to_owned()), + acus_consumed: report.acus_consumed, + pull_request_urls: report + .pull_requests + .iter() + .map(|pull_request| pull_request.pr_url.clone()) + .collect(), + event_count: self.events.len(), + attachments, + skipped_attachments: skipped, + evidence_root: self.root.clone(), + containment: DEVIN_REMOTE_CONTAINMENT.to_owned(), + }; + let rendered = serde_json::to_value(&manifest).unwrap_or_else(|_| json!({})); + write_file( + &self.root.join("evidence.json"), + pretty(&rendered).as_bytes(), + ) + .await?; + + Ok(manifest) + } + + /// Downloads attachments into `attachments/`. A single failed download is + /// recorded as skipped rather than failing the run: evidence import must + /// not turn a completed Devin session into a failed OpenSymphony run. + async fn import_attachments( + &self, + client: &DevinCloudClient, + report: &DevinRunReport, + ) -> Result<(Vec, Vec), DevinClientError> { + let mut stored = Vec::new(); + let mut skipped = Vec::new(); + if report.attachments.is_empty() { + return Ok((stored, skipped)); + } + + let directory = self.root.join("attachments"); + if self.limits.download_attachments { + create_dir(&directory).await?; + } + + for (index, attachment) in report.attachments.iter().enumerate() { + if !self.limits.download_attachments { + skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: "attachment download is disabled for this run".to_owned(), + }); + continue; + } + if index >= self.limits.max_attachments { + skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: format!( + "exceeds the {} attachment evidence limit", + self.limits.max_attachments + ), + }); + continue; + } + + let file_name = evidence_file_name(index, attachment); + let path = directory.join(&file_name); + // Sanitization already removes separators; this rejects anything a + // future change could let through. + if path.parent() != Some(directory.as_path()) { + skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: "resolved outside the evidence directory".to_owned(), + }); + continue; + } + + match client + .download_attachment(attachment, self.limits.max_attachment_bytes) + .await + { + Ok(bytes) => { + write_file(&path, &bytes).await?; + stored.push(DevinStoredAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + source: attachment.source.clone(), + content_type: attachment.content_type.clone(), + stored_path: format!("attachments/{file_name}"), + bytes: bytes.len() as u64, + }); + } + Err(error) => skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: error.to_string(), + }), + } + } + + Ok((stored, skipped)) + } +} + +/// Local file name for a remote attachment. +/// +/// Remote names are untrusted: only `[A-Za-z0-9._-]` survives, consecutive dots +/// are collapsed so no `..` segment can appear, the name is length-bounded, and +/// the ordinal prefix keeps duplicates distinct. +pub fn evidence_file_name(index: usize, attachment: &SessionAttachment) -> String { + let mut previous_dot = false; + let mut sanitized: String = attachment + .name + .chars() + .map(|character| { + let mapped = + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + character + } else { + '_' + }; + let mapped = if mapped == '.' && previous_dot { + '_' + } else { + mapped + }; + previous_dot = mapped == '.'; + mapped + }) + .collect(); + sanitized = sanitized.trim_matches('.').to_owned(); + sanitized.truncate(96); + if sanitized.is_empty() { + sanitized = "attachment".to_owned(); + } + + format!("{index:03}-{sanitized}") +} + +fn pretty(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_owned()) +} + +async fn create_dir(path: &Path) -> Result<(), DevinClientError> { + tokio::fs::create_dir_all(path) + .await + .map_err(|source| DevinClientError::Evidence { + path: path.to_path_buf(), + source, + }) +} + +async fn write_file(path: &Path, contents: &[u8]) -> Result<(), DevinClientError> { + tokio::fs::write(path, contents) + .await + .map_err(|source| DevinClientError::Evidence { + path: path.to_path_buf(), + source, + }) +} diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index ec1277d41..ffb8aa880 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -166,7 +166,7 @@ impl HarnessKind { match self { Self::OpenHandsAgentServer => HarnessCapability::openhands_agent_server(), Self::CodexAppServer => HarnessCapability::codex_app_server_local(), - Self::DevinCloudAgent => HarnessCapability::devin_cloud_future(), + Self::DevinCloudAgent => HarnessCapability::devin_cloud_agent(), Self::RustNative => HarnessCapability::rust_native_future(), } } @@ -403,19 +403,18 @@ impl HarnessCapability { } } - /// Future remote Devin cloud harness shape. + /// Remote Devin cloud harness, driven by the Devin API v3 session lifecycle. /// - /// Devin executes in its own cloud-owned workspace, so this capability stays - /// unavailable until hosted-mode security (TLS pinning, authenticated event - /// streams, secret injection, tenant isolation) and event normalization have - /// hardening evidence. - pub fn devin_cloud_future() -> Self { + /// Devin executes in its own cloud-owned workspace: OpenSymphony creates and + /// polls a remote session over HTTPS and imports evidence back into the local + /// issue workspace, which is never Devin's execution cwd. + pub fn devin_cloud_agent() -> Self { Self { kind: "devin_cloud_agent".into(), display_name: "Devin cloud agent".into(), - available: false, + available: true, adapter_contract_version: "harness-adapter-v1".into(), - runtime_contract_version: None, + runtime_contract_version: Some("devin-api-v3".into()), actions: HarnessActionCapability { start_run: true, send_user_message: true, @@ -430,7 +429,7 @@ impl HarnessCapability { event_streams: HarnessEventStreamCapability { runtime_events: true, terminal_frames: false, - replay_from_cursor: false, + replay_from_cursor: true, raw_payload_refs: true, delivery_modes: vec!["https_polling".into()], }, @@ -453,8 +452,8 @@ impl HarnessCapability { }, cancellation: HarnessCancellationCapability { cancel_run: true, - force_stop: false, - acknowledges_cancel: false, + force_stop: true, + acknowledges_cancel: true, }, pause_resume: HarnessPauseResumeCapability { pause: false, @@ -462,7 +461,7 @@ impl HarnessCapability { }, history: HarnessHistoryCapability { fetch_history: true, - reconcile_after_ready: false, + reconcile_after_ready: true, reconnect_and_replay: false, preserve_unknown_events: true, }, @@ -470,15 +469,17 @@ impl HarnessCapability { "Remote implementation agent executing in a Devin-owned cloud workspace.".into(), "OpenSymphony keeps a local issue workspace for manifests, journals, and evidence only; it is never the Devin execution cwd." .into(), + "Credentials are organization-scoped service-user tokens read from environment references; secrets are injected by Devin secret id, never by value." + .into(), ], feature_gaps: vec![ - "Remote workspace ownership is unimplemented: repository binding to a Devin-owned workspace has no verified checkout, diff, or evidence contract." + "Runtime events arrive by cursor-paginated HTTPS polling; there is no push stream, so event latency is bounded by the poll interval." .into(), - "Devin event normalization is unimplemented: recognized event mapping and unknown-event retention have no hardening evidence yet." + "No mid-run interrupt: cancellation terminates and archives the remote session instead of stopping a single turn." .into(), - "Authentication and secret handling are unimplemented: TLS posture, authenticated event streams, and API-token injection are not hardened." + "Pause/resume and approval flows are not exposed by the Devin API v3 session contract." .into(), - "Tenant isolation is unimplemented: per-org/per-issue session scoping and cross-tenant leak protections are not enforced." + "Model settings are fixed at session creation through `devin_mode`; per-run model overrides are unavailable." .into(), ], } diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 2b73b20bb..3e1d7f31c 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -796,7 +796,7 @@ fn harness_capability_roundtrips_future_adapters() { let caps = vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), - HarnessCapability::devin_cloud_future(), + HarnessCapability::devin_cloud_agent(), HarnessCapability::rust_native_future(), ]; @@ -836,35 +836,43 @@ fn harness_capability_roundtrips_future_adapters() { .any(|gap| gap.contains("Hosted Codex worker pools")) ); assert_eq!(back[2].kind, "devin_cloud_agent"); - assert!(!back[2].available); + assert!(back[2].available); + assert!(back[2].transport.remote); assert_eq!(back[3].kind, "rust_native"); assert!(back[3].pause_resume.pause); } #[test] -fn devin_cloud_future_capability_shape_is_stable() { - let future = HarnessCapability::devin_cloud_future(); +fn devin_cloud_agent_capability_shape_is_stable() { + let future = HarnessCapability::devin_cloud_agent(); let json = must_serialize(&future); let back: HarnessCapability = must_deserialize(&json); assert_eq!(back.kind, "devin_cloud_agent"); assert_eq!(back.display_name, "Devin cloud agent"); - assert!(!back.available); + assert!(back.available); assert_eq!(back.adapter_contract_version, "harness-adapter-v1"); - assert_eq!(back.runtime_contract_version, None); + assert_eq!( + back.runtime_contract_version.as_deref(), + Some("devin-api-v3") + ); assert_eq!(back.transport.protocol, "https"); assert!(back.transport.remote); assert!(!back.transport.local); assert_eq!(back.transport.modes, vec!["rest"]); + assert_eq!(back.event_streams.delivery_modes, vec!["https_polling"]); + assert!(back.event_streams.replay_from_cursor); assert!(back.actions.start_run); assert!(back.actions.cancel); assert!(!back.actions.approve); + assert!(back.cancellation.acknowledges_cancel); assert!(back.history.preserve_unknown_events); + assert_eq!(back.model_settings.credential_reference_kinds, vec!["env"]); for expected_gap in [ - "Remote workspace ownership", - "event normalization", - "Authentication and secret handling", - "Tenant isolation", + "polling", + "No mid-run interrupt", + "Pause/resume", + "per-run model overrides", ] { assert!( back.feature_gaps diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index 86049848e..0050e0c5a 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -1054,7 +1054,7 @@ fn build_capabilities() -> GatewayCapabilities { harnesses: vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), - HarnessCapability::devin_cloud_future(), + HarnessCapability::devin_cloud_agent(), HarnessCapability::rust_native_future(), ], features: vec![ diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index cc42ec621..c79e33164 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -1349,7 +1349,8 @@ async fn gateway_serves_capabilities_and_dashboard_snapshot() { .harnesses .iter() .any(|harness| harness.kind == "devin_cloud_agent" - && !harness.available + && harness.available + && harness.runtime_contract_version.as_deref() == Some("devin-api-v3") && harness.transport.protocol == "https" && harness.transport.remote && !harness.transport.local diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 644544568..13c38570c 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -5297,20 +5297,25 @@ async fn repository_binding_outcome_blocks_before_workspace_creation() { #[tokio::test] async fn unavailable_harness_rejects_dispatch_before_workspace_creation() { let tracker = FakeTracker { - active: vec![tracker_issue("lin-devin", "COE-DEVIN-1", "In Progress", 0)], + active: vec![tracker_issue( + "lin-native", + "COE-NATIVE-1", + "In Progress", + 0, + )], ..Default::default() }; let workspace = FakeWorkspace::default(); let worker = FakeWorker::default(); let mut config = scheduler_config(); - config.routing.harness = "devin_cloud_agent".into(); + config.routing.harness = "rust_native".into(); config.stall_timeout_ms = None; let mut scheduler = Scheduler::new(tracker, workspace, worker, config); let error = scheduler .tick(ts(100)) .await - .expect_err("unavailable devin routing should reject dispatch"); + .expect_err("unavailable harness routing should reject dispatch"); assert!(matches!( error, @@ -5320,6 +5325,35 @@ async fn unavailable_harness_rejects_dispatch_before_workspace_creation() { assert!(scheduler.worker().launches.is_empty()); } +#[tokio::test] +async fn devin_cloud_routing_dispatches_a_worker() { + let tracker = FakeTracker { + active: vec![tracker_issue("lin-devin", "COE-DEVIN-1", "In Progress", 0)], + ..Default::default() + }; + let mut config = scheduler_config(); + config.routing.harness = "devin_cloud_agent".into(); + config.stall_timeout_ms = None; + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + config, + ); + + scheduler.tick(ts(100)).await.expect("devin routing runs"); + + let launch = scheduler + .worker() + .launches + .first() + .expect("devin route should launch a worker"); + assert_eq!(launch.route.harness_kind, "devin_cloud_agent"); + // Devin executes remotely, but the issue workspace is still materialized: + // it is where run manifests, journals, and imported evidence live. + assert_eq!(scheduler.workspace().ensured.len(), 1); +} + #[tokio::test] async fn unlabeled_parent_remains_repository_neutral() { let mut issue = tracker_issue("lin-repo-parent", "COE-548-PARENT", "In Progress", 0); diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index ea180fe32..e59598eef 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -267,6 +267,14 @@ impl WorkspaceManager { &self.checkout_credential_envs } + /// Configured checkout policy for a canonical repository id. + /// + /// Remote harnesses that never materialize a local checkout still need the + /// configured remote and target branch to bind their own workspace. + pub fn checkout_repository(&self, repository_id: &str) -> Option<&CheckoutRepository> { + self.checkout_repositories.get(repository_id) + } + pub fn workspace_path_for(&self, issue_identifier: &str) -> Result { super::workspace_path_for_root(&self.config.root, issue_identifier) } diff --git a/docs/configuration.md b/docs/configuration.md index e7c708a1a..b2e658790 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -719,12 +719,19 @@ The harness targets the **Devin API v3** contract vendored at requests go to `/v3/organizations/{org_id}/sessions...` with a `cog_` service-user bearer token. -`devin_cloud_agent` is advertised as an unavailable capability, so these -settings describe the boundary rather than a runnable route. They are resolved -**only** when `routing.harness: devin_cloud_agent`; for OpenHands, Codex, or -`rust_native` workflows the block is ignored entirely and an inert default is -used, so a parked or half-configured `devin` block cannot fail an unrelated -workflow. +Selecting `routing.harness: devin_cloud_agent` runs the issue on Devin's hosted +infrastructure. These settings are resolved **only** when Devin is the selected +harness; for OpenHands, Codex, or `rust_native` workflows the block is ignored +entirely and an inert default is used, so a parked or half-configured `devin` +block cannot fail an unrelated workflow. + +Before the first run, export the two credentials the defaults expect (a `cog_` +service-user token and the organization it belongs to): + +```bash +export COG_SERVICE_USER_TOKEN=cog_... +export DEVIN_ORG_ID=org-... +``` ```yaml routing: @@ -787,10 +794,27 @@ configuration, manifests, and debug output. The name is validated as an environment-variable identifier and the token is read from the process environment only at request time. -Because `devin_cloud_agent` capability is `available: false`, selecting it -causes routing to reject issue dispatch before any local workspace is created: -the local issue workspace is evidence and manifest storage only and is never -Devin's execution directory. +### What a Devin run does + +1. Binds the client to one organization through `GET /v3/self`. A configured + `org_id` the credential does not own fails the run before any session is + created. +2. Resolves `secret_ids` against that organization's Devin secrets, by secret id + or key. Unknown or out-of-organization references fail the run; secret + *values* are never read by OpenSymphony — Devin injects them remotely. +3. Creates the session with the rendered issue prompt, repository, tags, and + session options, and reports the session URL as the run conversation link. +4. Polls session status and cursor-paginated messages, publishing normalized + runtime events onto the run timeline. +5. On settlement, imports evidence into the issue workspace under + `.opensymphony/devin/`: `session.json`, `events.jsonl`, + `evidence.json` (outcome, status, ACU usage, pull-request URLs, structured + output), and downloaded attachments. +6. Terminates and archives the remote session on cancellation, poll timeout, or + transport failure, so an abandoned run cannot keep burning ACUs. + +The local issue workspace is evidence and manifest storage only; it is never +Devin's execution directory, and no local checkout is prepared for the run. The client itself is verified against the live API by the opt-in suite in `tests/devin_cloud_agent_live.rs`. It needs `COG_SERVICE_USER_TOKEN` (and @@ -804,6 +828,12 @@ OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored See `docs/harness-adapter-compatibility.md` for what the last live run confirmed and what is still unverified. +Known limitations: event latency is bounded by `poll_interval_ms` (the v3 +contract has no push stream), cancellation terminates the whole session rather +than interrupting a turn, pause/resume and approvals are unavailable, the model +is fixed at session creation through `devin_mode`, and TLS certificate pinning +is not implemented. + ## Runtime Config `opensymphony init` also copies a starter `config.yaml` next to the target diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 8f1eeb290..67d96d03c 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -105,8 +105,9 @@ Known gaps: Devin is a remote, vendor-hosted implementation agent. The adapter lives in `crates/opensymphony-devin` (source-included by the root crate, not a separate -Cargo package) and advertises `devin_cloud_agent` with `available: false` over -an HTTPS transport (`remote: true`, `local: false`). +Cargo package) and advertises `devin_cloud_agent` as available over an HTTPS +transport (`remote: true`, `local: false`) with runtime contract +`devin-api-v3`. The client targets the **Devin API v3** contract, vendored from `https://docs.devin.ai/v3-openapi.yaml` into @@ -132,8 +133,56 @@ sources decode rather than fail). Devin owns its execution workspace. `DevinRemoteWorkspaceBinding` binds an issue workspace key and repository URL to that remote workspace and keeps the local OpenSymphony checkout as an evidence-only path; it is never used as Devin's -working directory. The CLI route therefore rejects `devin_cloud_agent` launches -with an explicit unavailability reason before any local workspace is prepared. +working directory. The CLI route in +`crates/opensymphony-cli/src/orchestrator_run/backends.rs` skips local checkout +preparation entirely for this harness: it binds tenancy, resolves secret +references, creates the session, streams normalized events as +`WorkerUpdate::RuntimeEvent`, imports evidence, and maps the settled session +onto a scheduler outcome. + +### Tenant isolation and secrets + +Every client binds to exactly one organization before it issues session traffic: +`bind_tenant()` calls `GET /v3/self`, rejects a configured organization that the +credential does not own (`TenantMismatch`), and adopts the credential's +organization when none is configured. Session, listing, and message payloads +carrying a different `org_id` are rejected as `CrossTenantPayload` rather than +journaled. + +Secrets are references, never values. `devin.session.secret_ids` holds Devin +secret ids or keys; they are resolved against `GET /v3/organizations/{org_id}/secrets` +for the bound organization, and anything not owned by it fails the run with +`UnknownSecret`. Only resolved ids are sent to Devin, which injects the values +into its own environment — OpenSymphony never reads them. + +### Transport hardening + +The base URL must be an absolute HTTPS URL with no embedded credentials, query, +or fragment; the client enforces HTTPS, requires TLS 1.2 or newer, disables +redirects so the bearer cannot follow a rewrite off-origin, and reads the token +from the configured environment variable without ever serializing or logging it. +Certificate pinning is **not** implemented. + +### Evidence import + +Settled runs write `session.json`, `events.jsonl`, sanitized `attachments/`, and +`evidence.json` under `/.opensymphony/devin/`. The +manifest records session id and URL, outcome, status and status detail, ACU +usage, pull-request URLs, normalized event count, stored and skipped +attachments, structured output, and the `devin_owned_remote_workspace` +containment marker. Attachment downloads must match the authenticated API +origin, are bounded to 25 MiB each and 50 per run (both `Content-Length` and +decoded body are checked), and land under sanitized names that cannot escape the +evidence directory. A failed download is recorded as a skipped attachment rather +than failing an otherwise completed run. + +### Session cleanup + +A Devin session outlives the worker process, so every abandoned path stops it: +poll timeout and transport failure terminate and archive the session before the +route returns, a scheduler interrupt looks the session up in the live-session +registry and deletes it, and dropping the worker task (scheduler abort) fires +`DevinSessionGuard`, which archives the session in the background. Workflow configuration lives under the `devin.api` and `devin.session` front-matter blocks (see `docs/configuration.md`). Endpoints must be absolute @@ -161,19 +210,34 @@ send, attachment listing, and `DELETE ...?archive=true` (the session settled at tag applied). The normalized event sequence observed was `SessionCreated`, `UserMessage`, two `StatusChanged`, `DevinMessage`, `SessionBlocked`. -Live protocol compatibility is not availability evidence: the hosted-security -and tenancy gaps below still gate `available: true`. - -Known gaps (all blocking `available: true`): - -- Remote workspace ownership: repository binding, checkout verification, and - artifact/evidence retrieval from the Devin-owned workspace are unimplemented. -- Event normalization: verified against one live happy-path session only; - failure, suspension, resume, and long-running sessions are unexercised. -- Authentication and secret handling: TLS pinning, authenticated event streams, - and secret injection into remote sessions are unimplemented. -- Tenant isolation: per-tenant credential scoping and isolation guarantees are - unimplemented. +A second run on 2026-09-09 covered the hardening paths and is what moved the +capability to `available: true`: + +- `devin_live_tenant_binding_and_secret_scope`: `bind_tenant()` records a + `service_user` tenancy; a configured foreign `org-` id is rejected with + `TenantMismatch`; organization secrets list and resolve by id; an unknown + reference fails with `UnknownSecret`. No ACUs. +- `devin_live_session_lifecycle`: create, poll, normalize, message, import + evidence (6 normalized events, manifest and journal written under a temporary + issue workspace), then terminate and archive. +- `devin_live_poll_timeout_terminates_the_remote_session`: an over-budget poll + returns `PollTimeout`, the cleanup path deletes and archives the session, and + a follow-up `GET` confirms `is_archived: true` — the orphaned-session case + that costs money if it regresses. + +Remaining limitations (advertised as capability `feature_gaps`, not blockers): + +- Runtime events arrive by cursor-paginated HTTPS polling; there is no push + stream, so event latency is bounded by `poll_interval_ms`. +- There is no mid-run interrupt in the v3 contract: cancellation terminates and + archives the session rather than stopping one turn. +- Pause/resume and approval flows are not exposed by the v3 session contract. +- Model selection is fixed at session creation through `devin_mode`; per-run + model overrides are unavailable. +- TLS certificate pinning is not implemented; transport security relies on the + platform trust store with TLS 1.2+ and redirects disabled. +- Live evidence covers happy-path, waiting-on-operator, and timeout settlement. + Devin-side failure and suspension settlements are covered by fixtures only. ## Rust-Native Harness diff --git a/docs/host-client-architecture.md b/docs/host-client-architecture.md index 107b5819d..5d01900a3 100644 --- a/docs/host-client-architecture.md +++ b/docs/host-client-architecture.md @@ -796,9 +796,10 @@ DevinCloudAdapter ├─ transport: https rest, remote only ├─ auth: api key read from a configured environment variable name ├─ workspace: Devin-owned remote workspace, local checkout is evidence only -├─ entities: session, run, message, tool call, event -├─ streams: polled event pages with cursor -├─ actions: create session, resume session, send message, start run, cancel run +├─ tenancy: one organization per client, verified by GET /v3/self +├─ entities: session, message, attachment, secret reference, event +├─ streams: cursor-paginated message pages plus session status polling +├─ actions: create session, send message, terminate/archive, list attachments └─ normalization: Devin event to OpenSymphony event envelope, unknown events retained as raw JSON ``` @@ -807,10 +808,18 @@ Unlike the local harnesses, OpenSymphony never owns Devin's execution directory. `DevinRemoteWorkspaceBinding` maps an issue workspace key and repository URL onto the remote workspace while the local issue workspace stays evidence-only, so the workspace manager's local-checkout invariants are not -applied to remote runs. The capability is advertised as unavailable: TLS -pinning, authenticated event streams, secret injection, tenant isolation, -remote workspace ownership, and event-normalization hardening evidence are all -prerequisites before `available` can become true. +applied to remote runs. The scheduler still materializes that issue workspace: +run manifests, journals, and the evidence imported from the remote session live +there, under `.opensymphony/devin/`. + +The capability is advertised as available against `devin-api-v3`. Its hosted +posture is organization-bound credentials, secret injection by Devin secret id, +HTTPS-only transport with TLS 1.2+ and redirects disabled, origin- and +size-bounded attachment import, and explicit remote-session termination on every +abandoned path. Cursor polling instead of a push stream, cancellation instead of +a mid-run interrupt, no pause/resume or approvals, fixed per-session model +selection, and the absence of certificate pinning remain advertised feature +gaps. See `docs/harness-adapter-compatibility.md` for the live evidence. ## 9. Hosted mode architecture diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index a77421c0c..fd015cd3b 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -10,12 +10,15 @@ use std::path::{Path, PathBuf}; use opensymphony::opensymphony_devin::{ DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_REMOTE_CONTAINMENT, - DEVIN_UNDISCRIMINATED_EVENT_KIND, DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, - DevinHttpMethod, DevinMessageCursor, DevinMode, DevinOperation, DevinProblemDetail, - DevinRemoteWorkspaceBinding, DevinRequest, DevinRequestBuilder, DevinSessionOptions, - DevinSessionStatus, DevinStatusDetail, NormalizedDevinEventKind, PaginatedResponse, - SessionMessage, SessionResponse, SessionsQueryParams, devin_event_summary, - normalize_devin_event, normalized_event_to_journal_record, session_create_request, + DEVIN_UNDISCRIMINATED_EVENT_KIND, DevinClientError, DevinCloudAdapter, DevinCloudClient, + DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinHttpMethod, + DevinMessageCursor, DevinMode, DevinOperation, DevinProblemDetail, DevinRemoteWorkspaceBinding, + DevinRequest, DevinRequestBuilder, DevinRunOutcome, DevinRunReport, DevinSelfResponse, + DevinSessionOptions, DevinSessionStatus, DevinStatusDetail, NormalizedDevinEventKind, + PaginatedResponse, SecretResponse, SessionAttachment, SessionMessage, SessionResponse, + SessionsQueryParams, attachment_download_url, devin_event_summary, ensure_session_tenant, + evidence_file_name, normalize_devin_event, normalized_event_to_journal_record, + reconcile_tenant, resolve_secret_references, session_create_request, session_created_event, }; use opensymphony::opensymphony_domain::HarnessAdapter; use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; @@ -102,21 +105,28 @@ fn message_page(messages: &[(&str, &str, &str)], end_cursor: Option<&str>, more: } #[test] -fn adapter_exposes_stable_unavailable_capability() { +fn adapter_exposes_stable_available_remote_capability() { let adapter = DevinCloudAdapter::default(); let capability = adapter.capabilities(); assert_eq!(adapter.harness_kind(), DEVIN_CLOUD_AGENT_KIND); assert_eq!(capability.kind, DEVIN_CLOUD_AGENT_KIND); - assert!(!capability.available); + assert!(capability.available); + assert_eq!( + capability.runtime_contract_version.as_deref(), + Some(DEVIN_CLOUD_API_CONTRACT) + ); assert_eq!(capability.transport.protocol, "https"); assert!(capability.transport.remote); assert!(!capability.transport.local); - assert_eq!(capability.feature_gaps.len(), 4); + assert!(capability.history.preserve_unknown_events); + assert!(capability.event_streams.replay_from_cursor); + assert_eq!(adapter.feature_gaps(), capability.feature_gaps); assert!( - adapter - .unavailability_reason() - .contains("advertised as unavailable") + capability + .feature_gaps + .iter() + .any(|gap| gap.contains("polling")) ); } @@ -631,3 +641,243 @@ fn undecodable_payloads_are_preserved_as_unknown() { undiscriminated ); } + +fn secret(secret_id: &str, key: Option<&str>) -> SecretResponse { + serde_json::from_value(json!({ + "secret_id": secret_id, + "key": key, + "access_type": "organization", + "secret_type": "env", + })) + .expect("secret decodes") +} + +fn attachment(name: &str, url: &str) -> SessionAttachment { + serde_json::from_value(json!({ + "attachment_id": "att-1", + "name": name, + "url": url, + "source": "devin", + "content_type": "text/plain", + })) + .expect("attachment decodes") +} + +fn client() -> DevinCloudClient { + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some(ORG_ID.to_owned()), + _ => None, + }) + .expect("client") +} + +#[test] +fn tenancy_binding_rejects_a_credential_from_another_organization() { + let identity = |org: &str| -> DevinSelfResponse { + serde_json::from_value(json!({ + "principal_type": "service_user", + "org_id": org, + "service_user_id": "su-1", + "service_user_name": "opensymphony", + })) + .expect("identity decodes") + }; + + // A configured organization the credential does not own is refused instead + // of silently driving sessions in the credential's tenant. + let mismatch = reconcile_tenant(Some(ORG_ID), identity("org-other")); + assert!(matches!( + mismatch, + Err(DevinClientError::TenantMismatch { + ref configured, + ref credential, + }) if configured == ORG_ID && credential == "org-other" + )); + + // Without a configured organization the credential's own tenant is adopted. + let adopted = reconcile_tenant(None, identity(ORG_ID)).expect("tenancy"); + assert_eq!(adopted.org_id, ORG_ID); + assert_eq!(adopted.service_user_name.as_deref(), Some("opensymphony")); + + // A credential without an organization cannot scope any request. + let anonymous: DevinSelfResponse = serde_json::from_value(json!({})).expect("identity"); + assert!(matches!( + reconcile_tenant(None, anonymous), + Err(DevinClientError::MissingOrgId) + )); +} + +#[test] +fn cross_tenant_session_payloads_are_rejected() { + let mut session = session("working", None); + assert!(ensure_session_tenant(ORG_ID, &session).is_ok()); + + // A session id from another tenant must not become run state, even when the + // API returns it for a request scoped to this organization. + session.org_id = "org-other".to_owned(); + assert!(matches!( + ensure_session_tenant(ORG_ID, &session), + Err(DevinClientError::CrossTenantPayload { + ref expected, + ref observed, + }) if expected == ORG_ID && observed == "org-other" + )); +} + +#[test] +fn secret_references_resolve_to_organization_owned_ids_only() { + let available = vec![ + secret("secret-1", Some("GITHUB_TOKEN")), + secret("secret-2", Some("NPM_TOKEN")), + ]; + + // Ids and keys both resolve, and repeats collapse to one injected id. + let resolved = resolve_secret_references( + ORG_ID, + &available, + &[ + "secret-1".to_owned(), + "NPM_TOKEN".to_owned(), + "GITHUB_TOKEN".to_owned(), + ], + ) + .expect("resolved"); + assert_eq!(resolved, vec!["secret-1", "secret-2"]); + + // Anything the bound organization does not own is refused, so a workflow + // cannot name another tenant's secret. + let unknown = + resolve_secret_references(ORG_ID, &available, &["secret-from-other-org".to_owned()]); + assert!(matches!( + unknown, + Err(DevinClientError::UnknownSecret { ref reference, ref org_id }) + if reference == "secret-from-other-org" && org_id == ORG_ID + )); +} + +#[test] +fn attachment_downloads_are_restricted_to_the_authenticated_api_origin() { + let base = "https://api.devin.ai"; + let allowed = attachment("log.txt", "https://api.devin.ai/v3/attachments/att-1"); + assert_eq!( + attachment_download_url(base, &allowed) + .expect("url") + .as_str(), + "https://api.devin.ai/v3/attachments/att-1" + ); + + // A bearer-authenticated download must never leave the API origin. + for foreign in [ + "https://evil.example.com/att-1", + "http://api.devin.ai/v3/attachments/att-1", + ] { + assert!(matches!( + attachment_download_url(base, &attachment("log.txt", foreign)), + Err(DevinClientError::ForeignAttachmentOrigin { .. }) + )); + } + + assert!(matches!( + attachment_download_url(base, &attachment("log.txt", "not-a-url")), + Err(DevinClientError::InvalidUrl { .. }) + )); +} + +#[test] +fn remote_attachment_names_stay_inside_the_evidence_directory() { + let traversal = attachment( + "../../etc/passwd", + "https://api.devin.ai/v3/attachments/att-1", + ); + let name = evidence_file_name(0, &traversal); + assert!(!name.contains('/')); + assert!(!name.contains("..")); + assert_eq!( + Path::new("/evidence/attachments").join(&name).parent(), + Some(Path::new("/evidence/attachments")) + ); + + let long = attachment( + &"a".repeat(400), + "https://api.devin.ai/v3/attachments/att-1", + ); + assert!(evidence_file_name(3, &long).len() <= 128); + assert!(evidence_file_name(3, &long).starts_with("003-")); +} + +#[tokio::test] +async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { + let root = tempfile::tempdir().expect("tempdir"); + let evidence_root = root.path().join(".opensymphony/devin/run-1"); + let mut collector = DevinEvidenceCollector::new( + &evidence_root, + DevinEvidenceLimits { + download_attachments: false, + ..DevinEvidenceLimits::default() + }, + ); + + let session = session("exit", Some("finished")); + collector.record(&session_created_event(&session)); + collector.record(&normalize_devin_event( + json!({ "session_id": "devin-1", "unrecognized": true }), + )); + + let report = DevinRunReport { + session_id: session.session_id.clone(), + session_url: session.url.clone(), + outcome: DevinRunOutcome::Finished, + status: session.status.clone(), + status_detail: session.status_detail.clone(), + acus_consumed: 1.5, + pull_requests: vec![ + serde_json::from_value(json!({ + "pr_url": "https://github.com/acme/api/pull/7", + "pr_state": "open", + })) + .expect("pull request"), + ], + structured_output: Some(json!({ "summary": "done" })), + attachments: vec![attachment( + "log.txt", + "https://api.devin.ai/v3/attachments/att-1", + )], + }; + + let manifest = collector + .persist(&client(), &report) + .await + .expect("persist"); + + assert_eq!(manifest.event_count, 2); + assert_eq!(manifest.containment, DEVIN_REMOTE_CONTAINMENT); + assert_eq!( + manifest.pull_request_urls, + vec!["https://github.com/acme/api/pull/7"] + ); + assert_eq!(manifest.acus_consumed, 1.5); + // Downloads were disabled, so the attachment is reported as skipped rather + // than silently dropped. + assert!(manifest.attachments.is_empty()); + assert_eq!(manifest.skipped_attachments.len(), 1); + + let journal = std::fs::read_to_string(evidence_root.join("events.jsonl")).expect("journal"); + assert_eq!(journal.lines().count(), 2); + // Unknown payloads survive the round trip into local evidence. + assert!(journal.contains("unrecognized")); + + let summary: Value = serde_json::from_str( + &std::fs::read_to_string(evidence_root.join("session.json")).expect("session file"), + ) + .expect("summary"); + assert_eq!(summary["structured_output"]["summary"], json!("done")); + + let written: Value = serde_json::from_str( + &std::fs::read_to_string(evidence_root.join("evidence.json")).expect("manifest file"), + ) + .expect("manifest"); + assert_eq!(written["session_id"], json!("devin-1")); + // Everything Devin returned stays under the orchestrator-owned root. + assert!(evidence_root.starts_with(root.path())); +} diff --git a/tests/devin_cloud_agent_live.rs b/tests/devin_cloud_agent_live.rs index abf85c8a9..d28aaa4a4 100644 --- a/tests/devin_cloud_agent_live.rs +++ b/tests/devin_cloud_agent_live.rs @@ -14,9 +14,10 @@ use std::path::PathBuf; use std::time::Duration; use opensymphony::opensymphony_devin::{ - DevinCloudClient, DevinCloudConfig, DevinRemoteWorkspaceBinding, DevinRunOptions, - DevinRunOutcome, DevinSessionOptions, DevinSessionRunner, SessionsQueryParams, - devin_event_summary, session_create_request, + DevinClientError, DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, + DevinEvidenceLimits, DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, + DevinSessionOptions, DevinSessionRunner, SessionsQueryParams, devin_event_summary, + session_create_request, }; const LIVE_ENV: &str = "OPENSYMPHONY_DEVIN_LIVE"; @@ -32,11 +33,58 @@ fn live_config() -> Option { async fn live_client(config: &DevinCloudConfig) -> DevinCloudClient { DevinCloudClient::from_environment(config, |name| std::env::var(name).ok()) .expect("credentials from the environment") - .resolve_org_id() + .bind_tenant() .await .expect("organization resolves") } +/// Verifies the tenancy binding and organization-scoped secret resolution +/// against the live API. No session is created, so this consumes no ACUs. +#[tokio::test] +#[ignore = "live Devin API call"] +async fn devin_live_tenant_binding_and_secret_scope() { + let Some(config) = live_config() else { return }; + let client = live_client(&config).await; + + let tenancy = client.tenancy().expect("bind_tenant records the tenancy"); + assert_eq!(Some(tenancy.org_id.as_str()), client.org_id()); + assert!(tenancy.org_id.starts_with("org-")); + assert_eq!(tenancy.principal_type.as_deref(), Some("service_user")); + + // A credential bound to one organization must not be usable with another. + let mismatched = DevinCloudConfig { + org_id: Some("org-00000000000000000000000000000000".to_owned()), + ..config.clone() + }; + let error = DevinCloudClient::from_environment(&mismatched, |name| std::env::var(name).ok()) + .expect("credentials from the environment") + .bind_tenant() + .await + .expect_err("a foreign organization must be rejected"); + assert!( + matches!(error, DevinClientError::TenantMismatch { .. }), + "unexpected error: {error}" + ); + + let secrets = client.list_secrets().await.expect("GET .../secrets"); + println!("organization exposes {} secret(s)", secrets.len()); + let unknown = client + .resolve_secret_ids(&["secret-does-not-exist".to_owned()]) + .await + .expect_err("unknown secret references must be rejected"); + assert!( + matches!(unknown, DevinClientError::UnknownSecret { .. }), + "unexpected error: {unknown}" + ); + if let Some(secret) = secrets.first() { + let resolved = client + .resolve_secret_ids(std::slice::from_ref(&secret.secret_id)) + .await + .expect("an organization-owned secret id resolves"); + assert_eq!(resolved, vec![secret.secret_id.clone()]); + } +} + #[tokio::test] #[ignore = "live Devin API call"] async fn devin_live_identity_and_session_listing() { @@ -139,9 +187,104 @@ async fn devin_live_session_lifecycle() { .await .expect("POST .../messages"); + // Evidence import is the artifact-retrieval path the CLI route runs: the + // manifest and journal must land inside the local issue workspace only. + let workspace = tempfile::tempdir().expect("evidence workspace"); + let evidence_root = workspace.path().join(".opensymphony/devin/live-check"); + let mut collector = + DevinEvidenceCollector::new(evidence_root.clone(), DevinEvidenceLimits::default()); + for event in &events { + collector.record(event); + } + let manifest = collector + .persist(runner.client(), &report) + .await + .expect("evidence import"); + assert_eq!(manifest.session_id, report.session_id); + assert!(manifest.event_count > 0); + assert!(evidence_root.join("evidence.json").exists()); + assert!(evidence_root.join("events.jsonl").exists()); + assert!(manifest.evidence_root.starts_with(workspace.path())); + println!( + "imported evidence: {} event(s), {} attachment(s), {} skipped", + manifest.event_count, + manifest.attachments.len(), + manifest.skipped_attachments.len() + ); + let terminated = runner .terminate(&report.session_id, true) .await .expect("DELETE .../sessions/{devin_id}?archive=true"); assert_eq!(terminated.outcome, DevinRunOutcome::Terminated); } + +/// A session that outlives the poll budget must be stopped, not orphaned. This +/// mirrors the CLI route's timeout cleanup and consumes ACUs. +#[tokio::test] +#[ignore = "live Devin API call that consumes ACUs"] +async fn devin_live_poll_timeout_terminates_the_remote_session() { + let Some(mut config) = live_config() else { + return; + }; + config.poll_interval = Duration::from_secs(3); + config.session = DevinSessionOptions { + max_acu_limit: Some(1), + title: Some("OpenSymphony harness timeout cleanup check".into()), + tags: vec!["opensymphony-live-check".into()], + resumable: false, + ..DevinSessionOptions::default() + }; + + let client = live_client(&config).await; + let runner = DevinSessionRunner::new( + client.clone(), + DevinRunOptions { + stop_when_waiting_on_operator: false, + max_duration: Some(Duration::from_secs(10)), + collect_attachments: false, + message_page_size: 100, + }, + ); + + let binding = DevinRemoteWorkspaceBinding::new( + "opensymphony-live-timeout", + PathBuf::from("/tmp/opensymphony-live-timeout"), + "https://github.com/kumanday/OpenSymphony", + Some("main".to_owned()), + ) + .expect("binding"); + let request = session_create_request( + "Wait quietly and do nothing. Do not read the repository, do not run \ + commands, and do not open a pull request.", + &binding, + &config.session, + ); + + let created = client + .create_session(&request) + .await + .expect("POST .../sessions"); + let error = runner + .follow(&created.session_id, |_| {}) + .await + .expect_err("the poll budget must expire"); + assert!( + matches!(error, DevinClientError::PollTimeout { .. }), + "unexpected error: {error}" + ); + + client + .delete_session(&created.session_id, true) + .await + .expect("timed-out sessions are terminated and archived"); + let stopped = client + .get_session(&created.session_id) + .await + .expect("GET .../sessions/{devin_id}"); + assert!( + stopped.is_archived, + "session {} stayed active after cleanup", + stopped.session_id + ); +} From 7e3d4787aefbd0afa8ecfb57d2b73929ec7cea9a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:39:07 +0000 Subject: [PATCH 05/13] fix(devin): make the devin route safe to cancel, recover, and dry-run Summary: - Settle `--dry-run` before credential resolution or session creation so a dry run never submits a task or consumes ACUs. - Persist `run.json` and the Devin conversation binding before reporting the launch, and rebuild a tenant-bound client when an interrupt finds no tracked session, so cancellation and restart recovery reach the live session. - Settle `WaitingOnOperator` as a paused `Detached` run instead of a retryable `Stalled` one, keeping the session tracked so the operator's answer continues the same run. - Keep the cleanup guard armed and settle as `CancelFailed` when terminating after a polling failure fails, instead of releasing ownership for a retry while the original session may still bill. - Prepare the issue workspace through a new evidence-only path for remote-only harness capabilities, skipping checkout clone/verify and repository hooks. - Compare complete attachment origins including effective ports, and read attachment bodies incrementally so an unbounded chunked response cannot exhaust daemon memory before the size ceiling applies. Rationale: - Every finding was a way for the route to lose ownership of a remote session that keeps executing and billing, or to start a second one. Ownership is now released only once the session is provably gone. - Devin clones the repository itself, so requiring a local checkout on the orchestrator host blocked launches the remote agent could serve. Tests: - cargo fmt --all; cargo check --all-targets; cargo clippy --all-targets -- -D warnings - cargo test --lib (1241 pass; the pre-existing configured_memory_checkout_must_match_canonical_remote failure is environmental: the sandbox rewrites git remotes to a proxy URL) - cargo test --test devin_cloud_agent --test scheduler --test gateway --test gateway_schema Co-Authored-By: Leonardo Gonzalez --- .../src/orchestrator_run/backends.rs | 501 ++++++++++++++++-- crates/opensymphony-devin/src/lib.rs | 94 +++- .../src/scheduler.rs | 55 +- .../tests/scheduler.rs | 19 +- crates/opensymphony-workspace/src/manager.rs | 15 + docs/harness-adapter-compatibility.md | 26 +- tests/devin_cloud_agent.rs | 13 +- 7 files changed, 659 insertions(+), 64 deletions(-) diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 7b654b4a3..4fbef633b 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -1236,6 +1236,30 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { }) } + async fn ensure_evidence_workspace( + &mut self, + issue: &NormalizedIssue, + _observed_at: TimestampMs, + ) -> Result { + let ensured = self + .manager + .ensure_evidence_only(&issue_descriptor(issue)) + .await?; + self.terminal_cleanup_paths + .remove(ensured.handle.workspace_path()); + Ok(crate::opensymphony_domain::WorkspaceRecord { + path: ensured.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_string())?, + created_now: ensured.created, + created_at: Some(datetime_to_timestamp_ms(ensured.issue_manifest.created_at)), + updated_at: Some(datetime_to_timestamp_ms(ensured.issue_manifest.updated_at)), + last_seen_tracker_refresh_at: ensured + .issue_manifest + .last_seen_tracker_refresh_at + .map(datetime_to_timestamp_ms), + }) + } + async fn recover_workspaces(&mut self) -> Result, Self::Error> { let mut recoveries = Vec::new(); for (handle, manifest) in self.manager.list_all_workspaces().await? { @@ -2211,8 +2235,86 @@ impl RuntimeWorkerBackend { // Devin executes in a Devin-owned cloud workspace, so this route // never prepares a local execution cwd: `ensure_with_run_id` and // checkout verification below are for local harnesses only. The - // scheduler-bound workspace is used purely to store evidence - // imported back from the remote session. + // scheduler-bound workspace is used purely to store manifests, + // journals, and evidence imported from the remote session. + let ensured = match workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + { + Ok(ensured) => ensured, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to ensure devin evidence workspace: {error}"), + ); + return; + } + }; + if let Err(detail) = + assert_scheduler_bound_workspace(&run, ensured.handle.workspace_path()).await + { + report_launch_failure(&mut launch_tx, detail); + return; + } + let attempt = run.attempt.map(|attempt| attempt.get()).unwrap_or(1); + // The run manifest has to exist before the scheduler is told a + // worker launched: cancellation persists interrupt intent into + // it, and restart recovery reads it to find the live session. + let mut run_manifest = match workspace_manager + .start_run( + &ensured.handle, + &RunDescriptor::new(&run_id, attempt) + .with_normal_retry_count(run.normal_retry_count) + .with_repository_binding(run.repository_binding.clone()), + ) + .await + { + Ok(run_manifest) => run_manifest, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to prepare devin workspace run: {error}"), + ); + return; + } + }; + // A routing preview must never reach Devin: a created session + // executes and bills immediately, with no dry-run mode of its + // own. + if route.dry_run { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation(Box::new( + dry_run_conversation_metadata(&run, &route), + ))); + } + let finish_error = finish_route_dry_run_workspace_run( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + &route, + ) + .await + .err(); + let outcome = WorkerOutcomeRecord::from_run( + &run, + if finish_error.is_some() { + WorkerOutcomeKind::Failed + } else { + WorkerOutcomeKind::Succeeded + }, + now_timestamp(), + Some(match &finish_error { + Some(_) => "routing dry-run workspace finalization failed".into(), + None => route.summary(), + }), + finish_error.map(|error| error.to_string()), + ); + let _ = updates_tx.send(WorkerUpdate::Finished { + worker_id: finished_worker_id.clone(), + outcome, + }); + return; + } let result = run_devin_cloud_route(DevinRouteContext { workflow: &workflow, issue: &issue, @@ -2220,31 +2322,61 @@ impl RuntimeWorkerBackend { route: &route, run_id: &run_id, workspace_manager: &workspace_manager, + workspace: &ensured.handle, + run_manifest: &mut run_manifest, updates_tx: &updates_tx, devin_sessions: &devin_sessions, worker_id: &finished_worker_id, launch_tx: &mut launch_tx, }) .await; - let outcome = match result { - Ok(settlement) => WorkerOutcomeRecord::from_run( - &run, - settlement.outcome, - now_timestamp(), - Some(settlement.summary), - settlement.error, - ), + let (outcome, run_status) = match result { + Ok(settlement) => { + let run_status = settlement.run_status; + ( + WorkerOutcomeRecord::from_run( + &run, + settlement.outcome, + now_timestamp(), + Some(settlement.summary), + settlement.error, + ), + run_status, + ) + } Err(detail) => { report_launch_failure(&mut launch_tx, detail.clone()); - WorkerOutcomeRecord::from_run( - &run, - WorkerOutcomeKind::Failed, - now_timestamp(), - Some("devin cloud run failed".to_owned()), - Some(detail), + ( + WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("devin cloud run failed".to_owned()), + Some(detail), + ), + RunStatus::Failed, ) } }; + let outcome = match finish_devin_workspace_run( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + run_status, + ) + .await + { + Ok(()) => outcome, + Err(error) => WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("devin workspace finalization failed".to_owned()), + Some(format!( + "failed to finish devin workspace run as {run_status}: {error}" + )), + ), + }; let _ = updates_tx.send(WorkerUpdate::Finished { worker_id: finished_worker_id.clone(), outcome, @@ -5589,6 +5721,8 @@ struct DevinRouteContext<'context> { route: &'context crate::opensymphony_orchestrator::HarnessRouteDecision, run_id: &'context str, workspace_manager: &'context WorkspaceManager, + workspace: &'context WorkspaceHandle, + run_manifest: &'context mut RunManifest, updates_tx: &'context mpsc::UnboundedSender, devin_sessions: &'context DevinSessionRegistry, worker_id: &'context crate::opensymphony_domain::WorkerId, @@ -5600,6 +5734,120 @@ struct DevinSettlement { outcome: WorkerOutcomeKind, summary: String, error: Option, + run_status: RunStatus, +} + +/// Confirms the worker's workspace is the one the scheduler bound to this run. +async fn assert_scheduler_bound_workspace( + run: &crate::opensymphony_domain::RunAttempt, + ensured_path: &Path, +) -> Result<(), String> { + let scheduler_workspace_path = fs::canonicalize(&run.workspace_path) + .await + .map_err(|error| format!("failed to resolve scheduler-bound workspace: {error}"))?; + let ensured_workspace_path = fs::canonicalize(ensured_path) + .await + .map_err(|error| format!("failed to resolve ensured workspace: {error}"))?; + if ensured_workspace_path != scheduler_workspace_path { + return Err(format!( + "workspace generation changed during worker launch: scheduler bound {}, ensured {}", + run.workspace_path.display(), + ensured_path.display() + )); + } + + Ok(()) +} + +async fn finish_devin_workspace_run( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + run_manifest: &mut RunManifest, + status: RunStatus, +) -> Result<(), WorkspaceError> { + run_manifest.status = status; + run_manifest.status_detail = Some(format!("Devin cloud route ended with {status}")); + workspace_manager + .finish_run(workspace, run_manifest, status) + .await +} + +/// Persists the remote session binding so cancellation and restart recovery can +/// find the live Devin session without the worker task. +async fn write_devin_conversation_manifest( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + issue: &NormalizedIssue, + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + run_manifest: &mut RunManifest, +) -> Result<(), String> { + let now = chrono::Utc::now(); + let conversation_id = ConversationId::new(session_id.to_owned()) + .map_err(|error| format!("invalid devin session id for conversation manifest: {error}"))?; + let manifest = IssueConversationManifest { + issue_id: issue.id.clone(), + identifier: issue.identifier.clone(), + conversation_id, + reuse_policy: "per_run".to_string(), + server_base_url: Some(base_url.to_owned()), + transport_target: Some(DEVIN_CLOUD_AGENT_KIND.to_string()), + http_auth_mode: Some("bearer".to_string()), + websocket_auth_mode: None, + websocket_query_param_name: None, + persistence_dir: workspace.metadata_dir(), + created_at: now, + updated_at: now, + last_attached_at: now, + launch_profile: None, + llm_config_fingerprint: None, + fresh_conversation: true, + workflow_prompt_seeded: true, + reset_reason: None, + runtime_contract_version: Some(DEVIN_CLOUD_API_CONTRACT.to_string()), + runtime_envelope: None, + codex_archive_state: None, + last_turn_id: None, + active_run_id: Some(run_manifest.run_id.clone()), + prepared_run_id: None, + trigger_pending_run_id: None, + last_prompt_kind: None, + last_prompt_at: Some(now), + last_prompt_path: None, + last_execution_status: None, + last_event_id: None, + last_event_kind: Some("devin.session_created".into()), + last_event_at: Some(now), + last_event_summary: Some(route.summary()), + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + last_token_accumulation_at: None, + }; + workspace_manager + .write_json_artifact_atomically( + workspace, + &pending_conversation_manifest_path(workspace), + &Some(&manifest), + ) + .await + .map_err(|error| format!("failed to persist devin session binding: {error}"))?; + workspace_manager + .write_json_artifact( + workspace, + &workspace.conversation_manifest_path(), + &manifest, + ) + .await + .map_err(|error| format!("failed to persist devin session binding: {error}"))?; + run_manifest.status = RunStatus::Running; + run_manifest.status_detail = Some(format!("devin session {session_id} is running")); + workspace_manager + .write_run_manifest(workspace, run_manifest) + .await + .map_err(|error| format!("failed to persist devin run state: {error}"))?; + Ok(()) } /// Stops the remote session if the worker task is dropped mid-run. @@ -5664,6 +5912,8 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result) -> Result "remote session terminated".to_owned(), + match client.delete_session(&created.session_id, true).await { + Ok(()) => { + guard.disarm(); + return Err(format!( + "devin session polling failed: {error} (remote session terminated)" + )); + } Err(stop_error) => { + // Ownership stays with this run: the session may still be + // executing and billing, so the guard keeps retrying + // termination on drop and the outcome must not authorize a + // replacement session. tracing::warn!( session_id = %created.session_id, error = %stop_error, "failed to stop devin session after polling failure" ); - format!("remote session termination failed: {stop_error}") + return Ok(DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!( + "Devin session {} could not be terminated after a polling failure", + created.session_id + ), + error: Some(format!( + "devin session polling failed: {error} (remote session termination failed: {stop_error}; session {} may still be running at {})", + created.session_id, created.url + )), + run_status: RunStatus::Failed, + }); } - }; - return Err(format!( - "devin session polling failed: {error} ({stop_detail})" - )); + } } }; @@ -5777,6 +6056,25 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result DevinSettlement { format!("; pull requests: {pull_requests}") } ); - let (outcome, error) = match report.outcome { - DevinRunOutcome::Finished => (WorkerOutcomeKind::Succeeded, None), - // Devin is waiting for a human, so the run cannot advance on its own. + let (outcome, run_status, error) = match report.outcome { + DevinRunOutcome::Finished => (WorkerOutcomeKind::Succeeded, RunStatus::Succeeded, None), + // Devin is waiting for a human and the session stays alive, so the run + // detaches instead of retrying: a retry would start a second session + // while the operator's answer still belongs to the first one, which + // remains reachable through the persisted conversation manifest. DevinRunOutcome::WaitingOnOperator => ( - WorkerOutcomeKind::Stalled, + WorkerOutcomeKind::Detached, + RunStatus::Paused, Some(format!( - "devin session {} is waiting on operator input: {}", + "devin session {} is waiting on operator input and stays active: {}", report.session_id, report.session_url )), ), + // Reached only after the route terminated the suspended session. DevinRunOutcome::Suspended => ( WorkerOutcomeKind::Stalled, - Some(format!("devin session {} was suspended", report.session_id)), + RunStatus::Failed, + Some(format!( + "devin session {} was suspended and terminated", + report.session_id + )), ), DevinRunOutcome::Failed => ( WorkerOutcomeKind::Failed, + RunStatus::Failed, Some(format!( "devin session {} reported status `{}`", report.session_id, @@ -5823,6 +6131,7 @@ fn devin_settlement(report: &DevinRunReport) -> DevinSettlement { ), DevinRunOutcome::Terminated => ( WorkerOutcomeKind::Cancelled, + RunStatus::Cancelled, Some(format!( "devin session {} was terminated", report.session_id @@ -5834,6 +6143,7 @@ fn devin_settlement(report: &DevinRunReport) -> DevinSettlement { outcome, summary, error, + run_status, } } @@ -6171,7 +6481,7 @@ impl WorkerBackend for RuntimeWorkerBackend { return send_codex_stdio_interrupt(&self.codex_interrupts, &command).await; } if command.harness_kind == DEVIN_CLOUD_AGENT_KIND { - return stop_devin_session(&self.devin_sessions, &command).await; + return stop_devin_session(&self.devin_sessions, &self.workflow, &command).await; } if command.harness_kind != OPENHANDS_AGENT_SERVER_KIND { return Err(CliWorkerError::InterruptFailed(format!( @@ -6220,20 +6530,38 @@ impl WorkerBackend for RuntimeWorkerBackend { /// session, which is also what keeps a cancelled run from burning ACUs. async fn stop_devin_session( registry: &DevinSessionRegistry, + workflow: &ResolvedWorkflow, command: &crate::opensymphony_domain::HarnessInterruptCommand, ) -> Result { let session_id = command.conversation_id.as_str(); - let client = registry + let tracked = registry .lock() .map_err(|_| { CliWorkerError::InterruptFailed("Devin session registry lock poisoned".to_string()) })? .get(session_id) .cloned(); - let Some(client) = client else { - return Err(CliWorkerError::InterruptFailed(format!( - "devin session `{session_id}` is not tracked by this worker backend" - ))); + // After a daemon restart the in-process registry is empty, but the session + // binding survives in the conversation manifest and the session is still + // billing. Rebuild a tenant-bound client so cancellation still reaches it. + let client = match tracked { + Some(client) => client, + None => { + let config = devin_cloud_config(workflow); + DevinCloudClient::from_environment(&config, |name| ProcessEnvironment.get(name)) + .map_err(|error| { + CliWorkerError::InterruptFailed(format!( + "devin session `{session_id}` is untracked and no client could be built: {error}" + )) + })? + .bind_tenant() + .await + .map_err(|error| { + CliWorkerError::InterruptFailed(format!( + "devin tenant binding failed while stopping `{session_id}`: {error}" + )) + })? + } }; client @@ -9268,6 +9596,93 @@ mod tests { ); } + #[tokio::test] + async fn devin_dry_run_settles_locally_without_creating_a_remote_session() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = Arc::new(sample_workflow(tempdir.path(), &workspace_root)); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut backend = RuntimeWorkerBackend::new( + OpenHandsClient::new(TransportConfig::new("http://127.0.0.1:1")), + workflow, + Arc::clone(&workspace_manager), + None, + BTreeMap::new(), + ); + let issue = sample_issue(); + let workspace = sample_workspace(&workspace_root); + let run = RunAttempt::new( + WorkerId::new("worker-devin-dry-run").expect("worker id should be valid"), + issue.id.clone(), + issue.identifier.clone(), + workspace.path.clone(), + TimestampMs::new(1), + None, + 8, + ); + + let launch = backend + .start_worker(WorkerStartRequest { + issue: issue.clone(), + workspace, + run, + route: devin_test_route(true), + memory_grant_registry_recovered: false, + }) + .await + .expect("devin dry-run worker should launch"); + assert_eq!( + launch.conversation.last_event_kind.as_deref(), + Some("routing.decision") + ); + + let mut outcome = None; + for _ in 0..40 { + for update in backend + .poll_updates() + .await + .expect("devin dry-run updates should poll") + { + if let WorkerUpdate::Finished { outcome: kind, .. } = update { + outcome = Some(kind); + } + } + if outcome.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + // A dry run never reaches credential resolution or session creation, so + // it succeeds locally even though no Devin credentials are configured. + assert!(matches!( + outcome.map(|record| record.outcome), + Some(WorkerOutcomeKind::Succeeded) + )); + + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("workspace should still be inspectable"); + let manifest = workspace_manager + .load_run_manifest(&ensured.handle) + .await + .expect("run manifest should load") + .expect("run manifest should exist"); + assert_eq!(manifest.status, RunStatus::Succeeded); + assert!( + workspace_manager + .load_conversation_manifest(&ensured.handle) + .await + .expect("conversation manifest should load") + .is_none(), + "a dry run must not bind a remote Devin session" + ); + } + #[tokio::test] async fn poll_updates_removes_issue_lookup_for_finished_task_without_update() { let tempdir = TempDir::new().expect("tempdir should exist"); @@ -10580,6 +10995,18 @@ Run the scheduler. issue } + fn devin_test_route(dry_run: bool) -> crate::opensymphony_orchestrator::HarnessRouteDecision { + crate::opensymphony_orchestrator::HarnessRouteDecision { + task_type: "issue_execution".into(), + harness_kind: DEVIN_CLOUD_AGENT_KIND.into(), + model: None, + model_profile: None, + reason: "test devin route".into(), + dry_run, + user_override: false, + } + } + fn codex_test_route(dry_run: bool) -> crate::opensymphony_orchestrator::HarnessRouteDecision { crate::opensymphony_orchestrator::HarnessRouteDecision { task_type: "issue_execution".into(), diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 0aa615fb3..4fbb91dc6 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -1360,7 +1360,7 @@ impl DevinCloudClient { resolve_secret_references(&org_id, &available, references) } - /// Downloads one session attachment for local evidence. + /// Downloads one session attachment for local evidence, bounded by `limit`. /// /// Attachments are only fetched from the API origin the client is already /// authenticated against: another origin would either receive the bearer @@ -1396,19 +1396,57 @@ impl DevinCloudClient { limit, }); } - let bytes = response - .bytes() + // `Content-Length` is advisory and absent for chunked responses, so the + // body is read incrementally and abandoned as soon as it exceeds the + // limit rather than being buffered in full first. + let mut response = response; + let mut body = BoundedBody::new(response.content_length(), limit); + while let Some(chunk) = response + .chunk() .await - .map_err(|error| DevinClientError::Transport(error.to_string()))?; - // `Content-Length` is advisory, so the decoded body is checked too. - if bytes.len() as u64 > limit { - return Err(DevinClientError::AttachmentTooLarge { - name: attachment.name.clone(), - limit, - }); + .map_err(|error| DevinClientError::Transport(error.to_string()))? + { + body.push(&chunk) + .map_err(|()| DevinClientError::AttachmentTooLarge { + name: attachment.name.clone(), + limit, + })?; } + let body = body.into_inner(); - Ok(bytes.to_vec()) + Ok(body) + } +} + +/// Accumulates a response body without ever holding more than `limit` bytes. +/// +/// A chunked attachment reports no `Content-Length`, so the ceiling can only be +/// enforced while the body streams in. +struct BoundedBody { + buffer: Vec, + limit: u64, +} + +impl BoundedBody { + fn new(content_length: Option, limit: u64) -> Self { + Self { + buffer: Vec::with_capacity( + usize::try_from(content_length.unwrap_or(0).min(limit)).unwrap_or(0), + ), + limit, + } + } + + fn push(&mut self, chunk: &[u8]) -> Result<(), ()> { + if self.buffer.len() as u64 + chunk.len() as u64 > self.limit { + return Err(()); + } + self.buffer.extend_from_slice(chunk); + Ok(()) + } + + fn into_inner(self) -> Vec { + self.buffer } } @@ -1506,7 +1544,12 @@ pub fn attachment_download_url( path: String::new(), source, })?; - if url.scheme() != base.scheme() || url.host_str() != base.host_str() { + // Ports are part of the origin: `https://api.devin.ai:8443` is a different + // service from `https://api.devin.ai`, and must never receive the token. + if url.scheme() != base.scheme() + || url.host_str() != base.host_str() + || url.port_or_known_default() != base.port_or_known_default() + { return Err(DevinClientError::ForeignAttachmentOrigin { name: attachment.name.clone(), origin: url.origin().ascii_serialization(), @@ -2315,3 +2358,30 @@ async fn write_file(path: &Path, contents: &[u8]) -> Result<(), DevinClientError source, }) } + +#[cfg(test)] +mod tests { + use super::BoundedBody; + + #[test] + fn bounded_body_stops_before_buffering_past_the_limit() { + let mut body = BoundedBody::new(None, 8); + assert!(body.push(&[0u8; 5]).is_ok()); + assert!(body.push(&[0u8; 5]).is_err()); + assert_eq!(body.into_inner().len(), 5); + } + + #[test] + fn bounded_body_preserves_a_body_within_the_limit() { + let mut body = BoundedBody::new(Some(6), 8); + assert!(body.push(b"abc").is_ok()); + assert!(body.push(b"def").is_ok()); + assert_eq!(body.into_inner(), b"abcdef".to_vec()); + } + + #[test] + fn bounded_body_never_preallocates_past_the_limit() { + let body = BoundedBody::new(Some(u64::MAX), 16); + assert!(body.into_inner().capacity() <= 16); + } +} diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 1dfccee6b..a2493ed1d 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -355,6 +355,18 @@ pub trait WorkspaceBackend { observed_at: TimestampMs, ) -> Result; + /// Prepares an issue workspace for a harness that executes remotely. + /// + /// The workspace holds manifests, journals, and imported evidence only, so + /// no local checkout is required and the host needs no repository access. + async fn ensure_evidence_workspace( + &mut self, + issue: &NormalizedIssue, + observed_at: TimestampMs, + ) -> Result { + self.ensure_workspace(issue, observed_at).await + } + async fn recover_workspaces(&mut self) -> Result, Self::Error>; async fn recover_retry_exhaustion( @@ -2022,11 +2034,15 @@ where let replacement_workspace = if retain_workspace { execution.workspace().cloned() } else if retry.is_some() && has_resolved_replacement { - match self - .workspace - .ensure_workspace(&replacement, observed_at) - .await - { + match if harness_executes_remotely(&self.config.routing.harness) { + self.workspace + .ensure_evidence_workspace(&replacement, observed_at) + .await + } else { + self.workspace + .ensure_workspace(&replacement, observed_at) + .await + } { Ok(workspace) => Some(workspace), Err(error) => { if let Some(retry) = retry.as_ref() @@ -2695,13 +2711,18 @@ where // is materialized and workspace creation hooks run. let route = decide_issue_route(&normalized, &self.config)?; - let workspace = self - .workspace - .ensure_workspace(&normalized, observed_at) - .await - .map_err(|error| SchedulerError::Workspace { - detail: error.to_string(), - })?; + let workspace = if harness_executes_remotely(&route.harness_kind) { + self.workspace + .ensure_evidence_workspace(&normalized, observed_at) + .await + } else { + self.workspace + .ensure_workspace(&normalized, observed_at) + .await + } + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })?; if let Some(normal_retry_count) = self .executions @@ -3803,6 +3824,16 @@ fn recovered_worker_ordinal(worker_id: &WorkerId) -> Option { .and_then(|value| value.parse::().ok()) } +/// Whether a harness runs the agent outside this host. +/// +/// A remote-only harness owns its execution workspace, so the scheduler +/// prepares an evidence-only local workspace instead of a checkout. +fn harness_executes_remotely(kind: &str) -> bool { + HarnessKind::parse(kind) + .map(HarnessKind::capability) + .is_some_and(|capability| capability.transport.remote && !capability.transport.local) +} + fn harness_capability(kind: &str) -> Result { HarnessKind::parse(kind) .map(HarnessKind::capability) diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 13c38570c..9a9b6a41b 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -439,6 +439,7 @@ struct FakeWorkspace { retry_pending_recoveries: Vec, cleared_retry_exhaustion: Vec, ensured: Vec, + ensured_evidence_only: Vec, cleaned: Vec<(String, bool)>, failed_cleaned: Vec, removed: Vec, @@ -484,6 +485,16 @@ impl WorkspaceBackend for FakeWorkspace { Ok(record) } + async fn ensure_evidence_workspace( + &mut self, + issue: &NormalizedIssue, + observed_at: TimestampMs, + ) -> Result { + self.ensured_evidence_only + .push(issue.identifier.to_string()); + self.ensure_workspace(issue, observed_at).await + } + async fn recover_workspaces(&mut self) -> Result, Self::Error> { Ok(self.recoveries.clone()) } @@ -5350,8 +5361,14 @@ async fn devin_cloud_routing_dispatches_a_worker() { .expect("devin route should launch a worker"); assert_eq!(launch.route.harness_kind, "devin_cloud_agent"); // Devin executes remotely, but the issue workspace is still materialized: - // it is where run manifests, journals, and imported evidence live. + // it is where run manifests, journals, and imported evidence live. It is + // prepared without a local checkout, so a host without repository access + // can still launch the remote session. assert_eq!(scheduler.workspace().ensured.len(), 1); + assert_eq!( + scheduler.workspace().ensured_evidence_only, + vec!["COE-DEVIN-1".to_string()] + ); } #[tokio::test] diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index e59598eef..109d9775d 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -312,6 +312,21 @@ impl WorkspaceManager { self.ensure(issue).await } + /// Ensures an issue workspace without preparing a local checkout. + /// + /// A remote-execution harness owns its own workspace, so the local one + /// carries manifests, journals, and imported evidence only. Cloning and + /// verifying repositories here would demand repository access on a host + /// that never runs the agent. + pub async fn ensure_evidence_only( + &self, + issue: &IssueDescriptor, + ) -> Result { + let mut evidence_issue = issue.clone(); + evidence_issue.repository_binding = None; + self.ensure_with_run_id(&evidence_issue, None).await + } + pub async fn ensure_with_run_id( &self, issue: &IssueDescriptor, diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 67d96d03c..de62ca695 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -140,6 +140,17 @@ references, creates the session, streams normalized events as `WorkerUpdate::RuntimeEvent`, imports evidence, and maps the settled session onto a scheduler outcome. +Because nothing is executed locally, the scheduler prepares the issue workspace +through `ensure_evidence_workspace` for any harness whose capability transport +is remote-only. That path materializes the contained issue directory for run +manifests, journals, and evidence without cloning or verifying a checkout and +without running repository hooks, so a host with no local repository access can +still launch a session Devin itself can clone. + +A `--dry-run` route settles before any credential resolution or session +creation, so it neither contacts the API nor consumes ACUs; it writes the local +run manifest and no conversation binding. + ### Tenant isolation and secrets Every client binds to exactly one organization before it issues session traffic: @@ -182,7 +193,20 @@ A Devin session outlives the worker process, so every abandoned path stops it: poll timeout and transport failure terminate and archive the session before the route returns, a scheduler interrupt looks the session up in the live-session registry and deletes it, and dropping the worker task (scheduler abort) fires -`DevinSessionGuard`, which archives the session in the background. +`DevinSessionGuard`, which archives the session in the background. A scheduler +interrupt that finds no tracked session — the normal state after a daemon +restart — rebuilds a tenant-bound client from configuration and stops the +session recorded in the conversation manifest. + +Ownership is only released once the remote session is provably gone. The route +writes `run.json` and the Devin conversation binding *before* it reports the +launch, so cancellation and restart recovery can always find the live session. +If termination after a polling failure fails, the route keeps the cleanup guard +armed and settles as `CancelFailed`, which does not retry, rather than as an +ordinary failure that would start a second session while the first still bills. +A session that blocks on operator input settles as `Detached` with the run +paused: the session stays alive and tracked so the human's answer continues the +same run instead of a replacement session being created. Workflow configuration lives under the `devin.api` and `devin.session` front-matter blocks (see `docs/configuration.md`). Endpoints must be absolute diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index fd015cd3b..be7c3a8bf 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -766,11 +766,22 @@ fn attachment_downloads_are_restricted_to_the_authenticated_api_origin() { .as_str(), "https://api.devin.ai/v3/attachments/att-1" ); + // An explicit default port is the same origin. + assert!( + attachment_download_url( + base, + &attachment("log.txt", "https://api.devin.ai:443/v3/attachments/att-1") + ) + .is_ok() + ); - // A bearer-authenticated download must never leave the API origin. + // A bearer-authenticated download must never leave the API origin, and the + // origin includes the effective port: `api.devin.ai:8443` is another + // service on the same host. for foreign in [ "https://evil.example.com/att-1", "http://api.devin.ai/v3/attachments/att-1", + "https://api.devin.ai:8443/v3/attachments/att-1", ] { assert!(matches!( attachment_download_url(base, &attachment("log.txt", foreign)), From edc4c16c6fae07c21bf03614ebd3218f739d4a0c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:18:01 +0000 Subject: [PATCH 06/13] fix(devin): make the remote session lifecycle restart-safe Summary: - Recovery reattaches to the Devin session recorded in the conversation manifest and follows it instead of calling create_session; a session that cannot be looked up settles as non-retrying CancelFailed. - RunManifest persists terminal_worker_outcome, RecoveryRecord carries it, and scheduler bootstrap restores the outcome (new IssueExecution::restore_worker_outcome) before any dispatch decision. - Workspace finalization and evidence import preserve Detached and CancelFailed whenever remote termination is unconfirmed, and keep the cleanup guard armed. - Terminal workspace cleanup detects a Devin conversation binding, rebuilds a tenant-bound client from workflow config, terminates and archives the session, and retains the workspace when that fails. - Evidence-only workspaces skip every repository hook: ensure, start, and finish run through a hooks-skipped path with no checkout, no after_create, and no before_run/after_run. Rationale: - Paused and Failed alone are ambiguous across a restart, so a detached or cancel-failed run was becoming a replacement session that billed ACUs while the original kept running. - Local persistence failures must not downgrade a safeguard into an ordinary retryable failure. - A detached session had no interruptible run left, so terminal cleanup was the only remaining owner able to stop it. - Remote execution must not require local repository access, which the repository's hooks (clone plus git commands) still forced. Tests: - cargo fmt --all; cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent; cargo test --lib devin - cargo test --test scheduler; --test gateway; --test gateway_schema; --test workspace_manager - cargo test --lib (only the pre-existing sandbox failure configured_memory_checkout_must_match_canonical_remote, caused by the environment rewriting git remotes to a proxy URL) Co-Authored-By: Leonardo Gonzalez --- crates/opensymphony-cli/src/memory.rs | 1 + .../src/orchestrator_run/backends.rs | 447 ++++++++++++++++-- .../opensymphony-domain/src/state_machine.rs | 6 + .../src/scheduler.rs | 37 +- .../tests/scheduler.rs | 111 +++++ crates/opensymphony-workspace/src/manager.rs | 62 ++- crates/opensymphony-workspace/src/models.rs | 7 +- .../tests/workspace_manager.rs | 50 ++ docs/harness-adapter-compatibility.md | 16 + 9 files changed, 683 insertions(+), 54 deletions(-) diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index 7a19d41af..427d6a23e 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -14110,6 +14110,7 @@ Public memory concept. .expect("runtime envelope"); let now = Utc::now(); let run = RunManifest { + terminal_worker_outcome: None, run_id: "run-551".to_string(), issue_id: "issue-551".to_string(), identifier: "COE-551".to_string(), diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 4fbef633b..399ac3e25 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -20,11 +20,11 @@ use crate::opensymphony_codex::{ turn_status, }; use crate::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_EVIDENCE_DIR_NAME, DevinCloudClient, - DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinMode, + DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_EVIDENCE_DIR_NAME, DevinClientError, + DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinMode, DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, DevinRunReport, DevinSessionOptions, DevinSessionRunner, devin_event_payload, devin_event_summary, - session_create_request, session_created_event, + session_create_request, session_created_event, status_event, }; use crate::opensymphony_domain::{ ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, IssueIdentifier, @@ -92,6 +92,8 @@ pub(super) enum CliWorkspaceError { OpenHandsLifecycle(String), #[error("conversation lifecycle recovery failed: {0}")] ConversationLifecycle(String), + #[error("Devin lifecycle recovery failed: {0}")] + DevinLifecycle(String), #[error("retry state persistence failed: {0}")] RetryState(String), } @@ -160,6 +162,9 @@ pub(super) struct RuntimeWorkspaceBackend { codex_bin: String, retain_failed: bool, retry_state_root: PathBuf, + /// Needed to reach a detached Devin session at terminal cleanup, when no + /// worker task or registry entry is left to interrupt. + devin_config: DevinCloudConfig, } pub(super) struct RuntimeWorkerBackend { @@ -976,6 +981,7 @@ impl RuntimeWorkspaceBackend { codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), retain_failed, retry_state_root, + devin_config: devin_cloud_config(workflow), } } @@ -1035,6 +1041,32 @@ impl RuntimeWorkspaceBackend { .await? { match serde_json::from_str::(&raw_manifest) { + Ok(manifest) if conversation_manifest_is_devin(&manifest) => { + // A detached Devin run has no current run to interrupt, + // so this manifest is the only remaining reference to a + // session that is still billing. Terminate it before + // the binding is deleted, and keep the workspace for a + // later retry when termination fails. + if let Err(error) = terminate_devin_session_for_cleanup( + &self.devin_config, + manifest.conversation_id.as_str(), + ) + .await + { + tracing::warn!( + issue = %handle.identifier(), + session_id = %manifest.conversation_id, + %error, + "preserving terminal Devin workspace until its remote session is terminated" + ); + return Err(CliWorkspaceError::DevinLifecycle(error)); + } + tracing::info!( + issue = %handle.identifier(), + session_id = %manifest.conversation_id, + "terminated the remote Devin session before removing its local binding" + ); + } Ok(mut manifest) if conversation_manifest_is_codex(&manifest) => { let envelope_compatible = if handle.checkout_generation().is_some() { self.manager @@ -1344,6 +1376,9 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { .as_ref() .and_then(|run| run.interrupt_reason.as_deref()) .and_then(interrupt_reason_from_manifest), + terminal_worker_outcome: run_manifest + .as_ref() + .and_then(|run| run.terminal_worker_outcome), recovered_run: had_in_flight_run.then_some(recovered_run).flatten(), }); } @@ -2220,7 +2255,7 @@ impl RuntimeWorkerBackend { let recovered = recovered && matches!( route.harness_kind.as_str(), - OPENHANDS_AGENT_SERVER_KIND | CODEX_APP_SERVER_KIND + OPENHANDS_AGENT_SERVER_KIND | CODEX_APP_SERVER_KIND | DEVIN_CLOUD_AGENT_KIND ); let pending_route = route.clone(); let codex_bin = self.codex_bin.clone(); @@ -2256,12 +2291,31 @@ impl RuntimeWorkerBackend { report_launch_failure(&mut launch_tx, detail); return; } + // A restarted worker must reattach to the session its previous + // process created; creating a second one would leave the first + // running and billing with nothing tracking it. + let reattach_session_id = if recovered { + match devin_session_binding(&workspace_manager, &ensured.handle).await { + Ok(session_id) => session_id, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to read devin session binding: {error}"), + ); + return; + } + } + } else { + None + }; let attempt = run.attempt.map(|attempt| attempt.get()).unwrap_or(1); // The run manifest has to exist before the scheduler is told a // worker launched: cancellation persists interrupt intent into // it, and restart recovery reads it to find the live session. + // Hooks are skipped because they operate on a checkout this + // route never materializes. let mut run_manifest = match workspace_manager - .start_run( + .start_evidence_run( &ensured.handle, &RunDescriptor::new(&run_id, attempt) .with_normal_retry_count(run.normal_retry_count) @@ -2287,14 +2341,15 @@ impl RuntimeWorkerBackend { dry_run_conversation_metadata(&run, &route), ))); } - let finish_error = finish_route_dry_run_workspace_run( - &workspace_manager, - &ensured.handle, - &mut run_manifest, - &route, - ) - .await - .err(); + run_manifest.status_detail = Some(route.summary()); + let finish_error = workspace_manager + .finish_evidence_run( + &ensured.handle, + &mut run_manifest, + RunStatus::Succeeded, + ) + .await + .err(); let outcome = WorkerOutcomeRecord::from_run( &run, if finish_error.is_some() { @@ -2328,6 +2383,7 @@ impl RuntimeWorkerBackend { devin_sessions: &devin_sessions, worker_id: &finished_worker_id, launch_tx: &mut launch_tx, + reattach_session_id, }) .await; let (outcome, run_status) = match result { @@ -2358,15 +2414,33 @@ impl RuntimeWorkerBackend { ) } }; + let non_retrying = non_retrying_devin_outcome(outcome.outcome); let outcome = match finish_devin_workspace_run( &workspace_manager, &ensured.handle, &mut run_manifest, run_status, + non_retrying, ) .await { Ok(()) => outcome, + // A local write failure says nothing about the remote + // session. Downgrading a non-retrying outcome here would + // authorize a replacement session while the original one + // is still alive. + Err(error) if non_retrying.is_some() => WorkerOutcomeRecord::from_run( + &run, + outcome.outcome, + now_timestamp(), + outcome.summary.clone(), + Some(match outcome.error.as_deref() { + Some(previous) => { + format!("{previous}; devin workspace finalization failed: {error}") + } + None => format!("devin workspace finalization failed: {error}"), + }), + ), Err(error) => WorkerOutcomeRecord::from_run( &run, WorkerOutcomeKind::Failed, @@ -5727,6 +5801,9 @@ struct DevinRouteContext<'context> { devin_sessions: &'context DevinSessionRegistry, worker_id: &'context crate::opensymphony_domain::WorkerId, launch_tx: &'context mut Option>, + /// Set when a restarted worker inherited a live session binding; the route + /// then follows that session instead of creating a replacement. + reattach_session_id: Option, } /// How a Devin session settled, in scheduler terms. @@ -5764,14 +5841,75 @@ async fn finish_devin_workspace_run( workspace: &WorkspaceHandle, run_manifest: &mut RunManifest, status: RunStatus, + terminal_worker_outcome: Option, ) -> Result<(), WorkspaceError> { run_manifest.status = status; run_manifest.status_detail = Some(format!("Devin cloud route ended with {status}")); + // `Paused` and `Failed` are ambiguous across a restart, so the outcome that + // forbids a replacement run is persisted alongside them. + run_manifest.terminal_worker_outcome = terminal_worker_outcome; workspace_manager - .finish_run(workspace, run_manifest, status) + .finish_evidence_run(workspace, run_manifest, status) .await } +/// The Devin outcomes that must never be retried automatically because the +/// remote session is either still alive or of unconfirmed state. +fn non_retrying_devin_outcome(outcome: WorkerOutcomeKind) -> Option { + matches!( + outcome, + WorkerOutcomeKind::Detached | WorkerOutcomeKind::CancelFailed + ) + .then_some(outcome) +} + +/// Terminates and archives a Devin session that only its durable binding still +/// points at, rebuilding a tenant-bound client because no worker task is left. +async fn terminate_devin_session_for_cleanup( + config: &DevinCloudConfig, + session_id: &str, +) -> Result<(), String> { + let client = DevinCloudClient::from_environment(config, |name| ProcessEnvironment.get(name)) + .map_err(|error| { + format!("no devin client could be built for session `{session_id}`: {error}") + })? + .bind_tenant() + .await + .map_err(|error| { + format!("devin tenant binding failed while stopping `{session_id}`: {error}") + })?; + match client.delete_session(session_id, true).await { + Ok(()) => Ok(()), + // An already-deleted session is the state cleanup wants; retaining the + // workspace forever over it would leak local state instead. + Err(DevinClientError::Status { status: 404, .. }) => Ok(()), + Err(error) => Err(format!( + "devin session `{session_id}` could not be terminated: {error}" + )), + } +} + +fn conversation_manifest_is_devin(manifest: &IssueConversationManifest) -> bool { + manifest.transport_target.as_deref() == Some(DEVIN_CLOUD_AGENT_KIND) +} + +/// Reads the durable Devin session binding for a workspace, if it has one. +async fn devin_session_binding( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, +) -> Result, WorkspaceError> { + let Some(raw) = workspace_manager + .read_text_artifact(workspace, &workspace.conversation_manifest_path()) + .await? + else { + return Ok(None); + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + return Ok(None); + }; + Ok(conversation_manifest_is_devin(&manifest).then(|| manifest.conversation_id.to_string())) +} + /// Persists the remote session binding so cancellation and restart recovery can /// find the live Devin session without the worker task. async fn write_devin_conversation_manifest( @@ -5918,6 +6056,7 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result match client.get_session(&session_id).await { + Ok(session) => (session, true), + Err(error) => { + // The bound session could not be loaded or does not belong to + // this organization. Report the failure without starting a + // replacement, and keep the outcome non-retrying. + let detail = format!( + "devin session `{session_id}` could not be reattached after restart: {error}" + ); + report_launch_failure(launch_tx, detail.clone()); + return Ok(DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!("Devin session {session_id} could not be reattached"), + error: Some(detail), + run_status: RunStatus::Failed, + }); + } + }, + None => { + let mut session_options = config.session.clone(); + if let Some(references) = session_options.secret_ids.as_ref() { + let resolved = client + .resolve_secret_ids(references) + .await + .map_err(|error| format!("devin secret resolution failed: {error}"))?; + session_options.secret_ids = Some(resolved); + } - let request = session_create_request(&task_prompt, &binding, &session_options); - let created = client - .create_session(&request) - .await - .map_err(|error| format!("devin session creation failed: {error}"))?; + let request = session_create_request(&task_prompt, &binding, &session_options); + let created = client + .create_session(&request) + .await + .map_err(|error| format!("devin session creation failed: {error}"))?; + (created, false) + } + }; let mut guard = DevinSessionGuard::new( client.clone(), created.session_id.clone(), @@ -5976,7 +6140,11 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result) -> Result manifest, + Err(error) => { + let settlement = devin_settlement(&report); + // Losing local evidence must not turn a session that is still + // alive into a retryable failure: the operator's answer belongs to + // the session the conversation manifest still points at. + if non_retrying_devin_outcome(settlement.outcome).is_some() { + return Ok(DevinSettlement { + error: Some(match settlement.error { + Some(previous) => { + format!("{previous}; devin evidence import failed: {error}") + } + None => format!("devin evidence import failed: {error}"), + }), + ..settlement + }); + } + return Err(format!("devin evidence import failed: {error}")); + } + }; + let _ = updates_tx.send(WorkerUpdate::RuntimeEvent { + worker_id: worker_id.clone(), + observed_at: now_timestamp(), + event_id: Some(format!("{}::evidence", report.session_id)), + event_kind: Some("devin.evidence_imported".to_owned()), + summary: Some(format!( + "Imported Devin evidence for {} ({} events, {} attachments)", + report.session_id, + manifest.event_count, + manifest.attachments.len() + )), + payload: serde_json::to_value(&manifest).ok(), + }); + Ok(devin_settlement(&report)) } @@ -7049,6 +7235,7 @@ mod tests { fn strict_recovery_requires_bound_run_and_conversation_envelopes() { let now = chrono::Utc::now(); let run_manifest = RunManifest { + terminal_worker_outcome: None, run_id: "run-strict-envelope".to_owned(), issue_id: "issue-contract".to_owned(), identifier: "COE-479".to_owned(), @@ -7122,6 +7309,7 @@ mod tests { })) .expect("sample runtime envelope should decode"); let run_manifest = RunManifest { + terminal_worker_outcome: None, run_id: "run-strict-pending".to_owned(), issue_id: "issue-contract".to_owned(), identifier: "COE-479".to_owned(), @@ -7203,6 +7391,7 @@ mod tests { .expect("sample runtime envelope should decode"); runtime_envelope.conversation_binding = Some("conv-unsent-prompt".to_owned()); let run_manifest = RunManifest { + terminal_worker_outcome: None, run_id: "run-unsent-prompt".to_owned(), issue_id: "issue-contract".to_owned(), identifier: "COE-479".to_owned(), @@ -8305,6 +8494,69 @@ mod tests { ); } + #[tokio::test] + async fn terminal_cleanup_retains_a_devin_workspace_until_the_session_is_stopped() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspaces"); + let mut workflow = sample_workflow(tempdir.path(), &workspace_root); + // An unset credential makes the termination attempt fail deterministically + // without reaching the network, which is the case cleanup must not treat + // as "the remote session is gone". + workflow.extensions.devin.api.api_key_env = + "OPENSYMPHONY_DEVIN_CLEANUP_PROBE_TOKEN".to_string(); + let workspace_manager = Arc::new( + WorkspaceManager::new(WorkspaceManagerConfig { + root: workspace_root, + hooks: HookConfig::default(), + cleanup: CleanupConfig { + remove_terminal_workspaces: true, + }, + }) + .expect("workspace manager should be constructed"), + ); + let issue = sample_terminal_issue(); + let ensured = workspace_manager + .ensure(&issue_descriptor(&issue)) + .await + .expect("workspace should be ensured"); + let mut manifest = sample_conversation_manifest("devin-session-detached"); + manifest.transport_target = Some(DEVIN_CLOUD_AGENT_KIND.to_string()); + workspace_manager + .write_json_artifact( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), + &manifest, + ) + .await + .expect("devin conversation manifest should persist"); + let workspace = crate::opensymphony_domain::WorkspaceRecord { + path: ensured.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_string()) + .expect("workspace key should be valid"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }; + let mut backend = RuntimeWorkspaceBackend::new(Arc::clone(&workspace_manager), &workflow); + + let error = backend + .cleanup_workspace(&workspace, true) + .await + .expect_err("cleanup must fail while the remote session is unconfirmed"); + + assert!( + matches!(error, CliWorkspaceError::DevinLifecycle(_)), + "unexpected cleanup error: {error}" + ); + assert!( + ensured.handle.conversation_manifest_path().is_file(), + "the devin binding must survive so cleanup can retry termination" + ); + assert!(ensured.handle.workspace_path().is_dir()); + assert!(!backend.terminal_cleanup_paths.contains(&workspace.path)); + } + #[tokio::test] async fn retained_terminal_cleanup_keeps_openhands_conversation_active() { let tempdir = TempDir::new().expect("tempdir should exist"); @@ -9683,6 +9935,107 @@ mod tests { ); } + #[tokio::test] + async fn devin_recovery_reads_the_persisted_session_binding() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"); + let issue = sample_issue(); + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("evidence workspace should be ensured"); + + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed"), + None + ); + + let mut manifest = sample_conversation_manifest("devin-session-recovered"); + manifest.transport_target = Some(CODEX_APP_SERVER_KIND.to_string()); + workspace_manager + .write_json_artifact( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), + &manifest, + ) + .await + .expect("conversation manifest should persist"); + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed"), + None, + "another harness's binding must never be followed as a Devin session" + ); + + manifest.transport_target = Some(DEVIN_CLOUD_AGENT_KIND.to_string()); + workspace_manager + .write_json_artifact( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), + &manifest, + ) + .await + .expect("devin conversation manifest should persist"); + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed"), + Some("devin-session-recovered".to_string()), + "recovery must reattach to the persisted session instead of creating one" + ); + } + + #[tokio::test] + async fn devin_finalization_persists_the_non_retrying_outcome() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"); + let issue = sample_issue(); + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("evidence workspace should be ensured"); + let mut run_manifest = workspace_manager + .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-detached", 1)) + .await + .expect("evidence run should start"); + + finish_devin_workspace_run( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + RunStatus::Paused, + non_retrying_devin_outcome(WorkerOutcomeKind::Detached), + ) + .await + .expect("evidence run should finish"); + + let persisted = workspace_manager + .load_run_manifest(&ensured.handle) + .await + .expect("run manifest should load") + .expect("run manifest should exist"); + assert_eq!(persisted.status, RunStatus::Paused); + assert_eq!( + persisted.terminal_worker_outcome, + Some(WorkerOutcomeKind::Detached), + "a paused status alone lets recovery dispatch a replacement session" + ); + assert_eq!( + non_retrying_devin_outcome(WorkerOutcomeKind::Failed), + None, + "ordinary failures must keep their retry behaviour" + ); + } + #[tokio::test] async fn poll_updates_removes_issue_lookup_for_finished_task_without_update() { let tempdir = TempDir::new().expect("tempdir should exist"); diff --git a/crates/opensymphony-domain/src/state_machine.rs b/crates/opensymphony-domain/src/state_machine.rs index a92f93b30..d94e8e8c8 100644 --- a/crates/opensymphony-domain/src/state_machine.rs +++ b/crates/opensymphony-domain/src/state_machine.rs @@ -688,6 +688,12 @@ impl IssueExecution { super::IssueSnapshot::from(self) } + /// Restores a worker outcome persisted by a previous process so recovery + /// applies the same non-retrying rules the in-process path applies. + pub fn restore_worker_outcome(&mut self, outcome: WorkerOutcomeRecord) { + self.record_outcome(outcome); + } + fn record_outcome(&mut self, outcome: WorkerOutcomeRecord) { if outcome.outcome == super::WorkerOutcomeKind::CancelFailed && let Some(interrupt) = &mut self.interrupt diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index a2493ed1d..6fee272ed 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -180,6 +180,9 @@ pub struct RecoveryRecord { pub retry_error: Option, pub harness_kind: Option, pub interrupt_reason: Option, + /// A persisted outcome that forbids a replacement run because the work it + /// describes is still live outside this process. + pub terminal_worker_outcome: Option, pub recovered_run: Option, } @@ -1322,7 +1325,39 @@ where recovered_workspace = None; } self.upsert_active_execution(normalized.clone(), observed_at, recovered_workspace)?; - if record.had_in_flight_run { + if let Some(terminal_outcome) = record.terminal_worker_outcome { + // `Paused` and `Failed` alone would make this dispatchable + // again. The persisted outcome says the previous run left + // something alive outside this process, so restart must + // reach the same non-retrying release the in-process path + // produces instead of launching a replacement. + tracing::info!( + issue = %issue_id, + outcome = ?terminal_outcome, + "restored a non-retrying worker outcome during recovery" + ); + let worker_id = self.next_worker_id()?; + let mut execution = self + .remove_execution(&issue_id) + .expect("active recovery execution should be present"); + execution.restore_worker_outcome(WorkerOutcomeRecord { + worker_id, + attempt: None, + outcome: terminal_outcome, + started_at: observed_at, + finished_at: observed_at, + turn_count: 0, + summary: Some( + "restored a non-retrying worker outcome from the run manifest" + .to_owned(), + ), + error: None, + }); + self.insert_execution( + issue_id.clone(), + execution.release(observed_at, ReleaseReason::TrackerInactive, None)?, + ); + } else if record.had_in_flight_run { if recovered_run.is_some() { self.restore_recovered_run( &issue_id, diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 9a9b6a41b..c7efc949b 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -1549,6 +1549,7 @@ async fn recovered_human_review_run_uses_restored_harness_kind_for_merging_inter }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-492", "COE-492", "Human Review"), workspace: recovered_workspace.clone(), successful_run: false, @@ -1640,6 +1641,7 @@ async fn recovered_retry_run_restores_retry_state_before_launch() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-494", "COE-494", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -2787,6 +2789,7 @@ async fn same_repository_recovery_supersedes_stale_binding_generations() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-same-repository-generation", "COE-548-SAME-REPOSITORY-GENERATION", @@ -3733,6 +3736,7 @@ async fn recovery_reuses_manifest_workspace_for_active_issue_dispatch() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-272", "COE-272", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -3804,6 +3808,7 @@ async fn recovery_keeps_an_active_cancelled_run_released() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-272-cancelled", "COE-272-CANCELLED", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -3844,6 +3849,88 @@ async fn recovery_keeps_an_active_cancelled_run_released() { )); } +async fn assert_recovered_non_retrying_outcome_is_not_dispatched( + identifier: &str, + outcome: WorkerOutcomeKind, + completed_run: bool, +) { + let issue_key = format!("lin-devin-{identifier}"); + let recovered_workspace = workspace_record(identifier, &format!("/tmp/recovered/{identifier}")); + let tracker = FakeTracker { + active: vec![tracker_issue(&issue_key, identifier, "In Progress", 0)], + ..Default::default() + }; + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + terminal_worker_outcome: Some(outcome), + issue: normalized_issue(&issue_key, identifier, "In Progress"), + workspace: recovered_workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some("devin_cloud_agent".to_string()), + interrupt_reason: None, + recovered_run: None, + }], + records: HashMap::from([(issue_key.clone(), recovered_workspace)]), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + + scheduler + .tick(ts(100)) + .await + .expect("recovery should succeed"); + scheduler + .tick(ts(200)) + .await + .expect("second tick should not dispatch a replacement"); + + let issue_id = IssueId::new(&issue_key).expect("issue id should be valid"); + assert!( + scheduler.worker().launches.is_empty(), + "a restored {outcome:?} run must not launch a replacement worker" + ); + assert!(matches!( + scheduler + .execution(&issue_id) + .expect("execution should remain visible") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::TrackerInactive, + .. + } + )); +} + +#[tokio::test] +async fn recovery_keeps_a_detached_run_undispatched() { + assert_recovered_non_retrying_outcome_is_not_dispatched( + "COE-DETACHED", + WorkerOutcomeKind::Detached, + false, + ) + .await; +} + +#[tokio::test] +async fn recovery_keeps_a_cancel_failed_run_undispatched() { + assert_recovered_non_retrying_outcome_is_not_dispatched( + "COE-CANCEL-FAILED", + WorkerOutcomeKind::CancelFailed, + true, + ) + .await; +} + #[tokio::test] async fn recovery_requeues_cancelled_merging_interrupt() { let recovered_workspace = workspace_record("COE-272-MERGING", "/tmp/recovered/COE-272-MERGING"); @@ -3858,6 +3945,7 @@ async fn recovery_requeues_cancelled_merging_interrupt() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-272-merging", "COE-272-MERGING", "Merging"), workspace: recovered_workspace.clone(), successful_run: false, @@ -3916,6 +4004,7 @@ async fn recovery_parks_cancelled_merging_interrupt_with_consumed_count() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-272-merging-exhausted", "COE-272-MERGING-EXHAUSTED", @@ -3963,6 +4052,7 @@ async fn pre_conversation_recovery_honors_retry_limit() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-273", "COE-273", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -4012,6 +4102,7 @@ async fn recovery_advances_consumed_retry_budget_before_dispatch() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-274", "COE-274", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -4059,6 +4150,7 @@ async fn recovery_queues_an_active_successful_run_for_continuation() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-278", "COE-278", "In Progress"), workspace: recovered_workspace.clone(), successful_run: true, @@ -4116,6 +4208,7 @@ async fn recovery_releases_an_active_successful_run_when_retry_limit_reached() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-279", "COE-279", "In Progress"), workspace: recovered_workspace.clone(), successful_run: true, @@ -4168,6 +4261,7 @@ async fn recovery_merges_successful_run_with_pending_retry_exhaustion() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: issue.clone(), workspace: recovered_workspace, successful_run: true, @@ -4224,6 +4318,7 @@ async fn recovery_keeps_successful_run_with_active_tracker_and_exhausted_marker_ }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: issue.clone(), workspace: recovered_workspace, successful_run: true, @@ -4279,6 +4374,7 @@ async fn recovery_dispatches_persisted_pending_retry_before_limit() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-276", "COE-276", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -4328,6 +4424,7 @@ async fn recovery_parks_pending_retry_when_current_limit_is_lowered() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-277", "COE-277", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -4483,6 +4580,7 @@ async fn inactive_retry_exhaustion_retries_failed_workspace_cleanup() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-278-exhausted", "COE-278-EXHAUSTED", "Backlog"), workspace: recovered_workspace, successful_run: false, @@ -4552,6 +4650,7 @@ async fn recovery_restores_exhausted_retry_count_without_dispatching() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-273", "COE-273", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -4605,6 +4704,7 @@ async fn recovery_reopens_exhausted_execution_after_retry_limit_increase() { let workspace = FakeWorkspace { retain_failed: true, recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-279", "COE-279", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -4710,6 +4810,7 @@ async fn terminal_recovery_honors_failed_workspace_retention() { let workspace = FakeWorkspace { retain_failed: true, recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-274", "COE-274", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -4750,6 +4851,7 @@ async fn terminal_recovery_preserves_cancelled_workspace_policy() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-275", "COE-275", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -4801,6 +4903,7 @@ async fn parked_recovered_issue_redispatches_when_tracker_reactivates() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-532", "COE-532", "Backlog"), workspace: recovered_workspace.clone(), successful_run: false, @@ -5001,6 +5104,7 @@ async fn recovered_in_flight_run_restores_persisted_interrupt_intent() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-493", "COE-493", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -5069,6 +5173,7 @@ async fn recovery_does_not_count_released_issues_as_running_capacity() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-283-a", "COE-283-A", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -5609,6 +5714,7 @@ async fn recovery_fences_persisted_binding_before_superseding_it() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-repo-recovery", "COE-548-RECOVERY", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -5706,6 +5812,7 @@ async fn legacy_recovery_backfills_binding_before_drift_comparison() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: recovered_issue, workspace: recovered_workspace.clone(), successful_run: false, @@ -5846,6 +5953,7 @@ async fn external_retry_marker_merges_into_metadata_only_workspace_recovery() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: pending_issue.clone(), workspace: recovered_workspace.clone(), successful_run: false, @@ -5926,6 +6034,7 @@ async fn metadata_only_retry_recovery_rematerializes_after_binding_drift() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: pending_issue.clone(), workspace: recovered_workspace.clone(), successful_run: false, @@ -6113,6 +6222,7 @@ async fn legacy_recovery_rematerializes_without_persisted_binding_proof() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-legacy-unproven", "COE-548-LEGACY-UNPROVEN", @@ -6197,6 +6307,7 @@ async fn unproven_recovery_keeps_workspace_and_worker_until_stop_acknowledges() }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-legacy-stop-pending", "COE-548-LEGACY-STOP-PENDING", diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index 109d9775d..481da0e83 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -61,6 +61,16 @@ struct HookFailure { record: HookExecutionRecord, } +/// Whether workspace lifecycle hooks run while a workspace is materialized. +/// +/// Hooks operate on a local checkout, so an evidence-only workspace for a +/// remote-execution harness skips them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkspaceHooks { + Executed, + Skipped, +} + enum ExistingIssueManifestState { Missing, Owned(IssueManifest), @@ -315,16 +325,17 @@ impl WorkspaceManager { /// Ensures an issue workspace without preparing a local checkout. /// /// A remote-execution harness owns its own workspace, so the local one - /// carries manifests, journals, and imported evidence only. Cloning and - /// verifying repositories here would demand repository access on a host - /// that never runs the agent. + /// carries manifests, journals, and imported evidence only. Cloning the + /// repository or running the workspace hooks here would demand repository + /// access and Git on a host that never runs the agent. pub async fn ensure_evidence_only( &self, issue: &IssueDescriptor, ) -> Result { let mut evidence_issue = issue.clone(); evidence_issue.repository_binding = None; - self.ensure_with_run_id(&evidence_issue, None).await + self.ensure_local_workspace(&evidence_issue, WorkspaceHooks::Skipped) + .await } pub async fn ensure_with_run_id( @@ -344,6 +355,15 @@ impl WorkspaceManager { .await; } + self.ensure_local_workspace(issue, WorkspaceHooks::Executed) + .await + } + + async fn ensure_local_workspace( + &self, + issue: &IssueDescriptor, + hooks: WorkspaceHooks, + ) -> Result { self.create_directory(&self.config.root).await?; let canonical_root = self.canonicalize_path(&self.config.root).await?; let workspace_key = sanitize_workspace_key(&issue.identifier)?; @@ -429,7 +449,7 @@ impl WorkspaceManager { existing_state, ExistingWorkspaceState::Missing | ExistingWorkspaceState::ForeignArtifact ); - let after_create = if created { + let after_create = if created && hooks == WorkspaceHooks::Executed { match self.execute_hook(HookKind::AfterCreate, &handle).await { Ok(record) => { if record.is_some() { @@ -2538,6 +2558,38 @@ impl WorkspaceManager { Ok(()) } + /// Starts a run in an evidence-only workspace. + /// + /// The configured `before_run` hook operates on a local checkout, which a + /// remote-execution harness never materializes, so it is not executed. + pub async fn start_evidence_run( + &self, + workspace: &WorkspaceHandle, + run: &RunDescriptor, + ) -> Result { + self.validate_workspace_handle(workspace).await?; + + let mut manifest = RunManifest::new(workspace, run); + manifest.status = RunStatus::Prepared; + self.write_run_manifest(workspace, &manifest).await?; + Ok(manifest) + } + + /// Finishes a run in an evidence-only workspace without the `after_run` + /// hook, which likewise assumes a local checkout. + pub async fn finish_evidence_run( + &self, + workspace: &WorkspaceHandle, + run_manifest: &mut RunManifest, + status: RunStatus, + ) -> Result<(), WorkspaceError> { + self.validate_workspace_handle(workspace).await?; + + run_manifest.status = status; + run_manifest.updated_at = Utc::now(); + self.write_run_manifest(workspace, run_manifest).await + } + pub async fn start_run( &self, workspace: &WorkspaceHandle, diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index de570a01e..00d11435e 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -8,7 +8,7 @@ use std::{ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::opensymphony_domain::{RepositoryBinding, RepositoryBindingOutcome}; +use crate::opensymphony_domain::{RepositoryBinding, RepositoryBindingOutcome, WorkerOutcomeKind}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckoutRepository { @@ -917,6 +917,10 @@ pub struct RunManifest { pub retry_error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub interrupt_reason: Option, + /// A worker outcome that must survive restart because retrying it would + /// duplicate work that is still live outside this process. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_worker_outcome: Option, pub status: RunStatus, pub created_at: DateTime, pub updated_at: DateTime, @@ -945,6 +949,7 @@ impl RunManifest { retry_reason: None, retry_error: None, interrupt_reason: None, + terminal_worker_outcome: None, status: RunStatus::Preparing, created_at: now, updated_at: now, diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index b869c63fa..d8fc68242 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -232,6 +232,56 @@ async fn ensure_creates_reuses_workspace_and_runs_after_create_once() { ); } +#[cfg(unix)] +#[tokio::test] +async fn evidence_only_workspace_runs_no_hooks() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let workspace_root = temp_dir.path().join("workspaces"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig { + after_create: Some(HookDefinition::shell("echo ran > after_create.txt")), + before_run: Some(HookDefinition::shell("echo ran > before_run.txt")), + after_run: Some(HookDefinition::shell("echo ran > after_run.txt")), + before_remove: None, + ..HookConfig::default() + }, + CleanupConfig::default(), + )) + .expect("manager should build"); + let issue = sample_issue("COE-EVIDENCE"); + + let ensured = manager + .ensure_evidence_only(&issue) + .await + .expect("evidence-only workspace should be created"); + assert!(ensured.created); + assert!(ensured.after_create.is_none()); + + let mut run_manifest = manager + .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-evidence", 1)) + .await + .expect("evidence run should start"); + assert_eq!(run_manifest.status, RunStatus::Prepared); + assert!(run_manifest.hooks.is_empty()); + + manager + .finish_evidence_run(&ensured.handle, &mut run_manifest, RunStatus::Succeeded) + .await + .expect("evidence run should finish"); + assert_eq!(run_manifest.status, RunStatus::Succeeded); + assert!(run_manifest.hooks.is_empty()); + + for marker in ["after_create.txt", "before_run.txt", "after_run.txt"] { + assert!( + !tokio::fs::try_exists(ensured.handle.workspace_path().join(marker)) + .await + .expect("hook marker lookup should succeed"), + "{marker} must not exist: evidence-only workspaces run no repository hooks" + ); + } +} + #[tokio::test] async fn checkout_timeout_does_not_override_legacy_hook_timeout() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index de62ca695..03ebab162 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -208,6 +208,22 @@ A session that blocks on operator input settles as `Detached` with the run paused: the session stays alive and tracked so the human's answer continues the same run instead of a replacement session being created. +`Detached` and `CancelFailed` also survive a restart. Because `Paused` and +`Failed` alone are ambiguous, the run manifest persists the worker outcome in +`terminal_worker_outcome`, and scheduler recovery restores it before any +dispatch decision, so neither state becomes a replacement session. Local +finalization or evidence-import failures keep that outcome and the cleanup +guard whenever remote termination is still unconfirmed. + +Recovery after a daemon restart reattaches instead of creating: the route reads +the Devin session id from the conversation manifest and follows the existing +session, and a session that cannot be looked up settles as a non-retrying +`CancelFailed` rather than launching a second one. When a detached issue later +becomes terminal, workspace cleanup inspects the conversation manifest first, +terminates and archives the remote session, and only then removes the local +binding; if termination fails the workspace is retained so a later cleanup can +retry it. + Workflow configuration lives under the `devin.api` and `devin.session` front-matter blocks (see `docs/configuration.md`). Endpoints must be absolute HTTPS URLs without embedded credentials, query, or fragment; credential values From 67605eafe87f87ff037b00d7421ec25387ba2d4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:30:29 +0000 Subject: [PATCH 07/13] fix(devin): keep detached sessions safe across launch, recovery, cleanup Summary: - Report a failed reattachment as a successful launch bound to the persisted session, then settle CancelFailed, so the scheduler never turns the lost session into a retry that dispatches a replacement. - Keep the repository binding in the evidence-only issue manifest; only the checkout is skipped, so recovery no longer compares a live binding against None and removes a repository-bound detached workspace. - Skip workspace removal during recovery whenever a non-retrying outcome is persisted, since something outside the process is still bound to the workspace and its evidence. - Add WorkspaceManager::cleanup_evidence_only and route Devin terminal cleanup through it so before_remove no longer runs repository commands in a directory that has no checkout. Rationale: - LaunchReport::Failed becomes a launch error, which is retried; the non-retrying outcome has to survive the launch protocol to have any effect. - Repository identity and checkout preparation are separate concerns: dropping the binding made the workspace look unbound to every later comparison and cost the waiting session its evidence. - All four hooks are now uniformly skipped for evidence-only workspaces. Tests: - cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent - cargo test --lib devin - cargo test --test scheduler - cargo test --test workspace_manager - cargo test --test gateway - cargo test --test gateway_schema Co-Authored-By: Leonardo Gonzalez --- .../src/orchestrator_run/backends.rs | 97 ++++++++++++++++--- .../src/scheduler.rs | 9 +- .../tests/scheduler.rs | 88 +++++++++++++++++ crates/opensymphony-workspace/src/manager.rs | 50 ++++++++-- .../tests/workspace_manager.rs | 57 ++++++++++- docs/harness-adapter-compatibility.md | 7 +- 6 files changed, 282 insertions(+), 26 deletions(-) diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 399ac3e25..237f65ef4 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -1066,6 +1066,18 @@ impl RuntimeWorkspaceBackend { session_id = %manifest.conversation_id, "terminated the remote Devin session before removing its local binding" ); + // This workspace never held a checkout, so the + // repository-specific `before_remove` hook has nothing + // to operate on here. + self.manager + .cleanup_evidence_only( + &handle, + IssueLifecycleState::Terminal, + force_remove, + ) + .await?; + self.terminal_cleanup_paths.insert(workspace.path.clone()); + return Ok(()); } Ok(mut manifest) if conversation_manifest_is_codex(&manifest) => { let envelope_compatible = if handle.checkout_generation().is_some() { @@ -6078,19 +6090,13 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result match client.get_session(&session_id).await { Ok(session) => (session, true), Err(error) => { - // The bound session could not be loaded or does not belong to - // this organization. Report the failure without starting a - // replacement, and keep the outcome non-retrying. - let detail = format!( - "devin session `{session_id}` could not be reattached after restart: {error}" - ); - report_launch_failure(launch_tx, detail.clone()); - return Ok(DevinSettlement { - outcome: WorkerOutcomeKind::CancelFailed, - summary: format!("Devin session {session_id} could not be reattached"), - error: Some(detail), - run_status: RunStatus::Failed, - }); + return Ok(devin_reattach_failure( + launch_tx, + &session_id, + &config.base_url, + route, + &error.to_string(), + )); } }, None => { @@ -6459,6 +6465,34 @@ fn devin_conversation_metadata( } } +/// Settle a session that could not be reattached after a restart. +/// +/// The launch has to be reported as successful so the worker registers and the +/// scheduler observes the non-retrying settlement. A launch failure instead +/// becomes a launch error the scheduler retries, which starts exactly the +/// replacement session this outcome exists to prevent. +fn devin_reattach_failure( + launch_tx: &mut Option>, + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + error: &str, +) -> DevinSettlement { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation(Box::new( + devin_conversation_metadata(session_id, base_url, route), + ))); + } + DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!("Devin session {session_id} could not be reattached"), + error: Some(format!( + "devin session `{session_id}` could not be reattached after restart: {error}" + )), + run_status: RunStatus::Failed, + } +} + fn report_launch_failure( launch_tx: &mut Option>, detail: impl Into, @@ -9991,6 +10025,43 @@ mod tests { ); } + #[tokio::test] + async fn devin_reattachment_failure_reports_a_launch_that_cannot_be_retried() { + let (launch_sender, launch_receiver) = oneshot::channel(); + let mut launch_tx = Some(launch_sender); + let route = devin_test_route(false); + let settlement = devin_reattach_failure( + &mut launch_tx, + "devin-session-lost", + "https://api.devin.ai", + &route, + "status 404", + ); + + assert_eq!(settlement.outcome, WorkerOutcomeKind::CancelFailed); + assert_eq!(settlement.run_status, RunStatus::Failed); + assert_eq!( + non_retrying_devin_outcome(settlement.outcome), + Some(WorkerOutcomeKind::CancelFailed) + ); + let report = launch_receiver + .await + .expect("a launch report should be sent"); + // `LaunchReport::Failed` becomes a launch error the scheduler retries, + // and the later `Finished` update is then ignored because the worker + // never registered. + match report { + LaunchReport::Conversation(conversation) => assert_eq!( + conversation.conversation_id.as_str(), + "devin-session-lost", + "the launch must register the bound session, not a replacement" + ), + LaunchReport::Failed(detail) => { + panic!("a failed reattachment must not report a retryable launch failure: {detail}") + } + } + } + #[tokio::test] async fn devin_finalization_persists_the_non_retrying_outcome() { let tempdir = TempDir::new().expect("tempdir should exist"); diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 6fee272ed..9db4d5c58 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -1315,7 +1315,14 @@ where // the live tracker binding, discard the old workspace before // restoring the retry so the replacement is materialized by // the normal dispatch path. - if recovered_run.is_none() && recovered_workspace.is_some() && binding_changed { + // A persisted non-retrying outcome means something outside this + // process is still bound to this workspace, so its manifests + // and evidence must survive even when the binding moved. + if recovered_run.is_none() + && recovered_workspace.is_some() + && binding_changed + && record.terminal_worker_outcome.is_none() + { self.workspace .remove_workspace(&record.workspace) .await diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index c7efc949b..1698e7de1 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -3931,6 +3931,94 @@ async fn recovery_keeps_a_cancel_failed_run_undispatched() { .await; } +async fn assert_repository_bound_detached_recovery_retains_the_workspace( + identifier: &str, + preserve_binding: bool, +) { + let issue_key = format!("lin-devin-{identifier}"); + let recovered_workspace = workspace_record(identifier, &format!("/tmp/recovered/{identifier}")); + let binding = match repository_routing().resolve( + &["repo:one".to_string()], + Some("project-id"), + None, + false, + ) { + RepositoryBindingOutcome::Resolved(binding) => binding, + outcome => panic!("expected a resolved binding, got {outcome:?}"), + }; + let tracker = FakeTracker { + active: vec![{ + let mut issue = tracker_issue(&issue_key, identifier, "In Progress", 0); + issue.project_id = Some("project-id".to_string()); + issue.labels = vec!["repo:one".to_string()]; + issue + }], + ..Default::default() + }; + let mut recovered_issue = normalized_issue(&issue_key, identifier, "In Progress"); + if preserve_binding { + recovered_issue.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding)); + } + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + terminal_worker_outcome: Some(WorkerOutcomeKind::Detached), + issue: recovered_issue, + workspace: recovered_workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some("devin_cloud_agent".to_string()), + interrupt_reason: None, + recovered_run: None, + }], + records: HashMap::from([(issue_key.clone(), recovered_workspace)]), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut config = scheduler_config(); + config.repository_routing = Some(repository_routing()); + let mut scheduler = Scheduler::new(tracker, workspace, worker, config); + + scheduler + .tick(ts(100)) + .await + .expect("detached recovery should succeed"); + + // Removing the workspace here would terminate the waiting remote session + // and delete the evidence it already produced. + assert!( + scheduler.workspace().removed.is_empty(), + "a detached run must keep its evidence workspace" + ); + assert!(scheduler.worker().launches.is_empty()); + assert!(matches!( + scheduler + .execution(&IssueId::new(&issue_key).expect("issue id should be valid")) + .expect("execution should remain visible") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { .. } + )); +} + +#[tokio::test] +async fn repository_bound_detached_recovery_keeps_its_evidence_workspace() { + assert_repository_bound_detached_recovery_retains_the_workspace("COE-DETACHED-BOUND", true) + .await; +} + +#[tokio::test] +async fn detached_recovery_without_a_proven_binding_keeps_its_evidence_workspace() { + assert_repository_bound_detached_recovery_retains_the_workspace("COE-DETACHED-UNBOUND", false) + .await; +} + #[tokio::test] async fn recovery_requeues_cancelled_merging_interrupt() { let recovered_workspace = workspace_record("COE-272-MERGING", "/tmp/recovered/COE-272-MERGING"); diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index 481da0e83..e9989276c 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -332,9 +332,11 @@ impl WorkspaceManager { &self, issue: &IssueDescriptor, ) -> Result { - let mut evidence_issue = issue.clone(); - evidence_issue.repository_binding = None; - self.ensure_local_workspace(&evidence_issue, WorkspaceHooks::Skipped) + // The binding is kept in the issue manifest: it records which + // repository the remote run belongs to, and dropping it makes the + // workspace look unbound to every later binding comparison. Only the + // checkout it would otherwise drive is skipped. + self.ensure_local_workspace(issue, WorkspaceHooks::Skipped) .await } @@ -2657,6 +2659,27 @@ impl WorkspaceManager { workspace, state, self.config.cleanup.remove_terminal_workspaces, + WorkspaceHooks::Executed, + ) + .await + } + + /// Clean up an evidence-only workspace without running `before_remove`. + /// + /// The hook is written for a checkout this workspace never materialized, + /// so running it here would execute repository commands in a directory + /// that only holds manifests, journals, and imported evidence. + pub async fn cleanup_evidence_only( + &self, + workspace: &WorkspaceHandle, + state: IssueLifecycleState, + force_remove: bool, + ) -> Result { + self.cleanup_with_terminal_removal( + workspace, + state, + force_remove || self.config.cleanup.remove_terminal_workspaces, + WorkspaceHooks::Skipped, ) .await } @@ -2668,8 +2691,13 @@ impl WorkspaceManager { &self, workspace: &WorkspaceHandle, ) -> Result { - self.cleanup_with_terminal_removal(workspace, IssueLifecycleState::Terminal, true) - .await + self.cleanup_with_terminal_removal( + workspace, + IssueLifecycleState::Terminal, + true, + WorkspaceHooks::Executed, + ) + .await } async fn cleanup_with_terminal_removal( @@ -2677,6 +2705,7 @@ impl WorkspaceManager { workspace: &WorkspaceHandle, state: IssueLifecycleState, remove_terminal_workspaces: bool, + hooks: WorkspaceHooks, ) -> Result { if !path_exists(workspace.workspace_path()).await? { return Ok(CleanupOutcome { @@ -2697,9 +2726,14 @@ impl WorkspaceManager { }); } - let before_remove = match self.execute_hook(HookKind::BeforeRemove, workspace).await { - Ok(record) => record, - Err(failure) => Some(failure.record), + let before_remove = match hooks { + WorkspaceHooks::Skipped => None, + WorkspaceHooks::Executed => { + match self.execute_hook(HookKind::BeforeRemove, workspace).await { + Ok(record) => record, + Err(failure) => Some(failure.record), + } + } }; let decision = if remove_terminal_workspaces { CleanupDecision::Remove diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index d8fc68242..8f74df053 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -243,13 +243,29 @@ async fn evidence_only_workspace_runs_no_hooks() { after_create: Some(HookDefinition::shell("echo ran > after_create.txt")), before_run: Some(HookDefinition::shell("echo ran > before_run.txt")), after_run: Some(HookDefinition::shell("echo ran > after_run.txt")), - before_remove: None, + before_remove: Some(HookDefinition::shell("echo ran > before_remove.txt")), ..HookConfig::default() }, CleanupConfig::default(), )) .expect("manager should build"); - let issue = sample_issue("COE-EVIDENCE"); + let mut issue = sample_issue("COE-EVIDENCE"); + let binding = RepositoryBinding { + alias: "source".to_owned(), + repository: RepositoryIdentity { + id: CanonicalRepositoryId::from_remote("github", None, "acme/evidence") + .expect("repository id should be valid"), + safe_remote_fingerprint: SafeRemoteFingerprint::from_remote( + "github", + None, + "acme/evidence", + ) + .expect("fingerprint should be valid"), + }, + config_generation: "config-1".to_owned(), + inventory_generation: "inventory-1".to_owned(), + }; + issue.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding.clone())); let ensured = manager .ensure_evidence_only(&issue) @@ -257,6 +273,17 @@ async fn evidence_only_workspace_runs_no_hooks() { .expect("evidence-only workspace should be created"); assert!(ensured.created); assert!(ensured.after_create.is_none()); + // Skipping the checkout must not erase which repository the remote run + // belongs to: recovery compares this binding against the live one. + assert_eq!( + ensured + .issue_manifest + .repository_binding + .as_ref() + .and_then(|outcome| outcome.repository_id()), + Some(binding.repository_id()), + "an evidence-only workspace must keep its repository identity" + ); let mut run_manifest = manager .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-evidence", 1)) @@ -272,7 +299,19 @@ async fn evidence_only_workspace_runs_no_hooks() { assert_eq!(run_manifest.status, RunStatus::Succeeded); assert!(run_manifest.hooks.is_empty()); - for marker in ["after_create.txt", "before_run.txt", "after_run.txt"] { + let retained = manager + .cleanup_evidence_only(&ensured.handle, IssueLifecycleState::Terminal, false) + .await + .expect("evidence-only cleanup should succeed"); + assert_eq!(retained.decision, CleanupDecision::Retain); + assert!(retained.before_remove.is_none()); + + for marker in [ + "after_create.txt", + "before_run.txt", + "after_run.txt", + "before_remove.txt", + ] { assert!( !tokio::fs::try_exists(ensured.handle.workspace_path().join(marker)) .await @@ -280,6 +319,18 @@ async fn evidence_only_workspace_runs_no_hooks() { "{marker} must not exist: evidence-only workspaces run no repository hooks" ); } + + let removed = manager + .cleanup_evidence_only(&ensured.handle, IssueLifecycleState::Terminal, true) + .await + .expect("evidence-only cleanup should remove the workspace"); + assert_eq!(removed.decision, CleanupDecision::Remove); + assert!(removed.before_remove.is_none()); + assert!( + !tokio::fs::try_exists(ensured.handle.workspace_path()) + .await + .expect("workspace lookup should succeed") + ); } #[tokio::test] diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 03ebab162..f014515a4 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -145,7 +145,12 @@ through `ensure_evidence_workspace` for any harness whose capability transport is remote-only. That path materializes the contained issue directory for run manifests, journals, and evidence without cloning or verifying a checkout and without running repository hooks, so a host with no local repository access can -still launch a session Devin itself can clone. +still launch a session Devin itself can clone. All four hooks are skipped: +`after_create`, `before_run`, `after_run`, and — through the evidence-only +cleanup path — `before_remove`. The issue manifest still records the repository +binding, because skipping the checkout must not erase which repository the +remote run belongs to; recovery compares that identity against the live tracker +binding, and a detached run keeps its evidence workspace either way. A `--dry-run` route settles before any credential resolution or session creation, so it neither contacts the API nor consumes ACUs; it writes the local From b620a4380c073bb78b81a4b6ee21888fec64d8e8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:16:23 +0000 Subject: [PATCH 08/13] fix(config): carry central devin settings into the resolved workflow Central `config.yaml` could select `routing.harness: devin_cloud_agent` but had no place for the `devin:` block, and the repository-local front-matter merge dropped `devin` entirely, so the Devin route ran on inert defaults (no org/api env overrides, no ACU cap, no tags). - Accept an optional top-level `devin:` block in the central config, include it in the versioned shared-key set, and convert it into `WorkflowFrontMatter.devin`. - Merge repository-local `devin:` settings only when the central block is absent/default; central configuration wins otherwise. - Reject reuse of `devin.api.api_key_env` / `org_id_env` as a repository checkout credential variable. - Re-export `DevinFrontMatter` types from `opensymphony-workflow`. - Document a complete central `project_set` + Devin live-testing configuration in docs/configuration.md, including the dry-run guard, evidence location, and Ctrl-C/session-termination semantics. Validation: cargo fmt --all; cargo clippy --all-targets -D warnings; cargo test --lib orchestrator_run::config (77 passed); the documented sample config resolves through `opensymphony run --dry-run` up to the Linear authentication step with placeholder credentials. Co-Authored-By: Leonardo Gonzalez --- .../src/orchestrator_run/config.rs | 95 ++++++++++++++++++- crates/opensymphony-workflow/src/lib.rs | 5 +- docs/configuration.md | 90 ++++++++++++++++++ 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index 9afcd2a0c..d736027c6 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -14,9 +14,9 @@ use crate::opensymphony_domain::{ use crate::opensymphony_memory::DEFAULT_PRIVATE_MEMORY_CONFIG_FILE; use crate::opensymphony_openhands::OpenHandsConversationStorePaths; use crate::opensymphony_workflow::{ - AgentFrontMatter, HooksFrontMatter, IntegerLike, OpenHandsFrontMatter, PollingFrontMatter, - ResolvedWorkflow, RoutingFrontMatter, TrackerFrontMatter, WorkflowDefinition, - WorkflowFrontMatter, WorkspaceFrontMatter, + AgentFrontMatter, DevinFrontMatter, HooksFrontMatter, IntegerLike, OpenHandsFrontMatter, + PollingFrontMatter, ResolvedWorkflow, RoutingFrontMatter, TrackerFrontMatter, + WorkflowDefinition, WorkflowFrontMatter, WorkspaceFrontMatter, }; use crate::opensymphony_workflow::{ DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, @@ -88,6 +88,8 @@ struct CentralConfigFile { #[serde(default)] openhands: CentralOpenHandsFile, #[serde(default)] + devin: Option, + #[serde(default)] compatibility: CentralCompatibilityFile, } @@ -754,7 +756,7 @@ const CENTRAL_CONFIG_KEYS: &[&str] = &[ "compatibility", ]; -const VERSIONED_CENTRAL_SHARED_KEYS: &[&str] = &["control_plane", "openhands"]; +const VERSIONED_CENTRAL_SHARED_KEYS: &[&str] = &["control_plane", "openhands", "devin"]; fn has_central_top_level_key(raw: &str) -> bool { raw.lines().any(|line| { @@ -1502,6 +1504,16 @@ fn reject_checkout_credential_env_reuse( "OPENSYMPHONY_CODEX_BIN".to_owned(), "runtime.codex_binary_env", ); + if let Some(devin) = config.devin.as_ref() { + for (field, variable) in [ + ("devin.api.api_key_env", devin.api.api_key_env.as_deref()), + ("devin.api.org_id_env", devin.api.org_id_env.as_deref()), + ] { + if let Some(variable) = variable { + non_checkout_variables.insert(variable.to_owned(), field); + } + } + } if let Some(front_matter) = config.openhands.front_matter.as_ref() { let value = serde_yaml::to_value(front_matter).map_err(|_| { CentralConfigError::InvalidReference { @@ -1960,6 +1972,7 @@ fn central_workflow_front_matter( } openhands }, + devin: config.devin.clone().unwrap_or_default(), ..WorkflowFrontMatter::default() }; Ok(front_matter) @@ -1970,6 +1983,9 @@ fn merge_repository_local_front_matter( local: &WorkflowFrontMatter, ) -> WorkflowFrontMatter { central.codex = local.codex.clone(); + if central.devin == DevinFrontMatter::default() { + central.devin = local.devin.clone(); + } central.logging = local.logging.clone(); central.extensions = local.extensions.clone(); central @@ -3965,13 +3981,18 @@ scheduler: #[test] fn central_runtime_preserves_repository_local_front_matter_extensions() { let local = WorkflowDefinition::parse( - "---\ncodex:\n command: codex app-server\nlogging:\n level: debug\n---\nImplementation instructions\n", + "---\ncodex:\n command: codex app-server\ndevin:\n session:\n max_acu_limit: 25\n tags: [team:core]\nlogging:\n level: debug\n---\nImplementation instructions\n", ) .expect("repository-local workflow should parse") .front_matter; let merged = merge_repository_local_front_matter(WorkflowFrontMatter::default(), &local); assert_eq!(merged.codex, local.codex); + assert_eq!(merged.devin, local.devin); + assert_eq!( + merged.devin.session.tags.as_deref(), + Some(&["team:core".to_owned()][..]) + ); assert_eq!(merged.logging, local.logging); assert!(merged.extensions.is_empty()); } @@ -4109,6 +4130,70 @@ scheduler: ); } + #[test] + fn central_config_carries_devin_session_options_into_front_matter() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write( + root.path().join("integration.md"), + "integration instructions\n", + ) + .expect("integration instructions should be written"); + let source = format!( + "{}\ndevin:\n api:\n org_id_env: CUSTOM_DEVIN_ORG\n session:\n max_acu_limit: 25\n tags: [team:core]\n", + central_fixture(root.path()).replace( + " harness_env: TEST_HARNESS\n", + " harness: devin_cloud_agent\n harness_env: TEST_HARNESS\n" + ) + ); + let resolved = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect("devin profile should resolve"); + assert_eq!( + resolved.workflow_front_matter.routing.harness.as_deref(), + Some("devin_cloud_agent") + ); + let devin = &resolved.workflow_front_matter.devin; + assert_eq!(devin.api.org_id_env.as_deref(), Some("CUSTOM_DEVIN_ORG")); + assert_eq!(devin.session.max_acu_limit, Some(IntegerLike::Integer(25))); + assert_eq!( + devin.session.tags.as_deref(), + Some(&["team:core".to_owned()][..]) + ); + let local = WorkflowDefinition::parse( + "---\ndevin:\n session:\n max_acu_limit: 5\n---\nInstructions\n", + ) + .expect("repository-local workflow should parse") + .front_matter; + let merged = + merge_repository_local_front_matter(resolved.workflow_front_matter.clone(), &local); + assert_eq!( + merged.devin.session.max_acu_limit, + Some(IntegerLike::Integer(25)) + ); + } + + #[test] + fn central_config_rejects_devin_credential_reuse_as_checkout_credential() { + let root = tempfile::tempdir().expect("central config root should exist"); + let source = format!( + "{}\ndevin:\n api:\n api_key_env: GITHUB_TOKEN\n", + central_fixture(root.path()) + .replace( + "clone: git@github.com:kumanday/OpenSymphony.git", + "clone: https://github.com/kumanday/OpenSymphony.git" + ) + .replace( + " github-ssh:\n kind: ssh-agent", + " github-ssh:\n kind: environment\n variable: GITHUB_TOKEN" + ) + ); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("devin token must not double as a checkout credential"); + assert!(matches!( + &error, + CentralConfigError::InvalidReference { field } if field == "devin.api.api_key_env" + )); + } + #[test] fn central_config_rejects_literal_openhands_environment_secret() { let root = tempfile::tempdir().expect("central config root should exist"); diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index 12111a8f8..3708bd26d 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -12,8 +12,9 @@ pub use error::{PromptTemplateError, WorkflowConfigError, WorkflowLoadError}; pub use model::{ AgentConfig, AgentFrontMatter, DEFAULT_OPENHANDS_LLM_MODEL, DEFAULT_PROMPT_TEMPLATE, DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, - DEFAULT_WORKSPACE_ROOT, Environment, HooksConfig, HooksFrontMatter, IntegerLike, - OpenHandsConfig, OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, + DEFAULT_WORKSPACE_ROOT, DevinApiFrontMatter, DevinFrontMatter, DevinSessionFrontMatter, + Environment, HooksConfig, HooksFrontMatter, IntegerLike, OpenHandsConfig, + OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, OpenHandsConversationAgentConfig, OpenHandsConversationAgentFrontMatter, OpenHandsConversationCondenserConfig, OpenHandsConversationCondenserFrontMatter, OpenHandsConversationConfig, OpenHandsConversationFrontMatter, OpenHandsConversationToolConfig, diff --git a/docs/configuration.md b/docs/configuration.md index b2e658790..4ee9c4413 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -794,6 +794,96 @@ configuration, manifests, and debug output. The name is validated as an environment-variable identifier and the token is read from the process environment only at request time. +### Running issues on Devin + +The route needs the repository's remote and target branch to bind Devin's +workspace, and those come from the central repository inventory, which is +consumed only in `project_set` routing. A repository-local `WORKFLOW.md` on +its own (or `legacy_single` routing) has no checkout policy to bind, so the +worker fails with `has no configured checkout policy to bind a devin +workspace`. A minimal `~/.opensymphony/config.yaml`: + +```yaml +schema_version: 1 +instance: + id: devin-trial + state_root: ~/.opensymphony/state/devin-trial +routing: + mode: project_set + active_project_set: trial + harness: devin_cloud_agent +tracker_profiles: + linear: + provider: linear + credential: linear-key + active_states: [Todo, In Progress, Rework] + terminal_states: [Done, Canceled] +project_sets: + trial: + tracker_profile: linear + projects: [trial-project] +linear_projects: + trial-project: + provider_project_id: "" + repositories: [target] +repositories: + target: + aliases: [target] + remote: + provider: github + locator: owner/repo + clone: https://github.com/owner/repo.git + target_branch: main + credential: github-https + review_profile: github + instructions: + path: WORKFLOW.md +credentials: + linear-key: + kind: environment + variable: LINEAR_API_KEY + github-https: + kind: environment + variable: GITHUB_TOKEN +review_profiles: + github: + provider: github + credential: github-https +workspace: + root: ~/.opensymphony/workspaces/devin-trial +memory: + catalog_root: ~/.opensymphony/state/devin-trial/memory +scheduler: + max_concurrent_tasks: 1 +devin: + session: + max_acu_limit: 10 + tags: [opensymphony] +``` + +`clone` must be an `https://` URL: Devin clones with its own credentials, and +the binding rejects SSH locators. The `devin:` block takes the same shape as the +`WORKFLOW.md` front matter above; the central copy wins, and a repository-local +block is only consulted when the central file has none. `api_key_env` and +`org_id_env` must not name a checkout-credential variable. Each Linear issue to +dispatch carries the managed `repo:target` label and sits in an active state of +the selected project. + +```bash +export LINEAR_API_KEY=lin_api_... COG_SERVICE_USER_TOKEN=cog_... DEVIN_ORG_ID=org-... +opensymphony run --dry-run # resolves routing, writes run.json, creates no session +opensymphony run # creates real sessions and consumes ACUs +``` + +`project_set` routing reads no repository checkout, so the command can run from +any directory. Nothing loads `.env` for you; export the variables (or +`set -a; . ./.env`). Watch progress with `opensymphony tui`; per-run evidence +lands under `//.opensymphony/devin//`. +Interrupting the orchestrator with Ctrl-C does not stop remote sessions: they +keep running within `max_acu_limit`, and the next `opensymphony run` +reattaches to them through the persisted binding. To stop a session, move its +issue to a terminal tracker state; terminal cleanup terminates and archives it. + ### What a Devin run does 1. Binds the client to one organization through `GET /v3/self`. A configured From 95274bf5ed6b2da3a24aa9885cb39cb52827e72f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:37:39 +0000 Subject: [PATCH 09/13] fix(devin): durable cleanup fence, verified tenancy, safe evidence writes Summary: - Terminate tracked Devin sessions (awaited, 30s bound) from WorkerBackend::abort_worker and a new WorkerBackend::shutdown called before runtime teardown; DevinSessionGuard::drop is opportunistic only. - Persist devin-route.json (session id, API origin, org, credential env NAME) beside the conversation manifest; terminal cleanup and the pre-creation fence address the bound session through that identity. - Reconcile a failed create_session through the correlation tag (list_sessions) before any retry; an unreconcilable create settles as non-retrying CancelFailed via the launch protocol. - DevinCloudClient::requests() requires verified tenancy (TenantUnbound), not a configured org id; identity() stays the only pre-binding call. - Evidence writes validate every path component (no symlinks) and commit via exclusive temp file + rename; symlinked dirs/files refused. - decide_issue_route rejects parent issues with sub-issues for remote-only harnesses; capability lists the gap. - Reserve one of 50 session tags for the correlation tag (49 operator tags, validated); send_user_message is advertised as false. - Box::pin the Devin route future inside the worker task like the other routes; the enlarged inline future overflowed the 1.5 MiB worker stack exercised by tests/run.rs. - Keep the guard armed through evidence persistence; failed import archives the settled session before reporting the retryable failure. Rationale: - Codex review at 2e3d270f: a Drop-spawned task may never run during runtime shutdown, a lost create response could orphan a billing session, and cleanup reconstructed from mutable workflow settings could mistake another tenant's 404 for proof the session is gone. - Durable bindings plus persisted route identity make cleanup ownership independent of the worker task and of later config edits. Tests: - cargo fmt; cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent (20), --test gateway_schema (59), --test scheduler (155), --lib devin (17); full cargo test - OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored (see PR comment) Co-Authored-By: Leonardo Gonzalez --- AGENTS.md | 12 +- .../src/orchestrator_run/backends.rs | 531 +++++++++++++++++- .../src/orchestrator_run/mod.rs | 3 + crates/opensymphony-devin/src/lib.rs | 256 ++++++++- .../src/capability.rs | 6 +- .../tests/gateway_schema.rs | 5 + .../src/scheduler.rs | 22 +- .../tests/scheduler.rs | 34 ++ docs/configuration.md | 2 +- docs/harness-adapter-compatibility.md | 49 +- tests/devin_cloud_agent.rs | 211 ++++++- 11 files changed, 1053 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cef33657..49f54c5b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,8 +205,16 @@ Devin hardening rules that must hold for any change to this harness: size and count limits, and written under sanitized names inside the issue workspace. - Every abandoned path (poll timeout, transport failure, scheduler interrupt, - worker task drop) must terminate and archive the remote session; an orphaned - Devin session keeps consuming ACUs. + worker abort, daemon shutdown) must terminate and archive the remote session + and await the acknowledgement; an orphaned Devin session keeps consuming + ACUs. `Drop` is never the only safeguard: the persisted session binding and + route identity (`devin-route.json`) are the durable fence, and a bound + session is stopped through *its own* persisted origin/organization before + any replacement is created. +- Organization-scoped requests require verified tenancy (`bind_tenant`), not + just a configured organization id; a failed `create_session` is reconciled + through the correlation tag before it is retried. +- Parent issues with sub-issues are not routed to remote-only harnesses. Changes to the route or the client must keep the opt-in live suite (`OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index b77af1336..2bf1f4187 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -3,7 +3,7 @@ mod acp; use futures_util::{StreamExt, stream}; -use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, @@ -530,7 +530,20 @@ type CodexInterruptRegistry = Arc>>>>; /// Live Devin sessions keyed by session id, so a scheduler interrupt can stop /// the remote session that a worker task is currently polling. -type DevinSessionRegistry = Arc>>; +type DevinSessionRegistry = Arc>>; + +/// A live Devin session owned by an in-process worker task, keyed by session id. +#[derive(Clone)] +struct TrackedDevinSession { + worker_id: String, + client: DevinCloudClient, +} + +/// Upper bound for one awaited remote termination during abort or shutdown. +const DEVIN_TERMINATION_TIMEOUT: Duration = Duration::from_secs(30); +/// Clock skew tolerated when matching a reconciled session's `created_at` +/// against the local creation attempt. +const DEVIN_CREATION_CLOCK_SKEW_SECS: i64 = 120; struct ActiveWorkerTask { handle: JoinHandle<()>, @@ -4224,8 +4237,10 @@ impl RuntimeWorkspaceBackend { // Keep the binding and evidence until the remote session // has stopped. The workspace manager records this workspace // as evidence-only and skips repository cleanup hooks. + let identity = read_devin_route_identity(&self.manager, &handle).await?; if let Err(error) = terminate_devin_session_for_cleanup( &self.devin_config, + identity.as_ref(), manifest.conversation_id.as_str(), ) .await @@ -5837,6 +5852,51 @@ impl RuntimeWorkerBackend { } } + /// Stops the Devin sessions tracked for `worker_id` (all of them when + /// `None`) and waits for each acknowledgement. + /// + /// Aborting a worker task only drops its future; the remote session would + /// keep executing and billing. A failed or timed-out termination is logged + /// rather than fatal because the session stays bound in the conversation + /// manifest: the pre-creation fence and terminal cleanup reach it later. + async fn terminate_tracked_devin_sessions(&self, worker_id: Option<&str>) { + let tracked: Vec<(String, DevinCloudClient)> = match self.devin_sessions.lock() { + Ok(sessions) => sessions + .iter() + .filter(|(_, tracked)| worker_id.is_none_or(|id| tracked.worker_id == id)) + .map(|(session_id, tracked)| (session_id.clone(), tracked.client.clone())) + .collect(), + Err(_) => Vec::new(), + }; + for (session_id, client) in tracked { + match timeout( + DEVIN_TERMINATION_TIMEOUT, + client.delete_session(&session_id, true), + ) + .await + { + Ok(Ok(())) => { + tracing::info!( + session_id, + "terminated and archived devin session before releasing its worker" + ); + if let Ok(mut sessions) = self.devin_sessions.lock() { + sessions.remove(&session_id); + } + } + Ok(Err(error)) => tracing::warn!( + session_id, + error = %error, + "devin session termination failed; its binding is retained for cleanup" + ), + Err(_) => tracing::warn!( + session_id, + "devin session termination timed out; its binding is retained for cleanup" + ), + } + } + } + fn abort_all_tracked_tasks(&mut self) { self.worker_issue_ids.clear(); let active_count = self.tasks.len(); @@ -5955,9 +6015,26 @@ impl RuntimeWorkerBackend { // A restarted worker must reattach to the session its previous // process created; creating a second one would leave the first // running and billing with nothing tracking it. - let reattach_session_id = if recovered { + let reattach = if recovered { match devin_session_binding(&workspace_manager, &ensured.handle).await { - Ok(session_id) => session_id, + Ok(Some(session_id)) => { + match read_devin_route_identity(&workspace_manager, &ensured.handle) + .await + { + Ok(identity) => Some(DevinReattachTarget { + session_id, + identity, + }), + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to read devin route identity: {error}"), + ); + return; + } + } + } + Ok(None) => None, Err(error) => { report_launch_failure( &mut launch_tx, @@ -6032,7 +6109,7 @@ impl RuntimeWorkerBackend { }); return; } - let result = run_devin_cloud_route(DevinRouteContext { + let result = Box::pin(run_devin_cloud_route(DevinRouteContext { workflow: &workflow, issue: &issue, run: &run, @@ -6045,8 +6122,8 @@ impl RuntimeWorkerBackend { devin_sessions: &devin_sessions, worker_id: &finished_worker_id, launch_tx: &mut launch_tx, - reattach_session_id, - }) + reattach, + })) .await; let (outcome, run_status) = match result { Ok(settlement) => { @@ -10401,7 +10478,65 @@ struct DevinRouteContext<'context> { launch_tx: &'context mut Option>, /// Set when a restarted worker inherited a live session binding; the route /// then follows that session instead of creating a replacement. - reattach_session_id: Option, + reattach: Option, +} + +/// A session binding inherited across a restart. +struct DevinReattachTarget { + session_id: String, + identity: Option, +} + +/// Where a bound Devin session lives: API origin, organization, and the name +/// of the environment variable holding its credential. +/// +/// Persisted beside the conversation manifest so that termination after a +/// restart or a configuration change is addressed to the session's own tenant. +/// Without it, a route pointed at another organization would receive a 404 that +/// looks like proof the original session is gone. The credential value itself +/// is never stored. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DevinRouteIdentity { + session_id: String, + base_url: String, + org_id: String, + api_key_env: String, +} + +fn devin_route_identity_path(workspace: &WorkspaceHandle) -> PathBuf { + workspace.metadata_dir().join("devin-route.json") +} + +async fn read_devin_route_identity( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, +) -> Result, WorkspaceError> { + let Some(raw) = workspace_manager + .read_text_artifact(workspace, &devin_route_identity_path(workspace)) + .await? + else { + return Ok(None); + }; + Ok(serde_json::from_str::(&raw).ok()) +} + +/// The client configuration a persisted binding must be addressed with. The +/// current workflow still supplies timeouts and polling cadence; origin, +/// organization, and credential reference come from the binding so that +/// `bind_tenant` refuses a credential from any other organization. +fn devin_config_for_identity( + current: &DevinCloudConfig, + identity: Option<&DevinRouteIdentity>, +) -> DevinCloudConfig { + let Some(identity) = identity else { + return current.clone(); + }; + DevinCloudConfig { + base_url: identity.base_url.clone(), + api_key_env: identity.api_key_env.clone(), + org_id: Some(identity.org_id.clone()), + ..current.clone() + } } /// How a Devin session settled, in scheduler terms. @@ -10463,10 +10598,22 @@ fn non_retrying_devin_outcome(outcome: WorkerOutcomeKind) -> Option, session_id: &str, ) -> Result<(), String> { + if identity.is_none() { + tracing::warn!( + session_id, + "devin binding has no persisted route identity; stopping it through the current configuration" + ); + } + let config = devin_config_for_identity(config, identity); + let config = &config; let client = DevinCloudClient::from_environment(config, |name| ProcessEnvironment.get(name)) .map_err(|error| { format!("no devin client could be built for session `{session_id}`: {error}") @@ -10514,12 +10661,19 @@ async fn write_devin_conversation_manifest( workspace_manager: &WorkspaceManager, workspace: &WorkspaceHandle, issue: &NormalizedIssue, - session_id: &str, - base_url: &str, + identity: &DevinRouteIdentity, route: &crate::opensymphony_orchestrator::HarnessRouteDecision, run_manifest: &mut RunManifest, ) -> Result<(), String> { + let session_id = identity.session_id.as_str(); + let base_url = identity.base_url.as_str(); let now = chrono::Utc::now(); + // The route identity goes first: once the conversation manifest names a + // session, every cleanup path must already know which tenant owns it. + workspace_manager + .write_json_artifact(workspace, &devin_route_identity_path(workspace), identity) + .await + .map_err(|error| format!("failed to persist devin route identity: {error}"))?; let conversation_id = ConversationId::new(session_id.to_owned()) .map_err(|error| format!("invalid devin session id for conversation manifest: {error}"))?; let manifest = IssueConversationManifest { @@ -10599,9 +10753,20 @@ struct DevinSessionGuard { } impl DevinSessionGuard { - fn new(client: DevinCloudClient, session_id: String, registry: DevinSessionRegistry) -> Self { + fn new( + client: DevinCloudClient, + session_id: String, + worker_id: &str, + registry: DevinSessionRegistry, + ) -> Self { if let Ok(mut sessions) = registry.lock() { - sessions.insert(session_id.clone(), client.clone()); + sessions.insert( + session_id.clone(), + TrackedDevinSession { + worker_id: worker_id.to_owned(), + client: client.clone(), + }, + ); } Self { client: Some(client), @@ -10624,14 +10789,23 @@ impl Drop for DevinSessionGuard { let Some(client) = self.client.take() else { return; }; + // `drop` cannot await. The guarantees live elsewhere: `abort_worker` + // and `shutdown` terminate tracked sessions *before* aborting the task, + // and the conversation manifest keeps the binding so the pre-creation + // fence and terminal cleanup stop anything that slipped through. This + // spawn is opportunistic only and may not run during runtime teardown. let session_id = self.session_id.clone(); + tracing::warn!( + session_id, + "devin worker dropped while its session was live; binding retained for cleanup" + ); if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { if let Err(error) = client.delete_session(&session_id, true).await { tracing::warn!( session_id, error = %error, - "failed to stop devin session after worker abort" + "failed to stop devin session after worker drop" ); } }); @@ -10655,10 +10829,17 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result match client.get_session(&session_id).await { + Some(target) => match client.get_session(&target.session_id).await { Ok(session) => (session, true), Err(error) => { return Ok(devin_reattach_failure( launch_tx, - &session_id, + &target.session_id, &config.base_url, route, &error.to_string(), @@ -10687,6 +10873,41 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result { + // Durable fence: a binding left behind by a dropped or crashed + // worker still names a live session. It is stopped before any + // replacement exists, and replacement is refused when it cannot be. + if let Some(stale_session_id) = devin_session_binding(workspace_manager, workspace) + .await + .map_err(|error| format!("failed to read devin session binding: {error}"))? + { + let stale_identity = read_devin_route_identity(workspace_manager, workspace) + .await + .map_err(|error| format!("failed to read devin route identity: {error}"))?; + if let Err(detail) = terminate_devin_session_for_cleanup( + &config, + stale_identity.as_ref(), + &stale_session_id, + ) + .await + { + return Ok(devin_replacement_refused( + launch_tx, + &stale_session_id, + stale_identity + .as_ref() + .map_or(config.base_url.as_str(), |identity| { + identity.base_url.as_str() + }), + route, + &detail, + )); + } + tracing::info!( + session_id = %stale_session_id, + "terminated the previously bound devin session before creating a replacement" + ); + } + let mut session_options = config.session.clone(); if let Some(references) = session_options.secret_ids.as_ref() { let resolved = client @@ -10697,18 +10918,59 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result created, + // Devin may have accepted the request even though the response + // was lost or undecodable. A retry is only safe once the + // correlation tag proves no session was created. + Err(create_error) => { + let correlation_tag = binding.correlation_tag(); + match client + .reconcile_created_session(&correlation_tag, not_before) + .await + { + Ok(Some(session)) => { + tracing::warn!( + session_id = %session.session_id, + error = %create_error, + "devin session creation reported an error but the tagged session exists; following it" + ); + session + } + Ok(None) => { + return Err(format!( + "devin session creation failed: {create_error} (no unarchived session tagged `{correlation_tag}` exists, so a retry is safe)" + )); + } + Err(lookup_error) => { + return Ok(devin_unreconciled_creation( + launch_tx, + &correlation_tag, + &config.base_url, + route, + &create_error.to_string(), + &lookup_error.to_string(), + )); + } + } + } + }; (created, false) } }; let mut guard = DevinSessionGuard::new( client.clone(), created.session_id.clone(), + worker_id.as_str(), Arc::clone(devin_sessions), ); + let identity = DevinRouteIdentity { + session_id: created.session_id.clone(), + base_url: config.base_url.clone(), + org_id: tenant_org_id, + api_key_env: config.api_key_env.clone(), + }; // The session binding and run state must be durable before the scheduler // hears about the launch: cancellation and restart recovery reach the live // session only through these records, and a failure here leaves the guard @@ -10717,8 +10979,7 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result manifest, Err(error) => { - let settlement = devin_settlement(&report); // Losing local evidence must not turn a session that is still // alive into a retryable failure: the operator's answer belongs to // the session the conversation manifest still points at. @@ -10840,9 +11108,28 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result>, + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + error: &str, +) -> DevinSettlement { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(devin_conversation_metadata(session_id, base_url, route)), + started_at: None, + }); + } + DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!("Devin session {session_id} is still bound and could not be terminated"), + error: Some(format!( + "a replacement devin session was refused because the bound session `{session_id}` could not be terminated: {error}" + )), + run_status: RunStatus::Failed, + } +} + +/// Settle a creation attempt whose outcome is unknown: the create call failed +/// and the correlation-tag lookup failed too, so a session may exist unseen. +/// Retrying could start a second one, so the outcome is non-retrying and the +/// operator is pointed at the tag to search for in Devin. +fn devin_unreconciled_creation( + launch_tx: &mut Option>, + correlation_tag: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + create_error: &str, + lookup_error: &str, +) -> DevinSettlement { + let placeholder = format!("unreconciled:{correlation_tag}"); + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(devin_conversation_metadata(&placeholder, base_url, route)), + started_at: None, + }); + } + DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!( + "Devin session creation could not be reconciled; check sessions tagged `{correlation_tag}`" + ), + error: Some(format!( + "devin session creation failed ({create_error}) and the correlation lookup failed ({lookup_error}); a session tagged `{correlation_tag}` may exist and must be checked before this issue is retried" + )), + run_status: RunStatus::Failed, + } +} + fn report_launch_failure( launch_tx: &mut Option>, detail: impl Into, @@ -11517,6 +11861,8 @@ impl WorkerBackend for RuntimeWorkerBackend { worker_id: &crate::opensymphony_domain::WorkerId, reason: WorkerAbortReason, ) -> Result<(), Self::Error> { + self.terminate_tracked_devin_sessions(Some(worker_id.as_str())) + .await; let acp_session = self .acp_active .lock() @@ -11610,6 +11956,11 @@ impl WorkerBackend for RuntimeWorkerBackend { Ok(()) } + async fn shutdown(&mut self) { + self.terminate_tracked_devin_sessions(None).await; + self.abort_all_tracked_tasks(); + } + async fn interrupt_worker( &mut self, command: crate::opensymphony_domain::HarnessInterruptCommand, @@ -11682,7 +12033,7 @@ async fn stop_devin_session( CliWorkerError::InterruptFailed("Devin session registry lock poisoned".to_string()) })? .get(session_id) - .cloned(); + .map(|tracked| tracked.client.clone()); // After a daemon restart the in-process registry is empty, but the session // binding survives in the conversation manifest and the session is still // billing. Rebuild a tenant-bound client so cancellation still reaches it. @@ -15673,6 +16024,130 @@ mod tests { ); } + #[tokio::test] + async fn devin_route_identity_is_persisted_and_addresses_cleanup() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"); + let issue = sample_issue(); + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("evidence workspace should be ensured"); + assert_eq!( + read_devin_route_identity(&workspace_manager, &ensured.handle) + .await + .expect("identity lookup should succeed"), + None + ); + + let identity = DevinRouteIdentity { + session_id: "devin-session-bound".into(), + base_url: "https://devin.example.test".into(), + org_id: "org-original".into(), + api_key_env: "ORIGINAL_DEVIN_TOKEN".into(), + }; + let mut run_manifest = workspace_manager + .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-1", 1)) + .await + .expect("run should start"); + write_devin_conversation_manifest( + &workspace_manager, + &ensured.handle, + &issue, + &identity, + &devin_test_route(false), + &mut run_manifest, + ) + .await + .expect("binding should persist"); + + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed") + .as_deref(), + Some("devin-session-bound") + ); + assert_eq!( + read_devin_route_identity(&workspace_manager, &ensured.handle) + .await + .expect("identity lookup should succeed"), + Some(identity.clone()) + ); + let raw = + fs::read_to_string(devin_route_identity_path(&ensured.handle)).expect("identity file"); + assert!( + !raw.contains("cog_") && !raw.contains("token_value"), + "only the credential's environment variable name is persisted" + ); + + // Cleanup is addressed through the persisted identity even after the + // workflow has been pointed at a different origin and organization. + let current = DevinCloudConfig { + base_url: "https://api.devin.ai".into(), + api_key_env: "OTHER_TOKEN".into(), + org_id: Some("org-other".into()), + ..DevinCloudConfig::default() + }; + let addressed = devin_config_for_identity(¤t, Some(&identity)); + assert_eq!(addressed.base_url, identity.base_url); + assert_eq!(addressed.org_id.as_deref(), Some("org-original")); + assert_eq!(addressed.api_key_env, "ORIGINAL_DEVIN_TOKEN"); + assert_eq!(addressed.session, current.session); + assert_eq!(devin_config_for_identity(¤t, None), current); + } + + #[test] + fn devin_unreconciled_creation_settles_without_a_retryable_launch() { + let (tx, mut rx) = oneshot::channel(); + let mut launch_tx = Some(tx); + let settlement = devin_unreconciled_creation( + &mut launch_tx, + "opensymphony:coe-284", + "https://api.devin.ai", + &devin_test_route(false), + "response body was not valid JSON", + "connection reset while listing sessions", + ); + assert_eq!(settlement.outcome, WorkerOutcomeKind::CancelFailed); + assert_eq!(settlement.run_status, RunStatus::Failed); + assert!( + settlement + .error + .as_deref() + .is_some_and(|error| error.contains("opensymphony:coe-284")), + "the operator must be told which tag to search for" + ); + match rx.try_recv().expect("launch report must be sent") { + LaunchReport::Conversation { conversation, .. } => assert_eq!( + conversation.conversation_id.as_str(), + "unreconciled:opensymphony:coe-284" + ), + LaunchReport::Failed(detail) => { + panic!("an unreconciled creation must not be reported as retryable: {detail}") + } + } + + let (tx, mut rx) = oneshot::channel(); + let mut launch_tx = Some(tx); + let refused = devin_replacement_refused( + &mut launch_tx, + "devin-stale", + "https://api.devin.ai", + &devin_test_route(false), + "tenant mismatch", + ); + assert_eq!(refused.outcome, WorkerOutcomeKind::CancelFailed); + assert!(matches!( + rx.try_recv().expect("launch report must be sent"), + LaunchReport::Conversation { conversation, .. } + if conversation.conversation_id.as_str() == "devin-stale" + )); + } + #[tokio::test] async fn devin_recovery_reads_the_persisted_session_binding() { let tempdir = TempDir::new().expect("tempdir should exist"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index 3d8c27eb8..d83734193 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -1613,6 +1613,9 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { } } + // Remote-harness workers (Devin) hold sessions that keep billing after + // their local task is gone; stop them before the runtime tears down. + scheduler.worker_mut().shutdown().await; server_task.abort(); shutdown_task.abort(); shutdown_memory_server(&mut memory_server).await?; diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 5ecb3f5b6..818f6f291 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -49,6 +49,7 @@ use std::{ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; +use tokio::io::AsyncWriteExt; use url::Url; use crate::{ @@ -72,6 +73,9 @@ pub const DEFAULT_DEVIN_SESSION_POLL_INTERVAL_MS: u64 = 5_000; pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; /// Maximum number of tags Devin accepts on a session. pub const DEVIN_MAX_SESSION_TAGS: usize = 50; +/// Tags an operator may configure; one slot is reserved for the correlation +/// tag that lets OpenSymphony find its own sessions again. +pub const DEVIN_MAX_OPERATOR_TAGS: usize = DEVIN_MAX_SESSION_TAGS - 1; /// Maximum page size accepted by the paginated v3 collection endpoints. pub const DEVIN_MAX_PAGE_SIZE: u32 = 200; /// Prefix used for the tag that correlates a Devin session with an issue. @@ -106,7 +110,9 @@ pub enum DevinConfigError { InvalidOrgIdFromEnvironment, #[error("devin.session.max_acu_limit must be greater than zero")] InvalidAcuLimit, - #[error("devin sessions accept at most {DEVIN_MAX_SESSION_TAGS} tags")] + #[error( + "devin sessions accept at most {DEVIN_MAX_OPERATOR_TAGS} configured tags; one slot is reserved for the correlation tag" + )] TooManyTags, #[error("devin session tags must not be blank")] BlankTag, @@ -155,6 +161,10 @@ pub enum DevinClientError { CrossTenantPayload { expected: String, observed: String }, #[error("devin secret `{reference}` is not available to organization `{org_id}`")] UnknownSecret { reference: String, org_id: String }, + #[error( + "devin tenancy is unverified: `GET /v3/self` must bind the organization before session requests" + )] + TenantUnbound, #[error("devin attachment `{name}` is served from `{origin}`, which is not the api origin")] ForeignAttachmentOrigin { name: String, origin: String }, #[error("devin attachment `{name}` exceeds the {limit} byte evidence download limit")] @@ -282,7 +292,7 @@ impl DevinCloudConfig { if self.session.max_acu_limit == Some(0) { return Err(DevinConfigError::InvalidAcuLimit); } - if self.session.tags.len() > DEVIN_MAX_SESSION_TAGS { + if self.session.tags.len() > DEVIN_MAX_OPERATOR_TAGS { return Err(DevinConfigError::TooManyTags); } if self.session.tags.iter().any(|tag| tag.trim().is_empty()) { @@ -1022,12 +1032,17 @@ pub fn session_create_request( binding: &DevinRemoteWorkspaceBinding, options: &DevinSessionOptions, ) -> SessionCreateRequest { - let mut tags = options.tags.clone(); + // The correlation tag is how an uncertain creation is reconciled and how + // operators find OpenSymphony sessions, so it always survives truncation. let correlation = binding.correlation_tag(); - if !tags.iter().any(|tag| tag == &correlation) { - tags.push(correlation); - } - tags.truncate(DEVIN_MAX_SESSION_TAGS); + let mut tags: Vec = options + .tags + .iter() + .filter(|tag| *tag != &correlation) + .take(DEVIN_MAX_OPERATOR_TAGS) + .cloned() + .collect(); + tags.push(correlation); SessionCreateRequest { prompt: binding.compose_prompt(task_prompt), @@ -1151,13 +1166,42 @@ impl DevinCloudClient { self.poll_interval } - /// Request builder bound to the resolved organization. + /// Request builder bound to the verified organization. + /// + /// Only [`Self::identity`] may run before [`Self::bind_tenant`]: a + /// configured organization alone is an operator claim, not evidence that + /// the credential belongs to it. pub fn requests(&self) -> Result { - let org_id = self - .org_id - .as_deref() - .ok_or(DevinClientError::MissingOrgId)?; - Ok(DevinRequestBuilder::new(self.base_url.clone(), org_id)) + let tenancy = self + .tenancy + .as_ref() + .ok_or(DevinClientError::TenantUnbound)?; + Ok(DevinRequestBuilder::new( + self.base_url.clone(), + tenancy.org_id.as_str(), + )) + } + + /// Adopts a tenancy that was already verified against `GET /v3/self`. + /// + /// This exists for callers that hold the verified identity from another + /// client of the same credential (and for offline fixtures); the + /// configured organization must still agree with it. + pub fn bind_verified_tenancy( + mut self, + tenancy: DevinTenancy, + ) -> Result { + if let Some(configured) = self.org_id.as_deref() + && configured != tenancy.org_id + { + return Err(DevinClientError::TenantMismatch { + configured: configured.to_owned(), + credential: tenancy.org_id, + }); + } + self.org_id = Some(tenancy.org_id.clone()); + self.tenancy = Some(tenancy); + Ok(self) } /// Verified tenancy, once [`Self::bind_tenant`] has run. @@ -1189,10 +1233,32 @@ impl DevinCloudClient { /// Rejects a session payload owned by a different organization. fn ensure_tenant(&self, session: &SessionResponse) -> Result<(), DevinClientError> { let expected = self - .org_id - .as_deref() - .ok_or(DevinClientError::MissingOrgId)?; - ensure_session_tenant(expected, session) + .tenancy + .as_ref() + .ok_or(DevinClientError::TenantUnbound)?; + ensure_session_tenant(&expected.org_id, session) + } + + /// Finds the live session a lost `create_session` response may have + /// produced, so a retry never leaves an untracked session billing. + /// + /// Sessions are matched by the correlation tag, must not be archived, and + /// must have been created at or after `not_before`. Every candidate is + /// tenant-checked; the newest one wins. + pub async fn reconcile_created_session( + &self, + correlation_tag: &str, + not_before: i64, + ) -> Result, DevinClientError> { + let mut query = SessionsQueryParams::by_tag(correlation_tag); + query.is_archived = Some(false); + query.first = Some(DEVIN_MAX_PAGE_SIZE); + let page = self.list_sessions(&query).await?; + Ok(select_reconciled_session( + page.items, + correlation_tag, + not_before, + )) } pub async fn identity(&self) -> Result { @@ -1493,6 +1559,34 @@ pub fn ensure_session_tenant( Ok(()) } +/// Picks the session that an uncertain `create_session` most plausibly +/// produced: tagged with this run's correlation tag, not archived, and not +/// older than the creation attempt. Devin reports `created_at` in epoch +/// seconds; a millisecond value is normalized so a unit change cannot make +/// every candidate look too old. +pub fn select_reconciled_session( + sessions: Vec, + correlation_tag: &str, + not_before: i64, +) -> Option { + sessions + .into_iter() + .filter(|session| { + !session.is_archived + && session.tags.iter().any(|tag| tag == correlation_tag) + && epoch_seconds(session.created_at) >= not_before + }) + .max_by_key(|session| epoch_seconds(session.created_at)) +} + +fn epoch_seconds(value: i64) -> i64 { + if value > 100_000_000_000 { + value / 1_000 + } else { + value + } +} + /// Maps secret references onto ids owned by the bound organization. /// /// A reference may be a secret id or a secret key, which keeps workflow files @@ -2357,22 +2451,128 @@ fn pretty(value: &Value) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_owned()) } +fn evidence_error(path: &Path, source: std::io::Error) -> DevinClientError { + DevinClientError::Evidence { + path: path.to_path_buf(), + source, + } +} + +/// Creates `path` and its ancestors without ever following a symlink. +/// +/// The evidence root lives inside an issue workspace that a remote session +/// could influence through imported artifacts, so a symlinked directory on the +/// way down would redirect journal writes anywhere on the host. Every existing +/// component must be a real directory; missing ones are created one at a time. async fn create_dir(path: &Path) -> Result<(), DevinClientError> { - tokio::fs::create_dir_all(path) - .await - .map_err(|source| DevinClientError::Evidence { - path: path.to_path_buf(), - source, - }) + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + if matches!( + component, + std::path::Component::RootDir | std::path::Component::Prefix(_) + ) { + continue; + } + match tokio::fs::symlink_metadata(¤t).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(evidence_error( + ¤t, + std::io::Error::other("evidence path component is a symlink"), + )); + } + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(evidence_error( + ¤t, + std::io::Error::other("evidence path component is not a directory"), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match tokio::fs::create_dir(¤t).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // Raced with another writer; re-check that it is a + // real directory on the next iteration. + let metadata = tokio::fs::symlink_metadata(¤t) + .await + .map_err(|error| evidence_error(¤t, error))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(evidence_error( + ¤t, + std::io::Error::other( + "evidence path component appeared as a non-directory", + ), + )); + } + } + Err(error) => return Err(evidence_error(¤t, error)), + } + } + Err(error) => return Err(evidence_error(¤t, error)), + } + } + Ok(()) } +/// Writes an evidence file without following symlinks at the destination. +/// +/// The parent must already be a verified real directory (see [`create_dir`]). +/// Content is staged under a fresh exclusive name, so a symlink or foreign +/// file planted at the destination is replaced by the rename rather than +/// written through; `rename` never dereferences its target. async fn write_file(path: &Path, contents: &[u8]) -> Result<(), DevinClientError> { - tokio::fs::write(path, contents) + let parent = path.parent().ok_or_else(|| { + evidence_error(path, std::io::Error::other("evidence file has no parent")) + })?; + let parent_metadata = tokio::fs::symlink_metadata(parent) .await - .map_err(|source| DevinClientError::Evidence { - path: path.to_path_buf(), - source, - }) + .map_err(|error| evidence_error(parent, error))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Err(evidence_error( + parent, + std::io::Error::other("evidence directory is not a real directory"), + )); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| evidence_error(path, std::io::Error::other("evidence file has no name")))?; + if let Ok(existing) = tokio::fs::symlink_metadata(path).await + && !existing.is_file() + { + return Err(evidence_error( + path, + std::io::Error::other("evidence destination is not a regular file"), + )); + } + + let staged = parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default() + )); + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged) + .await + .map_err(|error| evidence_error(&staged, error))?; + let written = async { + file.write_all(contents).await?; + file.sync_data().await?; + drop(file); + tokio::fs::rename(&staged, path).await + } + .await; + if let Err(error) = written { + let _ = tokio::fs::remove_file(&staged).await; + return Err(evidence_error(path, error)); + } + Ok(()) } #[cfg(test)] diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index 9c80e1bdf..cd70c61ed 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -457,7 +457,7 @@ impl HarnessCapability { runtime_contract_version: Some("devin-api-v3".into()), actions: HarnessActionCapability { start_run: true, - send_user_message: true, + send_user_message: false, retry: true, cancel: true, pause: false, @@ -521,6 +521,10 @@ impl HarnessCapability { .into(), "Model settings are fixed at session creation through `devin_mode`; per-run model overrides are unavailable." .into(), + "Operator messages are not forwarded to the remote session: a session waiting on input is answered in Devin's own UI while OpenSymphony keeps following it." + .into(), + "Parent issues with sub-issues are not routed to Devin: parent execution roots, shared child worktrees, and final verification need a local checkout." + .into(), ], } } diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 1e899a1ec..f110cd229 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -880,6 +880,9 @@ fn devin_cloud_agent_capability_shape_is_stable() { assert!(back.actions.start_run); assert!(back.actions.cancel); assert!(!back.actions.approve); + // The route follows a session but has no operator-to-Devin message path, + // so advertising `send_user_message` would be a false promise to clients. + assert!(!back.actions.send_user_message); assert!(back.cancellation.acknowledges_cancel); assert!(back.history.preserve_unknown_events); assert_eq!(back.model_settings.credential_reference_kinds, vec!["env"]); @@ -888,6 +891,8 @@ fn devin_cloud_agent_capability_shape_is_stable() { "No mid-run interrupt", "Pause/resume", "per-run model overrides", + "Operator messages are not forwarded", + "Parent issues with sub-issues", ] { assert!( back.feature_gaps diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index c23590d5a..7187435c4 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -839,6 +839,12 @@ pub trait WorkerBackend { timed_out: false, }) } + + /// Releases every worker the backend still tracks before the process + /// exits. Backends whose workers hold remote resources (a Devin session + /// keeps billing after its local task is gone) must stop them here and + /// await the acknowledgement; dropping the backend cannot await anything. + async fn shutdown(&mut self) {} } pub struct OperatorResponseDelivery( @@ -9382,7 +9388,7 @@ impl TrackerSnapshot { } pub fn decide_issue_route( - _issue: &NormalizedIssue, + issue: &NormalizedIssue, config: &SchedulerConfig, ) -> Result { let capability = harness_capability(&config.routing.harness)?; @@ -9394,6 +9400,20 @@ pub fn decide_issue_route( ), }); } + // Parent execution roots, shared child worktrees, repair, merge, and the + // orchestrator-owned final verification all operate on a local checkout. A + // harness that only executes remotely has none, so the parent would settle + // without the verification evidence `enforce_parent_outcome_trust` needs. + if !issue.sub_issues.is_empty() && capability.transport.remote && !capability.transport.local { + return Err(SchedulerError::InvalidConfiguration { + detail: format!( + "selected harness `{}` executes remotely and cannot run parent issue `{}` with {} sub-issue(s); parent integration needs a local checkout", + config.routing.harness, + issue.identifier, + issue.sub_issues.len() + ), + }); + } Ok(HarnessRouteDecision { task_type: ROUTING_TASK_ISSUE_EXECUTION.into(), diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 77333185c..0ee219489 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -243,6 +243,40 @@ fn selected_route_uses_configured_codex_harness_and_model() { assert!(route.reason.contains("workflow routing.harness")); } +#[test] +fn remote_only_harness_refuses_parent_issues_with_sub_issues() { + let mut parent = normalized_issue("lin-parent", "COE-700", "In Progress"); + parent.sub_issues = vec![IssueRef { + id: IssueId::new("lin-child").expect("child id should be valid"), + identifier: IssueIdentifier::new("COE-701").expect("child identifier should be valid"), + state: "Done".to_string(), + }]; + let mut config = scheduler_config(); + config.routing.harness = "devin_cloud_agent".into(); + + // Parent integration (execution roots, shared child worktrees, final + // verification) needs a local checkout that a remote-only harness lacks. + let error = decide_issue_route(&parent, &config).expect_err("parent must be refused"); + assert!( + error.to_string().contains("parent issue `COE-700`"), + "{error}" + ); + assert!(error.to_string().contains("local checkout"), "{error}"); + + // The same harness still routes a leaf issue, and a local harness still + // routes the parent. + let mut leaf = normalized_issue("lin-leaf", "COE-702", "In Progress"); + leaf.sub_issues.clear(); + assert_eq!( + decide_issue_route(&leaf, &config) + .expect("leaf route") + .harness_kind, + "devin_cloud_agent" + ); + config.routing.harness = "openhands_agent_server".into(); + assert!(decide_issue_route(&parent, &config).is_ok()); +} + #[test] fn selected_acp_route_persists_profile_and_model_identity() { let issue = normalized_issue("lin-acp", "COE-611", "In Progress"); diff --git a/docs/configuration.md b/docs/configuration.md index 0c51abd43..f8bc322d3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -813,7 +813,7 @@ devin: | `knowledge_ids` | unset | Knowledge notes to attach. | | `secret_ids` | unset | *References* to Devin-held organization secrets; values never live in OpenSymphony config. | | `max_acu_limit` | unset | Positive ACU cap for the session. | -| `tags` | `[]` | At most 50 tags; an `opensymphony:` correlation tag is appended automatically. | +| `tags` | `[]` | At most 49 tags; the 50th slot is reserved for the `opensymphony:` correlation tag that is always appended. | | `title` | unset | Session title. | | `devin_mode` | unset | One of `normal`, `fast`, `lite`, `ultra`, `fusion`. | | `platform` | unset | VM platform / outpost pool override. | diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index a0fd3c648..06eb69f5b 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -197,12 +197,34 @@ the session outcome is preserved. A Devin session outlives the worker process, so every abandoned path stops it: poll timeout and transport failure terminate and archive the session before the -route returns, a scheduler interrupt looks the session up in the live-session -registry and deletes it, and dropping the worker task (scheduler abort) fires -`DevinSessionGuard`, which archives the session in the background. A scheduler -interrupt that finds no tracked session — the normal state after a daemon -restart — rebuilds a tenant-bound client from configuration and stops the -session recorded in the conversation manifest. +route returns, and a scheduler interrupt looks the session up in the +live-session registry and deletes it. Worker abort and daemon shutdown +(`WorkerBackend::abort_worker` / `WorkerBackend::shutdown`) terminate the +sessions tracked for the affected workers and *await* the acknowledgement +before the task is aborted; `DevinSessionGuard::drop` only makes an +opportunistic attempt, because a `Drop` cannot await and a task spawned during +runtime teardown may never run. A scheduler interrupt that finds no tracked +session — the normal state after a daemon restart — rebuilds a tenant-bound +client and stops the session recorded in the conversation manifest. + +The durable fence is the binding itself. Beside the conversation manifest the +route writes `devin-route.json`: the session id, API origin, organization, and +the *name* of the credential environment variable (never its value). Every +later cleanup — terminal issue cleanup, and the pre-creation check that stops +a session a dropped or crashed worker left bound — is addressed through that +persisted identity, so a workflow that has since been pointed at another origin +or organization cannot produce a 404 that looks like proof the original +session is gone; `bind_tenant` refuses the mismatched credential instead. If +the bound session cannot be terminated, the route refuses to create a +replacement and settles as non-retrying `CancelFailed`. + +Session creation is reconciled before it is retried. Devin may accept a +`POST /sessions` whose response is lost or undecodable, so a failed create +first lists unarchived sessions carrying the run's correlation tag (the tag +always survives operator tag truncation; operators get 49 slots). A match +created at or after the attempt is followed as if the create had succeeded; a +confirmed miss is an ordinary retryable failure; a lookup that fails too +settles as `CancelFailed` and names the tag to search for in Devin. Ownership is only released once the remote session is provably gone. The route writes `run.json` and the Devin conversation binding *before* it reports the @@ -212,8 +234,19 @@ armed and settles as `CancelFailed`, which does not retry, rather than as an ordinary failure that would start a second session while the first still bills. A session waiting for operator input keeps its worker and polling loop active. The operator answers in Devin, and OpenSymphony follows that session through -completion and imports the final evidence. A configured scheduler stall timeout -still applies to a session that has no new runtime activity. +completion and imports the final evidence; OpenSymphony itself does not forward +operator messages, which is why the capability reports +`send_user_message: false`. A configured scheduler stall timeout still applies +to a session that has no new runtime activity. For every other outcome the +cleanup guard stays armed until the local evidence is durable: an evidence +import failure archives the settled session (awaited) before the retryable +failure is reported, and keeps `CancelFailed` ownership if even that fails. + +Parent issues with sub-issues are not routed to Devin. Parent execution roots, +shared child worktrees, repair/merge, and the orchestrator-owned final +verification all run against a local checkout, so `decide_issue_route` rejects +a parent for any harness whose transport is remote-only; the limitation is +listed in the capability's `feature_gaps`. When termination fails, the run manifest persists `CancelFailed` in `terminal_worker_outcome`. Scheduler recovery restores that outcome before diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index 3367681ff..baebc0b98 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -9,16 +9,18 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use opensymphony::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_REMOTE_CONTAINMENT, - DEVIN_UNDISCRIMINATED_EVENT_KIND, DevinClientError, DevinCloudAdapter, DevinCloudClient, - DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinHttpMethod, - DevinMessageCursor, DevinMode, DevinOperation, DevinProblemDetail, DevinRemoteWorkspaceBinding, - DevinRequest, DevinRequestBuilder, DevinRunOutcome, DevinRunReport, DevinSelfResponse, - DevinSessionOptions, DevinSessionStatus, DevinStatusDetail, NormalizedDevinEventKind, + DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_MAX_OPERATOR_TAGS, + DEVIN_MAX_SESSION_TAGS, DEVIN_REMOTE_CONTAINMENT, DEVIN_UNDISCRIMINATED_EVENT_KIND, + DevinClientError, DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, + DevinEvidenceCollector, DevinEvidenceLimits, DevinHttpMethod, DevinMessageCursor, DevinMode, + DevinOperation, DevinProblemDetail, DevinRemoteWorkspaceBinding, DevinRequest, + DevinRequestBuilder, DevinRunOutcome, DevinRunReport, DevinSelfResponse, DevinSessionOptions, + DevinSessionStatus, DevinStatusDetail, DevinTenancy, NormalizedDevinEventKind, PaginatedResponse, SecretResponse, SessionAttachment, SessionMessage, SessionResponse, SessionsQueryParams, attachment_download_url, devin_event_summary, ensure_session_tenant, evidence_file_name, normalize_devin_event, normalized_event_to_journal_record, - reconcile_tenant, resolve_secret_references, session_create_request, session_created_event, + reconcile_tenant, resolve_secret_references, select_reconciled_session, session_create_request, + session_created_event, }; use opensymphony::opensymphony_domain::HarnessAdapter; use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; @@ -389,12 +391,77 @@ fn create_session_request_maps_binding_and_options_onto_v3_fields() { assert!(body.get("structured_output_schema").is_none()); assert_eq!(body["max_acu_limit"], json!(20)); + // Operators get at most 49 tags: the 50th slot always carries the + // correlation tag, because it is how an uncertain creation is found again. let crowded = DevinSessionOptions { tags: (0..60).map(|index| format!("tag-{index}")).collect(), ..DevinSessionOptions::default() }; let request = session_create_request("fix the flake", &binding(), &crowded); - assert_eq!(request.tags.expect("tags").len(), 50); + let tags = request.tags.expect("tags"); + assert_eq!(tags.len(), DEVIN_MAX_SESSION_TAGS); + assert_eq!( + tags.last().map(String::as_str), + Some("opensymphony:acme-123") + ); + assert!( + tags.iter() + .take(DEVIN_MAX_OPERATOR_TAGS) + .all(|tag| tag.starts_with("tag-")) + ); + assert!( + DevinCloudConfig { + session: crowded, + ..DevinCloudConfig::default() + } + .validate() + .is_err(), + "more than {DEVIN_MAX_OPERATOR_TAGS} configured tags must be rejected" + ); + assert!( + DevinCloudConfig { + session: DevinSessionOptions { + tags: (0..DEVIN_MAX_OPERATOR_TAGS) + .map(|index| format!("tag-{index}")) + .collect(), + ..DevinSessionOptions::default() + }, + ..DevinCloudConfig::default() + } + .validate() + .is_ok() + ); +} + +#[test] +fn uncertain_creation_reconciles_only_this_runs_live_session() { + let mut mine = session("working", None); + mine.tags = vec!["opensymphony:acme-123".into()]; + mine.created_at = 1_700_000_100; + let mut newer = mine.clone(); + newer.session_id = "devin-newer".into(); + newer.created_at = 1_700_000_200_000; // milliseconds are normalized + let mut archived = mine.clone(); + archived.session_id = "devin-archived".into(); + archived.is_archived = true; + let mut other_issue = mine.clone(); + other_issue.session_id = "devin-other".into(); + other_issue.tags = vec!["opensymphony:acme-999".into()]; + let mut stale = mine.clone(); + stale.session_id = "devin-stale".into(); + stale.created_at = 1_699_999_000; + + let picked = select_reconciled_session( + vec![stale, archived, other_issue, mine, newer], + "opensymphony:acme-123", + 1_700_000_000, + ) + .expect("the tagged live session is found"); + assert_eq!(picked.session_id, "devin-newer"); + + assert!( + select_reconciled_session(Vec::new(), "opensymphony:acme-123", 1_700_000_000).is_none() + ); } #[test] @@ -573,7 +640,46 @@ fn client_requires_credentials_and_a_resolvable_organization() { }) .expect("client"); assert!(unresolved.org_id().is_none()); - assert!(unresolved.requests().is_err()); + assert!(matches!( + unresolved.requests(), + Err(DevinClientError::TenantUnbound) + )); + + // A configured organization is an operator claim, not verified tenancy: + // until `GET /v3/self` binds the credential, no organization-scoped + // request may be built. `identity()` is the only call allowed before that. + assert_eq!(discovered.org_id(), Some(ORG_ID)); + assert!(discovered.tenancy().is_none()); + assert!(matches!( + discovered.requests(), + Err(DevinClientError::TenantUnbound) + )); + let bound = discovered + .bind_verified_tenancy(DevinTenancy { + org_id: ORG_ID.into(), + principal_type: None, + service_user_id: None, + service_user_name: None, + }) + .expect("matching tenancy binds"); + assert!(bound.requests().is_ok()); + let foreign = + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some(ORG_ID.to_owned()), + _ => None, + }) + .expect("client") + .bind_verified_tenancy(DevinTenancy { + org_id: "org-other".into(), + principal_type: None, + service_user_id: None, + service_user_name: None, + }); + assert!(matches!( + foreign, + Err(DevinClientError::TenantMismatch { .. }) + )); // A bearer credential is attached to every request, so an invalid endpoint // must be rejected before the token is read. @@ -817,6 +923,93 @@ fn remote_attachment_names_stay_inside_the_evidence_directory() { assert!(evidence_file_name(3, &long).starts_with("003-")); } +fn finished_report(session: &SessionResponse) -> DevinRunReport { + DevinRunReport { + session_id: session.session_id.clone(), + session_url: session.url.clone(), + outcome: DevinRunOutcome::Finished, + status: session.status.clone(), + status_detail: session.status_detail.clone(), + acus_consumed: 0.0, + pull_requests: Vec::new(), + structured_output: None, + attachments: Vec::new(), + attachment_listing_error: None, + } +} + +#[cfg(unix)] +#[tokio::test] +async fn evidence_writes_refuse_symlinked_directories() { + let root = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside"); + // `.opensymphony/devin` points outside the workspace, as an imported + // artifact or a hostile checkout could arrange. + std::fs::create_dir_all(root.path().join(".opensymphony")).expect("metadata dir"); + std::os::unix::fs::symlink(outside.path(), root.path().join(".opensymphony/devin")) + .expect("symlink"); + let evidence_root = root.path().join(".opensymphony/devin/run-1"); + let mut collector = DevinEvidenceCollector::new( + &evidence_root, + DevinEvidenceLimits { + download_attachments: false, + ..DevinEvidenceLimits::default() + }, + ); + let session = session("exit", Some("finished")); + collector.record(&session_created_event(&session)); + + let error = collector + .persist(&client(), &finished_report(&session)) + .await + .expect_err("a symlinked evidence directory must be refused"); + assert!( + matches!(error, DevinClientError::Evidence { .. }), + "{error}" + ); + assert!( + std::fs::read_dir(outside.path()) + .expect("outside dir") + .next() + .is_none(), + "nothing may be written through the symlink" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn evidence_writes_do_not_follow_symlinked_output_files() { + let root = tempfile::tempdir().expect("tempdir"); + let target = root.path().join("victim.txt"); + std::fs::write(&target, "untouched").expect("victim"); + let evidence_root = root.path().join(".opensymphony/devin/run-1"); + std::fs::create_dir_all(&evidence_root).expect("evidence root"); + std::os::unix::fs::symlink(&target, evidence_root.join("events.jsonl")).expect("symlink"); + let mut collector = DevinEvidenceCollector::new( + &evidence_root, + DevinEvidenceLimits { + download_attachments: false, + ..DevinEvidenceLimits::default() + }, + ); + let session = session("exit", Some("finished")); + collector.record(&session_created_event(&session)); + + let error = collector + .persist(&client(), &finished_report(&session)) + .await + .expect_err("a symlinked evidence file must be refused"); + assert!( + matches!(error, DevinClientError::Evidence { .. }), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(&target).expect("victim still readable"), + "untouched", + "the symlink target must not be written through" + ); +} + #[tokio::test] async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { let root = tempfile::tempdir().expect("tempdir"); From 0bb08ba6b105d4755b98981bff7304d101d7984c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:47:52 +0000 Subject: [PATCH 10/13] fix(devin): stop exited sessions safely, surface message PR URLs Summary: - Add `DevinCloudClient::stop_session` with `DevinStopOutcome` (`Terminated` / `AlreadyStopped` / `Missing`) and the documented `POST .../sessions/{id}/archive` request. DELETE on an already-exited session is rejected by the real API with HTTP 400 "Devin session already exited"; the client now re-reads the session, treats terminal statuses as stopped, archives them if needed, and treats 404 as gone. A rejection while the session is still live keeps the original error. - Route every cleanup path (tracked-session shutdown, worker drop, polling failure, suspended session, evidence-import failure, scheduler interrupt, terminal workspace cleanup) through `stop_session`; interrupt acknowledgements report which outcome occurred. A session guard dropped after `abort_worker`/`shutdown` already stopped the session no longer spawns a redundant stop. - Production `DevinRunOptions` set `stop_when_waiting_on_operator`, so `waiting_for_user` / `waiting_for_approval` settle as `Detached` instead of being polled until Devin suspends for inactivity. - Scheduler recovery of an in-flight run with a retry attempt now passes through `RetryQueued` (`restore_retry` before `claim`), fixing "retry attempt mismatch: expected None, got Some(RetryAttempt(2))". - Evidence manifest gains `message_pull_request_urls`: `/pull/` and `/merge_requests/` https URLs that appear only in Devin-authored messages (deduplicated against the API-reported `pull_request_urls`); the settlement summary lists them. Rationale: - Observed during the live StackPerf dispatch: the first run's session was polled through inactivity suspension and treated as a stall, the retry's cleanup DELETE failed with "already exited", and a restarted daemon failed to rebuild its scheduler from the recovered retry attempt. The second run opened its PR through the GitHub API (Devin's built-in PR tool lacked permission) so the API's `pull_requests` was empty although the PR URL was in the journal. Tests: - cargo fmt; git diff --check - cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent (24 passed) - cargo test --lib -- devin - cargo test --test scheduler terminal_recovery (5 passed) - Live: `opensymphony run` against Linear COE-733/COE-734 routed to `devin_cloud_agent`; restart recovery restored `Detached` for COE-734 without creating a replacement session; terminal cleanup of COE-733 archived its exited session. Co-Authored-By: Leonardo Gonzalez --- .../src/orchestrator_run/backends.rs | 122 ++++++++----- crates/opensymphony-devin/src/lib.rs | 171 +++++++++++++++++- .../src/scheduler.rs | 12 ++ .../tests/scheduler.rs | 54 ++++++ docs/configuration.md | 24 ++- docs/harness-adapter-compatibility.md | 33 +++- tests/devin_cloud_agent.rs | 128 ++++++++++++- 7 files changed, 474 insertions(+), 70 deletions(-) diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 2bf1f4187..1ddd454b2 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -25,11 +25,11 @@ use crate::opensymphony_codex::{ turn_status, }; use crate::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_EVIDENCE_DIR_NAME, DevinClientError, - DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinMode, + DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_EVIDENCE_DIR_NAME, DevinCloudClient, + DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinMode, DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, DevinRunReport, - DevinSessionOptions, DevinSessionRunner, devin_event_payload, devin_event_summary, - session_create_request, session_created_event, status_event, + DevinSessionOptions, DevinSessionRunner, DevinStopOutcome, devin_event_payload, + devin_event_summary, session_create_request, session_created_event, status_event, }; use crate::opensymphony_domain::{ CanonicalRepositoryId, ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, @@ -5869,13 +5869,8 @@ impl RuntimeWorkerBackend { Err(_) => Vec::new(), }; for (session_id, client) in tracked { - match timeout( - DEVIN_TERMINATION_TIMEOUT, - client.delete_session(&session_id, true), - ) - .await - { - Ok(Ok(())) => { + match timeout(DEVIN_TERMINATION_TIMEOUT, client.stop_session(&session_id)).await { + Ok(Ok(_)) => { tracing::info!( session_id, "terminated and archived devin session before releasing its worker" @@ -10623,15 +10618,11 @@ async fn terminate_devin_session_for_cleanup( .map_err(|error| { format!("devin tenant binding failed while stopping `{session_id}`: {error}") })?; - match client.delete_session(session_id, true).await { - Ok(()) => Ok(()), - // An already-deleted session is the state cleanup wants; retaining the - // workspace forever over it would leak local state instead. - Err(DevinClientError::Status { status: 404, .. }) => Ok(()), - Err(error) => Err(format!( - "devin session `{session_id}` could not be terminated: {error}" - )), - } + client + .stop_session(session_id) + .await + .map(|_| ()) + .map_err(|error| format!("devin session `{session_id}` could not be terminated: {error}")) } fn conversation_manifest_is_devin(manifest: &IssueConversationManifest) -> bool { @@ -10783,12 +10774,19 @@ impl DevinSessionGuard { impl Drop for DevinSessionGuard { fn drop(&mut self) { - if let Ok(mut sessions) = self.registry.lock() { - sessions.remove(&self.session_id); - } + // `abort_worker` and `shutdown` untrack a session once they have + // terminated it; a guard dropped after that has nothing left to stop. + let still_tracked = self + .registry + .lock() + .map(|mut sessions| sessions.remove(&self.session_id).is_some()) + .unwrap_or(true); let Some(client) = self.client.take() else { return; }; + if !still_tracked { + return; + } // `drop` cannot await. The guarantees live elsewhere: `abort_worker` // and `shutdown` terminate tracked sessions *before* aborting the task, // and the conversation manifest keeps the binding so the pre-creation @@ -10801,7 +10799,7 @@ impl Drop for DevinSessionGuard { ); if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { - if let Err(error) = client.delete_session(&session_id, true).await { + if let Err(error) = client.stop_session(&session_id).await { tracing::warn!( session_id, error = %error, @@ -11007,15 +11005,7 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result { + match client.stop_session(&created.session_id).await { + Ok(_) => { guard.disarm(); return Err(format!( "devin session polling failed: {error} (remote session terminated)" @@ -11066,7 +11056,7 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result) -> Result DevinSettlement { WorkerOutcomeKind::Detached, RunStatus::Paused, Some(format!( - "devin session {} is waiting on operator input and stays active: {}", - report.session_id, report.session_url + "devin session {} is waiting on operator input (status `{}`) and stays bound: {}", + report.session_id, + report.status.as_str(), + report.session_url )), ), // Reached only after the route terminated the suspended session. @@ -11276,6 +11280,22 @@ fn devin_remote_binding( .map_err(|error| format!("devin repository binding rejected: {error}")) } +/// Polling policy for a scheduler-owned Devin session. +/// +/// A session that stops to ask its operator a question (`waiting_for_user`, +/// `waiting_for_approval`) settles the run as `Detached` instead of being +/// polled until Devin suspends it for inactivity: that suspension would be +/// reported as a stall and retried with a replacement session even though the +/// original one may already have delivered its work. +fn devin_run_options() -> DevinRunOptions { + DevinRunOptions { + stop_when_waiting_on_operator: true, + max_duration: None, + collect_attachments: true, + message_page_size: 100, + } +} + /// Renders the workflow prompt plus the issue facts Devin needs. fn devin_task_prompt( workflow: &ResolvedWorkflow, @@ -12057,15 +12077,21 @@ async fn stop_devin_session( } }; - client - .delete_session(session_id, true) + let outcome = client + .stop_session(session_id) .await .map_err(|error| CliWorkerError::InterruptFailed(error.to_string()))?; Ok(WorkerInterruptAcknowledgement { accepted: true, - detail: Some(format!( - "devin session `{session_id}` terminated and archived" - )), + detail: Some(match outcome { + DevinStopOutcome::Terminated => { + format!("devin session `{session_id}` terminated and archived") + } + DevinStopOutcome::AlreadyStopped => { + format!("devin session `{session_id}` had already stopped; archived") + } + DevinStopOutcome::Missing => format!("devin session `{session_id}` no longer exists"), + }), timed_out: false, }) } @@ -16100,6 +16126,14 @@ mod tests { assert_eq!(devin_config_for_identity(¤t, None), current); } + #[test] + fn devin_run_options_settle_when_devin_waits_on_its_operator() { + let options = devin_run_options(); + assert!(options.stop_when_waiting_on_operator); + assert!(options.collect_attachments); + assert_eq!(options.max_duration, None); + } + #[test] fn devin_unreconciled_creation_settles_without_a_retryable_launch() { let (tx, mut rx) = oneshot::channel(); diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 818f6f291..02a7a5403 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -557,6 +557,28 @@ impl DevinSessionStatus { } } +/// How [`DevinCloudClient::stop_session`] left a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DevinStopOutcome { + /// The terminate request was accepted; the session is stopped and archived. + Terminated, + /// Devin refused to terminate because the session had already reached a + /// terminal status; it is archived now if it was not before. + AlreadyStopped, + /// The session no longer exists. + Missing, +} + +/// Outcome of a termination Devin rejected, decided from the session's current +/// status: a session that is already terminal counts as stopped, anything else +/// keeps the rejection as an error. +pub fn rejected_termination_outcome(session: &SessionResponse) -> Option { + session + .status + .is_terminal() + .then_some(DevinStopOutcome::AlreadyStopped) +} + /// `status_detail` from `SessionResponse`. Kept as a string so unlisted detail /// values survive decoding; the interesting ones are exposed as predicates. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -577,6 +599,11 @@ impl DevinStatusDetail { pub fn is_waiting_on_operator(&self) -> bool { matches!(self.0.as_str(), "waiting_for_user" | "waiting_for_approval") } + + /// Devin put the session to sleep because nobody answered it. + pub fn is_inactivity(&self) -> bool { + self.0 == "inactivity" + } } /// `SessionPullRequest` from the Devin v3 OpenAPI document. @@ -636,6 +663,16 @@ impl SessionResponse { .as_ref() .is_some_and(DevinStatusDetail::is_waiting_on_operator) } + + /// The session went to sleep waiting for a reply; it stays resumable and + /// is treated like a session that is still waiting on its operator. + pub fn is_idle_suspended(&self) -> bool { + self.status == DevinSessionStatus::Suspended + && self + .status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_inactivity) + } } /// `SessionMessage` from the Devin v3 OpenAPI document. @@ -802,6 +839,7 @@ pub enum DevinOperation { ListSessionMessages, SendSessionMessage, DeleteSession, + ArchiveSession, ListSessionAttachments, UpdateSessionTags, ListSecrets, @@ -817,6 +855,7 @@ impl DevinOperation { Self::ListSessionMessages => "list_session_messages", Self::SendSessionMessage => "send_session_message", Self::DeleteSession => "delete_session", + Self::ArchiveSession => "archive_session", Self::ListSessionAttachments => "list_session_attachments", Self::UpdateSessionTags => "update_session_tags", Self::ListSecrets => "list_secrets", @@ -968,6 +1007,16 @@ impl DevinRequestBuilder { } } + /// `POST /v3/organizations/{org_id}/sessions/{devin_id}/archive` + pub fn archive_session(&self, devin_id: &str) -> DevinRequest { + DevinRequest { + operation: DevinOperation::ArchiveSession, + method: DevinHttpMethod::Post, + path: format!("{}/archive", self.session_path(devin_id)), + body: None, + } + } + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}/attachments` pub fn list_attachments(&self, devin_id: &str) -> DevinRequest { DevinRequest { @@ -1378,6 +1427,38 @@ impl DevinCloudClient { .map(|_| ()) } + pub async fn archive_session(&self, devin_id: &str) -> Result<(), DevinClientError> { + self.send(&self.requests()?.archive_session(devin_id)) + .await + .map(|_| ()) + } + + /// Stops a session so that it no longer runs or bills: terminates and + /// archives it, or confirms that it already reached a terminal status. + /// + /// Devin rejects `DELETE` for a session that has already exited + /// (`400 Devin session already exited`), so a rejected termination is + /// re-checked against the session's current status before it counts as + /// a failure; a session that is already terminal is archived if needed. + pub async fn stop_session(&self, devin_id: &str) -> Result { + let rejection = match self.delete_session(devin_id, true).await { + Ok(()) => return Ok(DevinStopOutcome::Terminated), + Err(DevinClientError::Status { status: 404, .. }) => { + return Ok(DevinStopOutcome::Missing); + } + Err(error @ DevinClientError::Status { .. }) => error, + Err(error) => return Err(error), + }; + let session = self.get_session(devin_id).await?; + let Some(outcome) = rejected_termination_outcome(&session) else { + return Err(rejection); + }; + if !session.is_archived { + self.archive_session(devin_id).await?; + } + Ok(outcome) + } + pub async fn list_attachments( &self, devin_id: &str, @@ -1695,6 +1776,9 @@ pub struct NormalizedDevinEvent { /// /// Unrecognized sources are retained as [`NormalizedDevinEventKind::Unknown`] /// with the full raw JSON so future Devin schema additions replay unchanged. +/// `event_type` of a normalized message authored by Devin itself. +pub const DEVIN_MESSAGE_EVENT_KIND: &str = "session.message.devin"; + pub fn normalize_session_message( session_id: &str, message: &SessionMessage, @@ -1877,6 +1961,46 @@ pub fn devin_event_summary(event: &NormalizedDevinEvent) -> String { } } +/// Extracts pull-request URLs (`.../pull/` or `.../merge_requests/`) +/// from free-form message text. Only absolute `https` URLs are accepted and +/// trailing punctuation is stripped. +pub fn pull_request_urls_in_text(text: &str) -> Vec { + let mut urls = Vec::new(); + for token in text.split(|c: char| { + c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '[' | ']' | '"' | '\'') + }) { + let candidate = token.trim_end_matches(['.', ',', ';', ':', '!', '?']); + if !candidate.starts_with("https://") { + continue; + } + let Ok(parsed) = Url::parse(candidate) else { + continue; + }; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + continue; + } + let Some(segments) = parsed.path_segments() else { + continue; + }; + let segments = segments.collect::>(); + let is_pull_request = segments.windows(2).any(|pair| { + matches!(pair[0], "pull" | "merge_requests") + && !pair[1].is_empty() + && pair[1].chars().all(|c| c.is_ascii_digit()) + }); + if is_pull_request { + let mut normalized = parsed.clone(); + normalized.set_query(None); + normalized.set_fragment(None); + let url = normalized.to_string(); + if !urls.contains(&url) { + urls.push(url); + } + } + } + urls +} + pub fn devin_event_payload(event: &NormalizedDevinEvent) -> Value { json!({ "source_kind": event.event_type, @@ -2079,7 +2203,15 @@ impl DevinSessionRunner { fn settled_outcome(&self, session: &SessionResponse) -> Option { match &session.status { DevinSessionStatus::Error => Some(DevinRunOutcome::Failed), + DevinSessionStatus::Suspended + if self.options.stop_when_waiting_on_operator && session.is_idle_suspended() => + { + Some(DevinRunOutcome::WaitingOnOperator) + } DevinSessionStatus::Suspended => Some(DevinRunOutcome::Suspended), + // An archived session was closed by someone (a terminate request, + // the console), not by Devin finishing the task. + DevinSessionStatus::Exit if session.is_archived => Some(DevinRunOutcome::Terminated), DevinSessionStatus::Exit => Some(DevinRunOutcome::Finished), _ if session .status_detail @@ -2221,7 +2353,12 @@ pub struct DevinEvidenceManifest { #[serde(default)] pub status_detail: Option, pub acus_consumed: f64, + /// Pull requests reported by the Devin API (`SessionResponse.pull_requests`). pub pull_request_urls: Vec, + /// Pull-request URLs Devin mentioned in its messages but that the API did + /// not report, e.g. when Devin opened the PR outside its built-in PR tool. + #[serde(default)] + pub message_pull_request_urls: Vec, pub event_count: usize, pub attachments: Vec, pub skipped_attachments: Vec, @@ -2300,6 +2437,11 @@ impl DevinEvidenceCollector { let (attachments, skipped) = self.import_attachments(client, report).await?; + let pull_request_urls = report + .pull_requests + .iter() + .map(|pull_request| pull_request.pr_url.clone()) + .collect::>(); let manifest = DevinEvidenceManifest { session_id: report.session_id.clone(), session_url: report.session_url.clone(), @@ -2310,11 +2452,8 @@ impl DevinEvidenceCollector { .as_ref() .map(|detail| detail.as_str().to_owned()), acus_consumed: report.acus_consumed, - pull_request_urls: report - .pull_requests - .iter() - .map(|pull_request| pull_request.pr_url.clone()) - .collect(), + pull_request_urls: pull_request_urls.clone(), + message_pull_request_urls: self.message_pull_request_urls(&pull_request_urls), event_count: self.events.len(), attachments, skipped_attachments: skipped, @@ -2332,6 +2471,28 @@ impl DevinEvidenceCollector { Ok(manifest) } + fn message_pull_request_urls(&self, reported: &[String]) -> Vec { + let mut urls = Vec::new(); + for event in &self.events { + let is_devin_message = event + .get("source_kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == DEVIN_MESSAGE_EVENT_KIND); + let Some(message) = event.get("message").and_then(Value::as_str) else { + continue; + }; + if !is_devin_message { + continue; + } + for url in pull_request_urls_in_text(message) { + if !reported.contains(&url) && !urls.contains(&url) { + urls.push(url); + } + } + } + urls + } + /// Downloads attachments into `attachments/`. A single failed download is /// recorded as skipped rather than failing the run: evidence import must /// not turn a completed Devin session into a failed OpenSymphony run. diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 7187435c4..b1894995d 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -5364,6 +5364,18 @@ where .with_repository_binding(recovered_run.repository_binding.clone()); let mut execution = IssueExecution::new(record.issue.clone(), observed_at); execution.attach_workspace(record.workspace.clone())?; + if let Some(attempt) = run.attempt { + execution = execution.restore_retry(RetryEntry { + issue_id: record.issue.id.clone(), + identifier: record.issue.identifier.clone(), + attempt, + normal_retry_count: recovered_run.normal_retry_count, + scheduled_at: observed_at, + due_at: observed_at, + reason: RetryReason::Reconciliation, + error: None, + })?; + } execution = execution.claim(run.clone())?.start_running( observed_at, effective_stall_timeout(self.config.stall_timeout_ms), diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 0ee219489..a340f08aa 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -10430,6 +10430,60 @@ async fn terminal_recovery_honors_failed_workspace_retention() { assert!(scheduler.workspace().cleaned.is_empty()); } +#[tokio::test] +async fn terminal_recovery_stops_a_retried_in_flight_run_before_workspace_removal() { + let recovered_worker_id = + WorkerId::new("worker-terminal-retried").expect("worker id should be valid"); + let recovered_workspace = workspace_record("COE-275", "/tmp/recovered/COE-275"); + let tracker = FakeTracker { + terminal: vec![tracker_issue("lin-275", "COE-275", "Done", 0)], + ..Default::default() + }; + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, + issue: normalized_issue("lin-275", "COE-275", "In Progress"), + workspace: recovered_workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: true, + pending_retry: false, + normal_retry_count: 2, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some("devin_cloud_agent".to_string()), + interrupt_reason: None, + recovered_run: Some(RecoveredRun { + worker_id: recovered_worker_id.clone(), + conversation: conversation(&recovered_worker_id), + normal_retry_count: 2, + repository_binding: None, + }), + }], + records: HashMap::from([("lin-275".to_string(), recovered_workspace)]), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + + scheduler + .tick(ts(100)) + .await + .expect("a retried in-flight run on a terminal issue must recover"); + + assert_eq!(scheduler.worker().aborted.len(), 1); + assert_eq!(scheduler.worker().aborted[0].0, "worker-terminal-retried"); + assert_eq!( + scheduler.worker().aborted[0].1, + WorkerAbortReason::BindingSuperseded + ); + assert_eq!(scheduler.workspace().cleaned.len(), 1); + assert!(scheduler.worker().launches.is_empty()); +} + #[tokio::test] async fn terminal_recovery_preserves_cancelled_workspace_policy() { let recovered_workspace = workspace_record("COE-275", "/tmp/recovered/COE-275"); diff --git a/docs/configuration.md b/docs/configuration.md index f8bc322d3..64f840f03 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -914,10 +914,20 @@ opensymphony run # creates real sessions and consumes ACUs any directory. Nothing loads `.env` for you; export the variables (or `set -a; . ./.env`). Watch progress with `opensymphony tui`; per-run evidence lands under `//.opensymphony/devin//`. -Interrupting the orchestrator with Ctrl-C does not stop remote sessions: they -keep running within `max_acu_limit`, and the next `opensymphony run` -reattaches to them through the persisted binding. To stop a session, move its -issue to a terminal tracker state; terminal cleanup terminates and archives it. +Interrupting the orchestrator with Ctrl-C terminates and archives every +session it is still tracking before the process exits, so a stopped daemon +leaves no session consuming ACUs; a session that was already detached +(waiting on its operator) is stopped by terminal cleanup once its issue +reaches a terminal tracker state. A daemon that dies without running its +shutdown path leaves the session alive within `max_acu_limit`; the next +`opensymphony run` reattaches to it through the persisted binding. + +When Devin finishes its turn and waits for a reply (`waiting_for_user` or +`waiting_for_approval`), the run settles as a non-retrying `Detached` outcome: +the session stays alive and bound, its message journal (including any pull +request URL Devin reported) is imported as evidence, and the operator continues +the conversation in the Devin console. The orchestrator does not forward +replies. ### What a Devin run does @@ -934,7 +944,11 @@ issue to a terminal tracker state; terminal cleanup terminates and archives it. 5. On settlement, imports evidence into the issue workspace under `.opensymphony/devin/`: `session.json`, `events.jsonl`, `evidence.json` (outcome, status, ACU usage, pull-request URLs, structured - output), and downloaded attachments. + output), and downloaded attachments. `pull_request_urls` lists the pull + requests the Devin API reports; `message_pull_request_urls` lists any + further `/pull/` URLs Devin mentioned in its messages (for example when + it opened the PR outside its built-in PR tool), so operators can find the + PR without reading `events.jsonl`. 6. Terminates and archives the remote session on cancellation, poll timeout, or transport failure, so an abandoned run cannot keep burning ACUs. diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 06eb69f5b..d22288f92 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -184,9 +184,11 @@ Certificate pinning is **not** implemented. Settled runs write `session.json`, `events.jsonl`, sanitized `attachments/`, and `evidence.json` under `/.opensymphony/devin/`. The manifest records session id and URL, outcome, status and status detail, ACU -usage, pull-request URLs, normalized event count, stored and skipped -attachments, structured output, and the `devin_owned_remote_workspace` -containment marker. Attachment downloads must match the authenticated API +usage, pull-request URLs (`pull_request_urls` from the API's +`SessionResponse.pull_requests`, plus `message_pull_request_urls` for +`/pull/` or `/merge_requests/` URLs that only appear in Devin's messages), +normalized event count, stored and skipped attachments, structured output, and +the `devin_owned_remote_workspace` containment marker. Attachment downloads must match the authenticated API origin, are bounded to 25 MiB each and 50 per run (both `Content-Length` and decoded body are checked), and land under sanitized names that cannot escape the evidence directory. A failed download is recorded as a skipped attachment. A @@ -232,15 +234,30 @@ launch, so cancellation and restart recovery can always find the live session. If termination after a polling failure fails, the route keeps the cleanup guard armed and settles as `CancelFailed`, which does not retry, rather than as an ordinary failure that would start a second session while the first still bills. -A session waiting for operator input keeps its worker and polling loop active. -The operator answers in Devin, and OpenSymphony follows that session through -completion and imports the final evidence; OpenSymphony itself does not forward +A session that stops to wait for operator input (`waiting_for_user`, +`waiting_for_approval`), or that Devin has already suspended for `inactivity` +while waiting, settles the run as the non-retrying `Detached` outcome: the +session stays alive and bound, its journal (including any pull request URL +Devin reported in a message) is imported as evidence, and the operator answers +in the Devin console. Polling through that state is not an option: Devin +suspends an unanswered session within minutes, and a suspension would otherwise +be reported as a stall and retried with a replacement session that repeats work +the first one may already have delivered. OpenSymphony itself does not forward operator messages, which is why the capability reports -`send_user_message: false`. A configured scheduler stall timeout still applies -to a session that has no new runtime activity. For every other outcome the +`send_user_message: false`. An `exit` session that is also archived was closed +by a terminate request or the console rather than by Devin finishing, and +settles as `Cancelled`. For every other outcome the cleanup guard stays armed until the local evidence is durable: an evidence import failure archives the settled session (awaited) before the retryable failure is reported, and keeps `CancelFailed` ownership if even that fails. +Every stop path (interrupt, worker abort, terminal cleanup, shutdown) goes +through `DevinCloudClient::stop_session`: Devin rejects `DELETE` for a session +that has already exited (`400 Devin session already exited`), so a rejected +termination is re-checked against the session's current status, and a session +that is already terminal counts as stopped (and is archived if it was not), +while a missing session (`404`) is treated the same way. Only a session that is +still live after a rejected termination keeps the error, and therefore keeps +the execution or workspace retained. Parent issues with sub-issues are not routed to Devin. Parent execution roots, shared child worktrees, repair/merge, and the orchestrator-owned final diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index baebc0b98..49002e62b 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -15,12 +15,13 @@ use opensymphony::opensymphony_devin::{ DevinEvidenceCollector, DevinEvidenceLimits, DevinHttpMethod, DevinMessageCursor, DevinMode, DevinOperation, DevinProblemDetail, DevinRemoteWorkspaceBinding, DevinRequest, DevinRequestBuilder, DevinRunOutcome, DevinRunReport, DevinSelfResponse, DevinSessionOptions, - DevinSessionStatus, DevinStatusDetail, DevinTenancy, NormalizedDevinEventKind, - PaginatedResponse, SecretResponse, SessionAttachment, SessionMessage, SessionResponse, - SessionsQueryParams, attachment_download_url, devin_event_summary, ensure_session_tenant, - evidence_file_name, normalize_devin_event, normalized_event_to_journal_record, - reconcile_tenant, resolve_secret_references, select_reconciled_session, session_create_request, - session_created_event, + DevinSessionStatus, DevinStatusDetail, DevinStopOutcome, DevinTenancy, + NormalizedDevinEventKind, PaginatedResponse, SecretResponse, SessionAttachment, SessionMessage, + SessionResponse, SessionsQueryParams, attachment_download_url, devin_event_summary, + ensure_session_tenant, evidence_file_name, normalize_devin_event, normalize_session_message, + normalized_event_to_journal_record, pull_request_urls_in_text, reconcile_tenant, + rejected_termination_outcome, resolve_secret_references, select_reconciled_session, + session_create_request, session_created_event, }; use opensymphony::opensymphony_domain::HarnessAdapter; use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; @@ -261,6 +262,11 @@ fn request_paths_match_the_vendored_v3_contract() { DevinHttpMethod::Delete, DevinOperation::DeleteSession, ), + ( + builder.archive_session("devin-1"), + DevinHttpMethod::Post, + DevinOperation::ArchiveSession, + ), ( builder.list_attachments("devin-1"), DevinHttpMethod::Get, @@ -1027,6 +1033,30 @@ async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { collector.record(&normalize_devin_event( json!({ "session_id": "devin-1", "unrecognized": true }), )); + let message = |event_id: &str, source: &str, text: &str| { + normalize_session_message( + &session.session_id, + &serde_json::from_value::(json!({ + "event_id": event_id, + "source": source, + "message": text, + "created_at": 1, + })) + .expect("session message"), + ) + }; + // The API-reported PR is not repeated; a PR Devin only mentioned in a + // message is surfaced separately; operator text is never mined. + collector.record(&message( + "event-1", + "devin", + "Opened https://github.com/acme/api/pull/7 and https://github.com/acme/api/pull/8.", + )); + collector.record(&message( + "event-2", + "user", + "Please look at https://github.com/acme/api/pull/9", + )); let report = DevinRunReport { session_id: session.session_id.clone(), @@ -1055,12 +1085,24 @@ async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { .await .expect("persist"); - assert_eq!(manifest.event_count, 2); + assert_eq!(manifest.event_count, 4); assert_eq!(manifest.containment, DEVIN_REMOTE_CONTAINMENT); assert_eq!( manifest.pull_request_urls, vec!["https://github.com/acme/api/pull/7"] ); + assert_eq!( + manifest.message_pull_request_urls, + vec!["https://github.com/acme/api/pull/8"] + ); + let on_disk: Value = serde_json::from_str( + &std::fs::read_to_string(evidence_root.join("evidence.json")).expect("evidence.json"), + ) + .expect("evidence json"); + assert_eq!( + on_disk["message_pull_request_urls"], + json!(["https://github.com/acme/api/pull/8"]) + ); assert_eq!(manifest.acus_consumed, 1.5); // Downloads were disabled, so the attachment is reported as skipped rather // than silently dropped. @@ -1068,7 +1110,7 @@ async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { assert_eq!(manifest.skipped_attachments.len(), 1); let journal = std::fs::read_to_string(evidence_root.join("events.jsonl")).expect("journal"); - assert_eq!(journal.lines().count(), 2); + assert_eq!(journal.lines().count(), 4); // Unknown payloads survive the round trip into local evidence. assert!(journal.contains("unrecognized")); @@ -1086,3 +1128,73 @@ async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { // Everything Devin returned stays under the orchestrator-owned root. assert!(evidence_root.starts_with(root.path())); } + +#[test] +fn idle_suspension_is_treated_as_waiting_on_operator() { + let idle = session("suspended", Some("inactivity")); + assert!(idle.is_idle_suspended()); + assert!(!idle.is_waiting_on_operator()); + assert!( + idle.status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_inactivity) + ); + + let out_of_quota = session("suspended", Some("out_of_quota")); + assert!(!out_of_quota.is_idle_suspended()); + + let waiting = session("running", Some("waiting_for_user")); + assert!(waiting.is_waiting_on_operator()); + assert!(!waiting.is_idle_suspended()); + assert!(!session("running", Some("inactivity")).is_idle_suspended()); +} + +#[test] +fn rejected_termination_counts_as_stopped_only_for_terminal_sessions() { + assert_eq!( + rejected_termination_outcome(&session("exit", None)), + Some(DevinStopOutcome::AlreadyStopped) + ); + assert_eq!( + rejected_termination_outcome(&session("suspended", Some("inactivity"))), + Some(DevinStopOutcome::AlreadyStopped) + ); + assert_eq!( + rejected_termination_outcome(&session("error", Some("error"))), + Some(DevinStopOutcome::AlreadyStopped) + ); + assert_eq!( + rejected_termination_outcome(&session("running", Some("working"))), + None + ); + assert_eq!(rejected_termination_outcome(&session("new", None)), None); +} + +#[test] +fn archived_exit_sessions_decode_as_closed_rather_than_finished() { + let mut value = session_json(); + value["status"] = json!("exit"); + value["status_detail"] = Value::Null; + value["is_archived"] = json!(true); + let closed: SessionResponse = serde_json::from_value(value).expect("session decodes"); + assert!(closed.is_archived); + assert!(closed.status.is_terminal()); + assert!(!closed.is_waiting_on_operator()); +} + +#[test] +fn pull_request_urls_are_extracted_from_message_text_only_when_well_formed() { + let text = "PR is open: https://github.com/acme/api/pull/52.\n\ + See (https://gitlab.com/acme/api/-/merge_requests/3) and \ + ; \ + not https://github.com/acme/api/pull/abc, not https://github.com/acme/api/issues/5, \ + not http://github.com/acme/api/pull/6, not github.com/acme/api/pull/7"; + assert_eq!( + pull_request_urls_in_text(text), + vec![ + "https://github.com/acme/api/pull/52", + "https://gitlab.com/acme/api/-/merge_requests/3", + ] + ); + assert!(pull_request_urls_in_text("no links here").is_empty()); +} From cf7ae3c35744362b5ac5e6c000566fc0217ce59b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:41:17 +0000 Subject: [PATCH 11/13] feat(devin): render remote repository instructions into Devin prompts Summary: - Fetch the repository's configured instruction file (WORKFLOW.md) from the GitHub remote at the target branch through the contents API, using the repository checkout credential, and render it into the `## Repository Instructions` section of the Devin session prompt. - Share the workspace `instruction_body` helper and `MAX_INSTRUCTION_FILE_BYTES` so remote instructions get the same front matter stripping and size bound as local checkouts. - Record path, git ref, SHA-256 and size as `repository_instructions` in `evidence.json` / `session.json`. - Document that `devin.session.secret_ids` must reference the Linear and GitHub secrets the repository's agent-side attach/push steps need. Rationale: - Remote-only routes have an evidence-only issue workspace with no checkout, so Devin sessions received "No repository-specific instructions were selected." and never saw the repository workflow step that attaches the PR to the Linear issue (observed on COE-733 and COE-734: PRs opened, no Linear attachments). - A configured file that cannot be fetched fails the launch (retryable), matching the local route, instead of running without instructions. Tests: - cargo test --lib -- devin (22 passed, 4 new) - cargo test --test devin_cloud_agent (24 passed) - cargo test --test scheduler terminal_recovery (5 passed) - cargo test --test workspace_manager (45 passed) - cargo clippy --all-targets -- -D warnings; cargo fmt --check; git diff --check Co-Authored-By: Leonardo Gonzalez --- .../src/orchestrator_run/backends.rs | 450 +++++++++++++++++- crates/opensymphony-devin/src/lib.rs | 30 ++ crates/opensymphony-workspace/src/lib.rs | 4 +- crates/opensymphony-workspace/src/manager.rs | 12 +- docs/configuration.md | 32 +- docs/harness-adapter-compatibility.md | 13 + 6 files changed, 518 insertions(+), 23 deletions(-) diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 1ddd454b2..d3211a478 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -60,12 +60,12 @@ use crate::opensymphony_orchestrator::{ use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ AcpRunRoute, CheckoutRepository, CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, - IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, ParentRuntimeEnvelope, - RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, - WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, - checkout_credential_environment_variables, compose_parent_continuation_prompt, - compose_parent_prompt, compose_terminal_prompt, environment_variable_names_equal, - redact_runtime_diagnostic, + IssueLifecycleState, MAX_INSTRUCTION_FILE_BYTES, ParentCheckoutRequest, + ParentRuntimeDescriptor, ParentRuntimeEnvelope, RunDescriptor, RunManifest, RunStatus, + TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, WorkspaceManager, + WorkspaceManagerConfig, checkout_credential_environment_variables, + compose_parent_continuation_prompt, compose_parent_prompt, compose_terminal_prompt, + environment_variable_names_equal, instruction_body, redact_runtime_diagnostic, }; use async_trait::async_trait; use thiserror::Error; @@ -10838,9 +10838,10 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result) -> Result) -> Result Result { +) -> Result { let repository_binding = run .repository_binding .as_ref() @@ -11257,14 +11273,23 @@ fn devin_remote_binding( .ok_or_else(|| { "devin routing requires a resolved repository binding for the issue".to_owned() })?; - let repository = workspace_manager + workspace_manager .checkout_repository(repository_binding.repository_id().as_str()) + .cloned() .ok_or_else(|| { format!( "repository `{}` has no configured checkout policy to bind a devin workspace", repository_binding.repository_id() ) - })?; + }) +} + +fn devin_remote_binding( + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + run_id: &str, + repository: &CheckoutRepository, +) -> Result { let evidence_path = run .workspace_path .join(".opensymphony") @@ -11296,12 +11321,185 @@ fn devin_run_options() -> DevinRunOptions { } } +/// Instruction file fetched from the repository remote for a Devin prompt. +#[derive(Debug)] +struct DevinFetchedInstructions { + record: crate::opensymphony_devin::DevinRepositoryInstructions, + body: String, +} + +/// GitHub contents URL of the repository's instruction file at the target +/// branch. Each path component becomes its own segment so names are escaped +/// and the path cannot climb out of the repository. +fn devin_instructions_contents_url( + api_root: &str, + owner: &str, + repository_name: &str, + instructions_path: &Path, + target_branch: &str, +) -> Result { + let mut url = + Url::parse(api_root).map_err(|error| format!("invalid GitHub API root: {error}"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "GitHub API root cannot be a base URL".to_owned())?; + segments + .pop_if_empty() + .push("repos") + .push(owner) + .push(repository_name) + .push("contents"); + for component in instructions_path.components() { + match component { + std::path::Component::Normal(name) => { + segments.push(name.to_str().ok_or_else(|| { + format!( + "instruction path `{}` is not valid UTF-8", + instructions_path.display() + ) + })?); + } + std::path::Component::CurDir => {} + _ => { + return Err(format!( + "instruction path `{}` must stay inside the repository", + instructions_path.display() + )); + } + } + } + } + url.query_pairs_mut().append_pair("ref", target_branch); + Ok(url) +} + +/// Reads the repository's configured instruction file from its GitHub remote. +/// +/// Returns `None` when the repository configures no instruction file. A +/// configured file that cannot be fetched is a launch failure, matching the +/// local route, which refuses to start without its verified checkout +/// instructions rather than silently running without them. +async fn fetch_devin_repository_instructions( + repository: &CheckoutRepository, +) -> Result, String> { + if repository.instructions_path.as_os_str().is_empty() { + return Ok(None); + } + if repository.provider != "github" { + return Err(format!( + "repository instructions `{}` cannot be fetched for provider `{}`; devin routing reads instructions from GitHub remotes only", + repository.instructions_path.display(), + repository.provider + )); + } + let (api_root, owner, repository_name) = + github_repository_api(repository).map_err(|error| error.to_string())?; + let url = devin_instructions_contents_url( + &api_root, + &owner, + &repository_name, + &repository.instructions_path, + &repository.target_branch, + )?; + let token = repository + .credential_env + .as_deref() + .or(repository.review_credential_env.as_deref()) + .map(|name| { + env::var(name) + .ok() + .filter(|token| !token.trim().is_empty()) + .ok_or_else(|| { + format!("configured repository credential variable `{name}` is not set") + }) + }) + .transpose()?; + fetch_devin_repository_instructions_at(repository, url, token).await +} + +async fn fetch_devin_repository_instructions_at( + repository: &CheckoutRepository, + url: Url, + token: Option, +) -> Result, String> { + let http = reqwest::Client::builder() + .timeout(GITHUB_ELIGIBILITY_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("GitHub client: {error}"))?; + let mut request = http + .get(url.clone()) + .header(reqwest::header::USER_AGENT, "opensymphony-orchestrator") + .header(reqwest::header::ACCEPT, "application/vnd.github.raw+json"); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(|error| { + format!( + "failed to fetch repository instructions `{}` from {}: {error}", + repository.instructions_path.display(), + repository.remote_locator + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "failed to fetch repository instructions `{}` at `{}` from {}: HTTP {}", + repository.instructions_path.display(), + repository.target_branch, + repository.remote_locator, + status.as_u16() + )); + } + let too_large = || { + format!( + "repository instructions `{}` exceed the {} byte limit", + repository.instructions_path.display(), + MAX_INSTRUCTION_FILE_BYTES + ) + }; + if response + .content_length() + .is_some_and(|length| length > MAX_INSTRUCTION_FILE_BYTES) + { + return Err(too_large()); + } + let mut response = response; + let mut raw: Vec = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("failed to read repository instructions: {error}"))? + { + if (raw.len() as u64).saturating_add(chunk.len() as u64) > MAX_INSTRUCTION_FILE_BYTES { + return Err(too_large()); + } + raw.extend_from_slice(&chunk); + } + let mut hasher = Sha256::new(); + hasher.update(&raw); + let content_hash = format!("sha256:{:x}", hasher.finalize()); + let body = String::from_utf8_lossy(&instruction_body(&repository.instructions_path, &raw)) + .into_owned(); + Ok(Some(DevinFetchedInstructions { + record: crate::opensymphony_devin::DevinRepositoryInstructions { + path: repository.instructions_path.to_string_lossy().into_owned(), + git_ref: repository.target_branch.clone(), + content_hash, + bytes: raw.len() as u64, + }, + body, + })) +} + /// Renders the workflow prompt plus the issue facts Devin needs. fn devin_task_prompt( workflow: &ResolvedWorkflow, issue: &NormalizedIssue, attempt: u32, route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + repository_instructions: Option<&str>, ) -> Result { let central_procedure = workflow .render_prompt(issue, Some(attempt)) @@ -11323,7 +11521,7 @@ fn devin_task_prompt( "Work in your own Devin cloud workspace. The OpenSymphony workspace on \ the orchestrator host stores evidence only and is not reachable from \ your environment.", - None, + repository_instructions, &format!( "harness={} containment={}", route.harness_kind, @@ -18848,6 +19046,230 @@ Run the scheduler. issue } + fn devin_instruction_repository(instructions_path: &str) -> CheckoutRepository { + CheckoutRepository { + provider: "github".into(), + provider_id: None, + remote_locator: "https://github.com/trilogy-group/StackPerf.git".into(), + remote: "https://github.com/trilogy-group/StackPerf.git".into(), + target_branch: "main".into(), + credential_kind: "environment".into(), + credential_reference: None, + credential_env: None, + review_credential_env: None, + instructions_path: instructions_path.into(), + policy_generation: "policy-1".into(), + review_profile: "github".into(), + review_provider: "github".into(), + review_policy_generation: "review-1".into(), + required_checks: false, + required_review: false, + merge_method: None, + } + } + + /// Serves exactly one HTTP response and returns the request head it saw. + async fn serve_one_response( + status: &'static str, + headers: &'static str, + body: Vec, + ) -> (String, JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = socket.read(&mut buffer).await.expect("read request"); + request.extend_from_slice(&buffer[..read]); + if read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let mut response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(&body); + socket.write_all(&response).await.expect("write response"); + socket.shutdown().await.ok(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{address}"), server) + } + + #[test] + fn devin_instructions_contents_url_targets_the_configured_branch() { + let url = devin_instructions_contents_url( + "https://api.github.com", + "trilogy-group", + "StackPerf", + Path::new("docs/agent notes/WORKFLOW.md"), + "release/next", + ) + .expect("contents url"); + assert_eq!( + url.as_str(), + "https://api.github.com/repos/trilogy-group/StackPerf/contents/docs/agent%20notes/WORKFLOW.md?ref=release%2Fnext" + ); + + let enterprise = devin_instructions_contents_url( + "https://github.example.com/api/v3", + "org", + "repo", + Path::new("./AGENTS.md"), + "main", + ) + .expect("enterprise contents url"); + assert_eq!( + enterprise.as_str(), + "https://github.example.com/api/v3/repos/org/repo/contents/AGENTS.md?ref=main" + ); + + let escape = devin_instructions_contents_url( + "https://api.github.com", + "org", + "repo", + Path::new("../other/WORKFLOW.md"), + "main", + ) + .expect_err("parent components must be rejected"); + assert!( + escape.contains("must stay inside the repository"), + "{escape}" + ); + } + + #[tokio::test] + async fn devin_repository_instructions_are_fetched_from_the_remote_and_stripped() { + let raw = b"---\nname: workflow\n---\n# Procedure\n\nAttach the PR.\n".to_vec(); + let (origin, server) = serve_one_response( + "200 OK", + "Content-Type: application/vnd.github.raw+json\r\n", + raw.clone(), + ) + .await; + let repository = devin_instruction_repository("WORKFLOW.md"); + let url = devin_instructions_contents_url( + &origin, + "trilogy-group", + "StackPerf", + &repository.instructions_path, + &repository.target_branch, + ) + .expect("contents url"); + + let fetched = fetch_devin_repository_instructions_at( + &repository, + url, + Some("token-under-test".to_owned()), + ) + .await + .expect("fetch succeeds") + .expect("instructions are configured"); + let request = server.await.expect("server task"); + + assert!( + request.starts_with( + "GET /repos/trilogy-group/StackPerf/contents/WORKFLOW.md?ref=main HTTP/1.1" + ), + "{request}" + ); + assert!( + request.contains("authorization: Bearer token-under-test"), + "{request}" + ); + assert!( + request.contains("accept: application/vnd.github.raw+json"), + "{request}" + ); + assert_eq!(fetched.body, "# Procedure\n\nAttach the PR.\n"); + let mut hasher = Sha256::new(); + hasher.update(&raw); + assert_eq!( + fetched.record, + crate::opensymphony_devin::DevinRepositoryInstructions { + path: "WORKFLOW.md".into(), + git_ref: "main".into(), + content_hash: format!("sha256:{:x}", hasher.finalize()), + bytes: raw.len() as u64, + } + ); + } + + #[tokio::test] + async fn devin_repository_instructions_fetch_failures_are_launch_errors() { + let (origin, server) = serve_one_response("404 Not Found", "", b"{}".to_vec()).await; + let repository = devin_instruction_repository("WORKFLOW.md"); + let url = devin_instructions_contents_url( + &origin, + "trilogy-group", + "StackPerf", + &repository.instructions_path, + &repository.target_branch, + ) + .expect("contents url"); + let error = fetch_devin_repository_instructions_at(&repository, url, None) + .await + .expect_err("missing instruction file fails the launch"); + let request = server.await.expect("server task"); + assert!(!request.contains("authorization:"), "{request}"); + assert!(error.contains("HTTP 404"), "{error}"); + + let oversized = vec![b'#'; (MAX_INSTRUCTION_FILE_BYTES + 1) as usize]; + let (origin, server) = serve_one_response("200 OK", "", oversized).await; + let url = devin_instructions_contents_url( + &origin, + "trilogy-group", + "StackPerf", + &repository.instructions_path, + &repository.target_branch, + ) + .expect("contents url"); + let error = fetch_devin_repository_instructions_at(&repository, url, None) + .await + .expect_err("oversized instructions are rejected"); + server.abort(); + assert!(error.contains("exceed"), "{error}"); + + let unconfigured = devin_instruction_repository(""); + assert!( + fetch_devin_repository_instructions(&unconfigured) + .await + .expect("no instruction file configured") + .is_none() + ); + } + + #[test] + fn devin_task_prompt_renders_repository_instructions() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workflow = sample_workflow(tempdir.path(), &tempdir.path().join("workspace-root")); + let issue = sample_issue(); + let route = devin_test_route(false); + + let without = devin_task_prompt(&workflow, &issue, 1, &route, None) + .expect("prompt without instructions"); + assert!(without.contains("No repository-specific instructions were selected.")); + + let with = devin_task_prompt( + &workflow, + &issue, + 2, + &route, + Some("# Procedure\n\nAttach the PR URL to the Linear issue.\n"), + ) + .expect("prompt with instructions"); + assert!(with.contains("## Repository Instructions\n\n# Procedure\n\nAttach the PR URL")); + assert!(!with.contains("No repository-specific instructions were selected.")); + assert!(with.contains("Attempt: 2")); + } + fn devin_test_route(dry_run: bool) -> crate::opensymphony_orchestrator::HarnessRouteDecision { crate::opensymphony_orchestrator::HarnessRouteDecision { task_type: "issue_execution".into(), diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 02a7a5403..4fafbe0e1 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -2343,6 +2343,20 @@ pub struct DevinSkippedAttachment { pub reason: String, } +/// Repository instructions rendered into the session prompt, fetched from the +/// repository remote because the evidence-only issue workspace has no checkout. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinRepositoryInstructions { + /// Repository-relative path of the instruction file. + pub path: String, + /// Git ref the file was read from (the configured target branch). + pub git_ref: String, + /// `sha256:` over the raw file bytes before any front matter is removed. + pub content_hash: String, + /// Size of the raw file. + pub bytes: u64, +} + /// Index of everything written into the local evidence directory for a run. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DevinEvidenceManifest { @@ -2364,6 +2378,10 @@ pub struct DevinEvidenceManifest { pub skipped_attachments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub attachment_listing_error: Option, + /// Repository instructions the session prompt carried, when the + /// repository configures an instruction file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_instructions: Option, /// Absolute path of the evidence root inside the local issue workspace. pub evidence_root: PathBuf, pub containment: String, @@ -2380,6 +2398,7 @@ pub struct DevinEvidenceCollector { root: PathBuf, events: Vec, limits: DevinEvidenceLimits, + repository_instructions: Option, } impl DevinEvidenceCollector { @@ -2388,9 +2407,18 @@ impl DevinEvidenceCollector { root: root.into(), events: Vec::new(), limits, + repository_instructions: None, } } + /// Records which repository instructions the session prompt carried. + pub fn set_repository_instructions( + &mut self, + instructions: Option, + ) { + self.repository_instructions = instructions; + } + pub fn root(&self) -> &Path { &self.root } @@ -2424,6 +2452,7 @@ impl DevinEvidenceCollector { "structured_output": report.structured_output, "attachments": report.attachments, "attachment_listing_error": report.attachment_listing_error, + "repository_instructions": self.repository_instructions, "containment": DEVIN_REMOTE_CONTAINMENT, }); write_file(&self.root.join("session.json"), pretty(&summary).as_bytes()).await?; @@ -2458,6 +2487,7 @@ impl DevinEvidenceCollector { attachments, skipped_attachments: skipped, attachment_listing_error: report.attachment_listing_error.clone(), + repository_instructions: self.repository_instructions.clone(), evidence_root: self.root.clone(), containment: DEVIN_REMOTE_CONTAINMENT.to_owned(), }; diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index 2cb3aed62..bfcc6f200 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -11,8 +11,8 @@ pub use environment::environment_variable_names_equal; pub(crate) use environment::{has_environment_name_collision, insert_environment_value}; pub use error::{WorkspaceError, WorkspaceOwnershipConflictDetails}; pub use manager::{ - WorkspaceManager, compose_parent_continuation_prompt, compose_parent_prompt, - compose_terminal_prompt, + MAX_INSTRUCTION_FILE_BYTES, WorkspaceManager, compose_parent_continuation_prompt, + compose_parent_prompt, compose_terminal_prompt, instruction_body, }; pub use models::{ AcpProcessState, AcpRecovery, AcpRunRoute, AcpSessionIdentity, AcpSessionState, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index 0b3a53fd4..9aa6430f1 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -49,7 +49,7 @@ use super::{ }; use crate::opensymphony_domain::{RepositoryBinding, SafeRemoteFingerprint}; -const MAX_INSTRUCTION_FILE_BYTES: u64 = 1024 * 1024; +pub const MAX_INSTRUCTION_FILE_BYTES: u64 = 1024 * 1024; const MAX_TOTAL_INSTRUCTION_BYTES: u64 = 4 * 1024 * 1024; #[derive(Clone)] @@ -7713,6 +7713,16 @@ async fn read_bounded_instruction_file( Ok((format!("sha256:{:x}", hasher.finalize()), contents)) } +/// Instruction text to present to a harness for `path`: `WORKFLOW.md` keeps +/// only its body, every other instruction file is used verbatim. +pub fn instruction_body(path: &Path, bytes: &[u8]) -> Vec { + if is_workflow_instruction_path(path) { + workflow_body(bytes) + } else { + bytes.to_vec() + } +} + fn workflow_body(bytes: &[u8]) -> Vec { let text = String::from_utf8_lossy(bytes); let Some(rest) = text.strip_prefix("---") else { diff --git a/docs/configuration.md b/docs/configuration.md index 64f840f03..6cbed2bff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -937,21 +937,41 @@ replies. 2. Resolves `secret_ids` against that organization's Devin secrets, by secret id or key. Unknown or out-of-organization references fail the run; secret *values* are never read by OpenSymphony — Devin injects them remotely. -3. Creates the session with the rendered issue prompt, repository, tags, and - session options, and reports the session URL as the run conversation link. -4. Polls session status and cursor-paginated messages, publishing normalized +3. Fetches the repository's configured instruction file (`instructions.path`, + usually `WORKFLOW.md`) from the GitHub remote at the target branch through + the contents API, using the repository's checkout credential. The evidence-only + issue workspace has no checkout to read it from, yet Devin must see the same + repository procedure a local harness gets — including the agent-side step + that attaches the PR to the Linear issue. `WORKFLOW.md` front matter is + stripped exactly as for local checkouts, the file is bounded to 1 MiB, and a + configured file that cannot be fetched fails the launch (retryable) rather + than starting a session without instructions. Non-GitHub providers cannot + supply instructions to Devin yet. +4. Creates the session with the rendered issue prompt (central procedure, task + facts, repository instructions), repository, tags, and session options, and + reports the session URL as the run conversation link. +5. Polls session status and cursor-paginated messages, publishing normalized runtime events onto the run timeline. -5. On settlement, imports evidence into the issue workspace under +6. On settlement, imports evidence into the issue workspace under `.opensymphony/devin/`: `session.json`, `events.jsonl`, `evidence.json` (outcome, status, ACU usage, pull-request URLs, structured - output), and downloaded attachments. `pull_request_urls` lists the pull + output, and `repository_instructions` — the instruction path, git ref, + SHA-256 and size the prompt carried), and downloaded attachments. `pull_request_urls` lists the pull requests the Devin API reports; `message_pull_request_urls` lists any further `/pull/` URLs Devin mentioned in its messages (for example when it opened the PR outside its built-in PR tool), so operators can find the PR without reading `events.jsonl`. -6. Terminates and archives the remote session on cancellation, poll timeout, or +7. Terminates and archives the remote session on cancellation, poll timeout, or transport failure, so an abandoned run cannot keep burning ACUs. +Repository workflows that end with agent-side tracker writes (the repo-local +`linear` skill attaching the PR URL to the issue) and `git push` need their +credentials inside Devin's workspace. Reference them through +`devin.session.secret_ids` (for example `[LINEAR_API_KEY, GH_TOKEN_TG]`, +matching the keys of the organization's Devin secrets); without them the +session can open a PR through Devin's own GitHub integration but cannot run the +repository's Linear helper, and the PR never appears on the issue. + The local issue workspace is evidence and manifest storage only; it is never Devin's execution directory, and no local checkout is prepared for the run. diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index d22288f92..23ff9555c 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -152,6 +152,19 @@ cleanup also skips checkout hooks after a restart. Recovery compares the repository identity against the live tracker binding and retains the evidence for a remote session whose termination is unconfirmed. +Repository instructions still reach the prompt. Because there is no local +checkout to read `instructions.path` from, the route fetches the configured file +from the GitHub remote at the repository's target branch (`GET +/repos/{owner}/{repo}/contents/{path}?ref=` with the checkout +credential), strips `WORKFLOW.md` front matter through the shared +`instruction_body` helper, bounds it to `MAX_INSTRUCTION_FILE_BYTES`, renders it +into the `## Repository Instructions` section of `compose_terminal_prompt`, and +records path, ref, SHA-256 and size as `repository_instructions` in +`evidence.json`. A configured file that cannot be fetched is a retryable launch +failure, matching the local route. The repository's agent-side steps (attaching +the PR to the Linear issue, pushing) run inside Devin, so the secrets they need +must be referenced through `devin.session.secret_ids`. + A `--dry-run` route settles before any credential resolution or session creation, so it neither contacts the API nor consumes ACUs; it writes the local run manifest and no conversation binding. From d83664876ef7193a70e231c73bc150ca93e03efc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:06:25 +0000 Subject: [PATCH 12/13] fix(devin): publish heartbeats so a busy session is not aborted as a stall Summary: - `DevinSessionRunner::follow` emits a `session.heartbeat` liveness event when an executing session has produced no message or status change for `DevinRunOptions::heartbeat_interval` (default 60s); the route derives that interval from the scheduler stall timeout (`stall / 3`, capped at 60s) so it always lands inside the stall window. - `SessionResponse::is_executing` names the liveness condition (not settled, not waiting on the operator); `heartbeat_event` carries the status, status detail, ACUs and `updated_at` of the snapshot. - `DevinEvidenceCollector::record` keeps heartbeats out of `events.jsonl`; they are orchestrator observations, not remote events. - Docs and AGENTS.md record the rule; the live suite's option literals use `..DevinRunOptions::default()`. Rationale: - The first real StackPerf dispatch (COE-373) was aborted by the scheduler with `scheduler stall timeout reached` (`agent.stall_timeout_ms` default 300000) about five minutes after Devin posted its plan: Devin reports no message or status change while it implements, so the polling route went silent and the stall policy terminated the session and created a replacement, which failed the same way. The stall policy is activity based (`observe_runtime_event`), so the fix is to make genuine remote execution observable as activity rather than to disable stall detection for the harness. Tests: - cargo fmt --check; git diff --check - cargo clippy --all-targets -- -D warnings - cargo test --test devin_cloud_agent (25 passed, incl. quiet_executing_sessions_publish_heartbeats_outside_the_evidence_journal) - cargo test --lib -- devin_run_options (2 passed) Co-Authored-By: Leonardo Gonzalez --- AGENTS.md | 4 + .../src/orchestrator_run/backends.rs | 40 +++++++-- crates/opensymphony-devin/src/lib.rs | 83 +++++++++++++++++-- docs/configuration.md | 8 ++ docs/harness-adapter-compatibility.md | 7 +- tests/devin_cloud_agent.rs | 65 ++++++++++++--- tests/devin_cloud_agent_live.rs | 2 + 7 files changed, 186 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 49f54c5b5..8fe751f2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,6 +215,10 @@ Devin hardening rules that must hold for any change to this harness: just a configured organization id; a failed `create_session` is reconciled through the correlation tag before it is retried. - Parent issues with sub-issues are not routed to remote-only harnesses. +- A quiet, executing session is not a stalled one: the poll loop publishes a + `session.heartbeat` liveness event inside the scheduler stall window so a + long Devin step is never aborted and replaced as a stall. Heartbeats are + orchestrator observations and stay out of the evidence journal. Changes to the route or the client must keep the opt-in live suite (`OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index d3211a478..994e6386a 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -25,10 +25,10 @@ use crate::opensymphony_codex::{ turn_status, }; use crate::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_EVIDENCE_DIR_NAME, DevinCloudClient, - DevinCloudConfig, DevinEvidenceCollector, DevinEvidenceLimits, DevinMode, - DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, DevinRunReport, - DevinSessionOptions, DevinSessionRunner, DevinStopOutcome, devin_event_payload, + DEFAULT_DEVIN_HEARTBEAT_INTERVAL, DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, + DEVIN_EVIDENCE_DIR_NAME, DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, + DevinEvidenceLimits, DevinMode, DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, + DevinRunReport, DevinSessionOptions, DevinSessionRunner, DevinStopOutcome, devin_event_payload, devin_event_summary, session_create_request, session_created_event, status_event, }; use crate::opensymphony_domain::{ @@ -11020,7 +11020,10 @@ async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result DevinRunOptions { +/// +/// Devin emits no message or status change while it works through a long +/// step, so the runner publishes a liveness event for an executing session +/// well inside the scheduler's stall window; otherwise that silence would be +/// aborted as a stalled worker and replaced with a fresh session. +fn devin_run_options(stall_timeout_ms: Option) -> DevinRunOptions { + let heartbeat_interval = match stall_timeout_ms { + Some(stall) => Duration::from_millis(stall / 3).min(DEFAULT_DEVIN_HEARTBEAT_INTERVAL), + None => DEFAULT_DEVIN_HEARTBEAT_INTERVAL, + }; DevinRunOptions { stop_when_waiting_on_operator: true, max_duration: None, collect_attachments: true, message_page_size: 100, + heartbeat_interval: Some(heartbeat_interval.max(Duration::from_secs(1))), } } @@ -16326,10 +16339,23 @@ mod tests { #[test] fn devin_run_options_settle_when_devin_waits_on_its_operator() { - let options = devin_run_options(); + let options = devin_run_options(None); assert!(options.stop_when_waiting_on_operator); assert!(options.collect_attachments); assert_eq!(options.max_duration, None); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(60))); + } + + #[test] + fn devin_run_options_heartbeat_fits_inside_the_stall_window() { + let options = devin_run_options(Some(300_000)); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(60))); + + let options = devin_run_options(Some(30_000)); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(10))); + + let options = devin_run_options(Some(1_000)); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(1))); } #[test] diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs index 4fafbe0e1..235477ee5 100644 --- a/crates/opensymphony-devin/src/lib.rs +++ b/crates/opensymphony-devin/src/lib.rs @@ -673,6 +673,11 @@ impl SessionResponse { .as_ref() .is_some_and(DevinStatusDetail::is_inactivity) } + + /// Devin is still executing: not settled and not parked on its operator. + pub fn is_executing(&self) -> bool { + !self.is_settled() && !self.is_waiting_on_operator() + } } /// `SessionMessage` from the Devin v3 OpenAPI document. @@ -1757,6 +1762,9 @@ pub enum NormalizedDevinEventKind { SessionFailed, SessionSuspended, SessionTerminated, + /// Orchestrator-side liveness observation: the polled session is still + /// executing but produced no new message or status since the last event. + SessionHeartbeat, Unknown, } @@ -1778,6 +1786,10 @@ pub struct NormalizedDevinEvent { /// with the full raw JSON so future Devin schema additions replay unchanged. /// `event_type` of a normalized message authored by Devin itself. pub const DEVIN_MESSAGE_EVENT_KIND: &str = "session.message.devin"; +/// `event_type` of the liveness event emitted while a quiet session executes. +pub const DEVIN_HEARTBEAT_EVENT_KIND: &str = "session.heartbeat"; +/// Default spacing between liveness events for a quiet, executing session. +pub const DEFAULT_DEVIN_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); pub fn normalize_session_message( session_id: &str, @@ -1874,6 +1886,35 @@ pub fn session_created_event(session: &SessionResponse) -> NormalizedDevinEvent } } +/// Liveness event for a session that keeps executing without new output. +/// +/// Devin reports neither a message nor a status change while it works through +/// a long tool call, so a polling observer would otherwise go silent for as +/// long as the task takes; the scheduler's activity-based stall policy would +/// read that silence as a hung worker. The raw payload is the session snapshot +/// the observation was made from, not a Devin-authored event. +pub fn heartbeat_event(session: &SessionResponse) -> NormalizedDevinEvent { + let status = match session.status_detail.as_ref() { + Some(detail) => format!("{}:{}", session.status.as_str(), detail.as_str()), + None => session.status.as_str().to_owned(), + }; + NormalizedDevinEvent { + kind: NormalizedDevinEventKind::SessionHeartbeat, + event_type: DEVIN_HEARTBEAT_EVENT_KIND.into(), + event_id: None, + session_id: Some(session.session_id.clone()), + timestamp: Some(session.updated_at.to_string()), + message: None, + status: Some(status), + raw: json!({ + "status": session.status.as_str(), + "status_detail": session.status_detail.as_ref().map(DevinStatusDetail::as_str), + "acus_consumed": session.acus_consumed, + "updated_at": session.updated_at, + }), + } +} + /// Tracks the message cursor and last observed status for one polled session. /// /// Messages are paginated by `end_cursor`, but the cursor is only advanced @@ -1955,6 +1996,10 @@ pub fn devin_event_summary(event: &NormalizedDevinEvent) -> String { NormalizedDevinEventKind::SessionFailed => "Devin session errored".into(), NormalizedDevinEventKind::SessionSuspended => "Devin session was suspended".into(), NormalizedDevinEventKind::SessionTerminated => "Devin session terminated".into(), + NormalizedDevinEventKind::SessionHeartbeat => match event.status.as_deref() { + Some(status) => format!("Devin session still executing ({status})"), + None => "Devin session still executing".into(), + }, NormalizedDevinEventKind::Unknown => { format!("Unknown Devin event `{}`", event.event_type) } @@ -2101,6 +2146,9 @@ pub struct DevinRunOptions { pub collect_attachments: bool, /// Page size for message polling. pub message_page_size: u32, + /// Emit a [`heartbeat_event`] when an executing session has produced no + /// event for this long; `None` disables liveness events. + pub heartbeat_interval: Option, } impl Default for DevinRunOptions { @@ -2110,6 +2158,7 @@ impl Default for DevinRunOptions { max_duration: None, collect_attachments: true, message_page_size: 100, + heartbeat_interval: Some(DEFAULT_DEVIN_HEARTBEAT_INTERVAL), } } } @@ -2153,20 +2202,37 @@ impl DevinSessionRunner { mut observer: impl FnMut(NormalizedDevinEvent), ) -> Result { let started = Instant::now(); + let mut last_event_at = started; let mut cursor = DevinMessageCursor::new(); loop { let session = self.client.get_session(devin_id).await?; - self.drain_messages(devin_id, &mut cursor, &mut observer) + let drained = self + .drain_messages(devin_id, &mut cursor, &mut observer) .await?; - if let Some(event) = cursor.ingest_status(&session) { - observer(event); + let status_changed = match cursor.ingest_status(&session) { + Some(event) => { + observer(event); + true + } + None => false, + }; + if drained > 0 || status_changed { + last_event_at = Instant::now(); } if let Some(outcome) = self.settled_outcome(&session) { return self.report(session, outcome).await; } + if let Some(interval) = self.options.heartbeat_interval + && session.is_executing() + && last_event_at.elapsed() >= interval + { + observer(heartbeat_event(&session)); + last_event_at = Instant::now(); + } + if let Some(max_duration) = self.options.max_duration && started.elapsed() >= max_duration { @@ -2233,7 +2299,8 @@ impl DevinSessionRunner { devin_id: &str, cursor: &mut DevinMessageCursor, observer: &mut impl FnMut(NormalizedDevinEvent), - ) -> Result<(), DevinClientError> { + ) -> Result { + let mut emitted = 0; loop { let page = self .client @@ -2245,12 +2312,13 @@ impl DevinSessionRunner { .await?; let previous = cursor.cursor().map(str::to_owned); for event in cursor.ingest_messages(devin_id, &page) { + emitted += 1; observer(event); } // A page that reports more data but does not advance the cursor // would otherwise be requested forever. if !page.has_next_page || cursor.cursor().map(str::to_owned) == previous { - return Ok(()); + return Ok(emitted); } } } @@ -2428,7 +2496,12 @@ impl DevinEvidenceCollector { } /// Records one normalized event for the journal written at settle time. + /// Heartbeats are orchestrator-side observations, not remote events, and + /// stay out of the journal. pub fn record(&mut self, event: &NormalizedDevinEvent) { + if event.kind == NormalizedDevinEventKind::SessionHeartbeat { + return; + } self.events.push(devin_event_payload(event)); } diff --git a/docs/configuration.md b/docs/configuration.md index 6cbed2bff..712cd76df 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -993,6 +993,14 @@ than interrupting a turn, pause/resume and approvals are unavailable, the model is fixed at session creation through `devin_mode`, and TLS certificate pinning is not implemented. +Devin reports no message or status change while it works through a long step, +so the route publishes a `session.heartbeat` runtime event for a session that is +still executing whenever it has been quiet for one third of +`scheduler.stall_timeout_ms` (at most every 60 seconds). The scheduler's +activity-based stall policy therefore applies to a Devin session that has +genuinely stopped reporting, not to one that is simply busy; heartbeats appear +on the run timeline but are not written to the `events.jsonl` evidence journal. + ## Runtime Config `opensymphony init` also copies a starter `config.yaml` next to the target diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 23ff9555c..a4dffa2c1 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -337,7 +337,12 @@ capability to `available: true`: Remaining limitations (advertised as capability `feature_gaps`, not blockers): - Runtime events arrive by cursor-paginated HTTPS polling; there is no push - stream, so event latency is bounded by `poll_interval_ms`. + stream, so event latency is bounded by `poll_interval_ms`. While an + executing session produces no new message or status the route publishes a + `session.heartbeat` liveness event (every third of the scheduler stall + timeout, at most once a minute) so the stall policy does not abort and + replace a session that is merely busy; heartbeats are scheduler activity, + not journaled evidence. - There is no mid-run interrupt in the v3 contract: cancellation terminates and archives the session rather than stopping one turn. - Pause/resume and approval flows are not exposed by the v3 session contract. diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs index 49002e62b..32e8b42a4 100644 --- a/tests/devin_cloud_agent.rs +++ b/tests/devin_cloud_agent.rs @@ -9,19 +9,20 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use opensymphony::opensymphony_devin::{ - DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, DEVIN_MAX_OPERATOR_TAGS, - DEVIN_MAX_SESSION_TAGS, DEVIN_REMOTE_CONTAINMENT, DEVIN_UNDISCRIMINATED_EVENT_KIND, - DevinClientError, DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, - DevinEvidenceCollector, DevinEvidenceLimits, DevinHttpMethod, DevinMessageCursor, DevinMode, - DevinOperation, DevinProblemDetail, DevinRemoteWorkspaceBinding, DevinRequest, - DevinRequestBuilder, DevinRunOutcome, DevinRunReport, DevinSelfResponse, DevinSessionOptions, + DEFAULT_DEVIN_HEARTBEAT_INTERVAL, DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, + DEVIN_HEARTBEAT_EVENT_KIND, DEVIN_MAX_OPERATOR_TAGS, DEVIN_MAX_SESSION_TAGS, + DEVIN_REMOTE_CONTAINMENT, DEVIN_UNDISCRIMINATED_EVENT_KIND, DevinClientError, + DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, + DevinEvidenceLimits, DevinHttpMethod, DevinMessageCursor, DevinMode, DevinOperation, + DevinProblemDetail, DevinRemoteWorkspaceBinding, DevinRequest, DevinRequestBuilder, + DevinRunOptions, DevinRunOutcome, DevinRunReport, DevinSelfResponse, DevinSessionOptions, DevinSessionStatus, DevinStatusDetail, DevinStopOutcome, DevinTenancy, NormalizedDevinEventKind, PaginatedResponse, SecretResponse, SessionAttachment, SessionMessage, SessionResponse, SessionsQueryParams, attachment_download_url, devin_event_summary, - ensure_session_tenant, evidence_file_name, normalize_devin_event, normalize_session_message, - normalized_event_to_journal_record, pull_request_urls_in_text, reconcile_tenant, - rejected_termination_outcome, resolve_secret_references, select_reconciled_session, - session_create_request, session_created_event, + ensure_session_tenant, evidence_file_name, heartbeat_event, normalize_devin_event, + normalize_session_message, normalized_event_to_journal_record, pull_request_urls_in_text, + reconcile_tenant, rejected_termination_outcome, resolve_secret_references, + select_reconciled_session, session_create_request, session_created_event, }; use opensymphony::opensymphony_domain::HarnessAdapter; use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; @@ -1170,6 +1171,50 @@ fn rejected_termination_counts_as_stopped_only_for_terminal_sessions() { assert_eq!(rejected_termination_outcome(&session("new", None)), None); } +#[test] +fn quiet_executing_sessions_publish_heartbeats_outside_the_evidence_journal() { + // Only a session that is still doing work is a liveness candidate; settled + // or operator-parked sessions settle the run instead. + assert!(session("running", Some("working")).is_executing()); + assert!(session("claimed", None).is_executing()); + assert!(!session("running", Some("finished")).is_executing()); + assert!(!session("running", Some("waiting_for_user")).is_executing()); + assert!(!session("suspended", Some("inactivity")).is_executing()); + assert!(!session("exit", None).is_executing()); + + let event = heartbeat_event(&session("running", Some("working"))); + assert_eq!(event.kind, NormalizedDevinEventKind::SessionHeartbeat); + assert_eq!(event.event_type, DEVIN_HEARTBEAT_EVENT_KIND); + assert_eq!(event.session_id.as_deref(), Some("devin-1")); + assert_eq!(event.status.as_deref(), Some("running:working")); + assert_eq!(event.raw["acus_consumed"], json!(1.5)); + assert_eq!(event.raw["status_detail"], json!("working")); + assert_eq!( + devin_event_summary(&event), + "Devin session still executing (running:working)" + ); + let record = normalized_event_to_journal_record("run-1", 3, &event); + assert_eq!( + record.kind, + EventKind::HarnessEventNormalized { + source_kind: DEVIN_HEARTBEAT_EVENT_KIND.to_owned(), + } + ); + + // Heartbeats are orchestrator observations, not remote events. + let temp = tempfile::tempdir().expect("tempdir"); + let mut collector = + DevinEvidenceCollector::new(temp.path().join("evidence"), DevinEvidenceLimits::default()); + collector.record(&session_created_event(&session("running", Some("working")))); + collector.record(&event); + assert_eq!(collector.event_count(), 1); + + assert_eq!( + DevinRunOptions::default().heartbeat_interval, + Some(DEFAULT_DEVIN_HEARTBEAT_INTERVAL) + ); +} + #[test] fn archived_exit_sessions_decode_as_closed_rather_than_finished() { let mut value = session_json(); diff --git a/tests/devin_cloud_agent_live.rs b/tests/devin_cloud_agent_live.rs index d28aaa4a4..9ef7186b3 100644 --- a/tests/devin_cloud_agent_live.rs +++ b/tests/devin_cloud_agent_live.rs @@ -139,6 +139,7 @@ async fn devin_live_session_lifecycle() { max_duration: Some(Duration::from_secs(240)), collect_attachments: true, message_page_size: 100, + ..DevinRunOptions::default() }, ); @@ -244,6 +245,7 @@ async fn devin_live_poll_timeout_terminates_the_remote_session() { max_duration: Some(Duration::from_secs(10)), collect_attachments: false, message_page_size: 100, + ..DevinRunOptions::default() }, ); From 2d355e6e1e82d6391f367865a60f447c8a405836 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:35:08 +0000 Subject: [PATCH 13/13] fix(devin): let terminated route tasks settle before the worker is aborted Summary: - `abort_worker` and `shutdown` still terminate the tracked Devin sessions first, but a worker that owns a Devin session now gets a bounded window (`DEVIN_SETTLE_TIMEOUT`, 30s) in which its route task observes the archived session, imports the evidence, and finishes `run.json` as cancelled; only a task that does not settle in time is aborted. - A route task that panics while settling is reported through the normal `WorkerUpdate::Finished` boundary (failed, harness stopped), mirroring the ACP abort path, so `poll_updates` never joins a consumed handle. - `settle_devin_tasks` takes the settle budget as a parameter so the unit test covers the settled, wedged, and panicking cases without waiting out the production timeout. Rationale: - The first real StackPerf dispatch (COE-373) ended with Devin moving the issue to `Human Review` after attaching PR #53; the scheduler released the worker as tracker-inactive, the backend terminated the session and immediately aborted the task, so the evidence-only workspace was left with `run.json` still `running` and no imported evidence for a run that had in fact produced a PR. Letting the route see the termination it was handed is cheaper and more faithful than finalizing the manifest from the abort path, which would have to duplicate the settle logic. Tests: - cargo fmt --check; git diff --check - cargo clippy --all-targets -- -D warnings - cargo test --lib -- settling_terminated_devin_tasks (1 passed) - cargo test --lib -- devin (24 passed) - cargo test --test devin_cloud_agent (25 passed) - cargo test --test run (9 passed, 4 ignored) Co-Authored-By: Leonardo Gonzalez --- AGENTS.md | 5 + .../src/orchestrator_run/backends.rs | 193 +++++++++++++++++- docs/harness-adapter-compatibility.md | 9 +- 3 files changed, 197 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fe751f2e..bc7321e83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,11 @@ Devin hardening rules that must hold for any change to this harness: route identity (`devin-route.json`) are the durable fence, and a bound session is stopped through *its own* persisted origin/organization before any replacement is created. +- Scheduler abort and daemon shutdown terminate the session first and then + give the route task a bounded window to observe the archived session, + import its evidence, and finish `run.json`; the task is aborted only if it + does not settle in time. Aborting first leaves a `running` manifest with no + evidence for a run the tracker already moved on from. - Organization-scoped requests require verified tenancy (`bind_tenant`), not just a configured organization id; a failed `create_session` is reconciled through the correlation tag before it is retried. diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 994e6386a..799d7b4db 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -541,6 +541,9 @@ struct TrackedDevinSession { /// Upper bound for one awaited remote termination during abort or shutdown. const DEVIN_TERMINATION_TIMEOUT: Duration = Duration::from_secs(30); +/// Upper bound for letting terminated Devin route tasks settle (observe the +/// archived session, import evidence, finish the run manifest) before abort. +const DEVIN_SETTLE_TIMEOUT: Duration = Duration::from_secs(30); /// Clock skew tolerated when matching a reconciled session's `created_at` /// against the local creation attempt. const DEVIN_CREATION_CLOCK_SKEW_SECS: i64 = 120; @@ -5892,6 +5895,81 @@ impl RuntimeWorkerBackend { } } + fn owns_devin_session(&self, worker_id: &str) -> bool { + self.devin_sessions + .lock() + .map(|sessions| { + sessions + .values() + .any(|tracked| tracked.worker_id == worker_id) + }) + .unwrap_or(false) + } + + fn owns_devin_session_any(&self) -> bool { + self.devin_sessions + .lock() + .map(|sessions| !sessions.is_empty()) + .unwrap_or(false) + } + + /// Lets the Devin route tasks whose sessions were just terminated observe + /// the archived session themselves: the next poll settles it, imports the + /// evidence, and finishes the run manifest. Aborting the task straight + /// away would leave `run.json` as `running` with no evidence. Bounded, so + /// a wedged poll still falls through to the task abort; a task that + /// panicked is reported through the normal worker message boundary. + async fn settle_devin_tasks(&mut self, worker_id: Option<&str>, settle_timeout: Duration) { + let worker_ids: Vec = self + .tasks + .keys() + .filter(|id| worker_id.is_none_or(|worker_id| worker_id == id.as_str())) + .cloned() + .collect(); + let deadline = tokio::time::Instant::now() + settle_timeout; + for id in worker_ids { + let Some(mut task) = self.tasks.remove(&id) else { + continue; + }; + match timeout(deadline - tokio::time::Instant::now(), &mut task.handle).await { + Ok(Ok(())) => { + self.worker_issue_ids.remove(&id); + } + Ok(Err(error)) => { + tracing::warn!( + worker_id = %id, + error = %error, + "devin worker task failed while settling its terminated session" + ); + self.worker_issue_ids.remove(&id); + let Ok(worker) = crate::opensymphony_domain::WorkerId::new(&id) else { + continue; + }; + let _ = self.updates_tx.send(WorkerUpdate::Finished { + worker_id: worker, + outcome: WorkerOutcomeRecord::from_run( + &task.run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some( + "devin worker task failed while settling after termination".into(), + ), + Some(error.to_string()), + ) + .with_harness_stopped(), + }); + } + Err(_) => { + tracing::warn!( + worker_id = %id, + "devin worker did not settle its terminated session in time; aborting its task" + ); + self.tasks.insert(id, task); + } + } + } + } + fn abort_all_tracked_tasks(&mut self) { self.worker_issue_ids.clear(); let active_count = self.tasks.len(); @@ -12092,8 +12170,22 @@ impl WorkerBackend for RuntimeWorkerBackend { worker_id: &crate::opensymphony_domain::WorkerId, reason: WorkerAbortReason, ) -> Result<(), Self::Error> { + let issue_identifier = self + .worker_issue_ids + .get(worker_id.as_str()) + .cloned() + .or_else(|| { + self.tasks + .get(worker_id.as_str()) + .map(|task| task.run.issue_identifier.to_string()) + }); + let owns_devin_session = self.owns_devin_session(worker_id.as_str()); self.terminate_tracked_devin_sessions(Some(worker_id.as_str())) .await; + if owns_devin_session { + self.settle_devin_tasks(Some(worker_id.as_str()), DEVIN_SETTLE_TIMEOUT) + .await; + } let acp_session = self .acp_active .lock() @@ -12157,14 +12249,7 @@ impl WorkerBackend for RuntimeWorkerBackend { } } } - let issue_identifier = self - .worker_issue_ids - .remove(worker_id.as_str()) - .or_else(|| { - self.tasks - .get(worker_id.as_str()) - .map(|task| task.run.issue_identifier.to_string()) - }); + self.worker_issue_ids.remove(worker_id.as_str()); let revoke_after_stop = matches!( reason, WorkerAbortReason::TrackerInactive @@ -12188,7 +12273,11 @@ impl WorkerBackend for RuntimeWorkerBackend { } async fn shutdown(&mut self) { + let owns_devin_sessions = self.owns_devin_session_any(); self.terminate_tracked_devin_sessions(None).await; + if owns_devin_sessions { + self.settle_devin_tasks(None, DEVIN_SETTLE_TIMEOUT).await; + } self.abort_all_tracked_tasks(); } @@ -16597,6 +16686,94 @@ mod tests { assert!(backend.worker_issue_ids.is_empty()); } + #[tokio::test] + async fn settling_terminated_devin_tasks_waits_for_settled_runs_and_abandons_wedged_ones() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = Arc::new(sample_workflow(tempdir.path(), &workspace_root)); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut backend = RuntimeWorkerBackend::new( + OpenHandsClient::new(TransportConfig::new("http://127.0.0.1:1")), + workflow, + workspace_manager, + None, + BTreeMap::new(), + ); + let workspace = sample_workspace(&workspace_root); + let mut track = |worker_id: &str, handle: JoinHandle<()>| { + let run = RunAttempt::new( + WorkerId::new(worker_id).expect("worker id should be valid"), + IssueId::new(format!("issue-{worker_id}")).expect("issue id should be valid"), + IssueIdentifier::new("COE-373").expect("issue identifier should be valid"), + workspace.path.clone(), + TimestampMs::new(1), + None, + 8, + ); + backend + .worker_issue_ids + .insert(worker_id.to_string(), run.issue_identifier.to_string()); + backend + .tasks + .insert(worker_id.to_string(), ActiveWorkerTask { handle, run }); + }; + // A route that observes the archived session on its next poll and + // finishes its manifest after a short delay. + track( + "devin-settles", + tokio::spawn(async { tokio::time::sleep(Duration::from_millis(50)).await }), + ); + // A route whose poll is wedged for longer than the settle budget. + track( + "devin-wedged", + tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }), + ); + // A route task that panics while settling. + track( + "devin-panics", + tokio::spawn(async { panic!("settle panic") }), + ); + // Only the named worker is settled. + track( + "devin-other", + tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }), + ); + + backend + .settle_devin_tasks(Some("devin-settles"), Duration::from_millis(500)) + .await; + assert!(!backend.tasks.contains_key("devin-settles")); + assert!(!backend.worker_issue_ids.contains_key("devin-settles")); + assert!(backend.tasks.contains_key("devin-other")); + + let started = tokio::time::Instant::now(); + backend + .settle_devin_tasks(Some("devin-wedged"), Duration::from_millis(200)) + .await; + assert!(started.elapsed() < Duration::from_secs(5)); + assert!( + backend.tasks.contains_key("devin-wedged"), + "a wedged task stays tracked so the abort path can drop it" + ); + assert!(backend.worker_issue_ids.contains_key("devin-wedged")); + + backend + .settle_devin_tasks(Some("devin-panics"), Duration::from_millis(500)) + .await; + assert!(!backend.tasks.contains_key("devin-panics")); + assert!(!backend.worker_issue_ids.contains_key("devin-panics")); + let updates = backend.poll_updates().await.expect("poll updates"); + assert!(updates.iter().any(|update| matches!(update, + WorkerUpdate::Finished { worker_id, outcome } if worker_id.as_str() == "devin-panics" + && outcome.outcome == WorkerOutcomeKind::Failed && outcome.harness_stopped))); + + backend.abort_all_tracked_tasks(); + assert!(backend.tasks.is_empty()); + } + #[tokio::test] async fn recovered_worker_reuses_running_manifest_without_before_run_hook() { let tempdir = TempDir::new().expect("tempdir should exist"); diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index a4dffa2c1..096b02c1d 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -215,8 +215,13 @@ poll timeout and transport failure terminate and archive the session before the route returns, and a scheduler interrupt looks the session up in the live-session registry and deletes it. Worker abort and daemon shutdown (`WorkerBackend::abort_worker` / `WorkerBackend::shutdown`) terminate the -sessions tracked for the affected workers and *await* the acknowledgement -before the task is aborted; `DevinSessionGuard::drop` only makes an +sessions tracked for the affected workers, *await* the acknowledgement, and +then give the route task a bounded window (`DEVIN_SETTLE_TIMEOUT`, 30s) to +observe the archived session on its next poll, import the evidence, and finish +`run.json` as cancelled before the task is aborted — a tracker state change +such as `In Progress -> Human Review` releases the worker this way, and the +imported evidence is how the run stays inspectable afterwards; +`DevinSessionGuard::drop` only makes an opportunistic attempt, because a `Drop` cannot await and a task spawned during runtime teardown may never run. A scheduler interrupt that finds no tracked session — the normal state after a daemon restart — rebuilds a tenant-bound