feat: add durable backend job runner
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
//! Durable Backend-owned jobs executed by ordinary Runtime-managed Workers.
|
||||
//!
|
||||
//! A job is durable intent. An attempt binds that intent to one dedicated
|
||||
//! Worker, and a result is accepted only through the fenced structured-result
|
||||
//! API below. Runtime lifecycle and final prose are deliberately not success
|
||||
//! authority.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const BACKEND_JOB_RUNTIME_ID: &str = crate::hosts::EMBEDDED_RUNTIME_ID;
|
||||
pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8;
|
||||
pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64;
|
||||
pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120;
|
||||
pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600;
|
||||
pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024;
|
||||
pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024;
|
||||
pub const DEFAULT_MAX_ATTEMPTS: u8 = 2;
|
||||
pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3;
|
||||
pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024;
|
||||
pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024;
|
||||
pub const MAX_JOB_PURPOSE_BYTES: usize = 120;
|
||||
pub const MAX_JOB_REFERENCE_BYTES: usize = 512;
|
||||
pub const MAX_JOB_FAILURE_BYTES: usize = 1024;
|
||||
pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendJobState {
|
||||
Pending,
|
||||
Completed,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BackendJobState {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
other => Err(Error::Store(format!("unknown Backend Job state `{other}`"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendJobAttemptState {
|
||||
Reserved,
|
||||
Dispatched,
|
||||
Completed,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BackendJobAttemptState {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Reserved => "reserved",
|
||||
Self::Dispatched => "dispatched",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"reserved" => Ok(Self::Reserved),
|
||||
"dispatched" => Ok(Self::Dispatched),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
other => Err(Error::Store(format!(
|
||||
"unknown Backend Job attempt state `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminal(self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed | Self::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendJobDeliveryState {
|
||||
Pending,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl BackendJobDeliveryState {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
other => Err(Error::Store(format!(
|
||||
"unknown Backend Job delivery state `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendJobLimits {
|
||||
#[serde(default = "default_max_concurrent_jobs")]
|
||||
pub max_concurrent_jobs: u16,
|
||||
#[serde(default = "default_job_timeout_seconds")]
|
||||
pub timeout_seconds: u32,
|
||||
#[serde(default = "default_max_result_bytes")]
|
||||
pub max_result_bytes: u32,
|
||||
#[serde(default = "default_max_attempts")]
|
||||
pub max_attempts: u8,
|
||||
}
|
||||
|
||||
impl Default for BackendJobLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
|
||||
timeout_seconds: DEFAULT_JOB_TIMEOUT_SECONDS,
|
||||
max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
|
||||
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendJobLimits {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS
|
||||
{
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}"
|
||||
)));
|
||||
}
|
||||
if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}"
|
||||
)));
|
||||
}
|
||||
if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}"
|
||||
)));
|
||||
}
|
||||
if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_max_concurrent_jobs() -> u16 {
|
||||
DEFAULT_MAX_CONCURRENT_JOBS
|
||||
}
|
||||
const fn default_job_timeout_seconds() -> u32 {
|
||||
DEFAULT_JOB_TIMEOUT_SECONDS
|
||||
}
|
||||
const fn default_max_result_bytes() -> u32 {
|
||||
DEFAULT_MAX_RESULT_BYTES
|
||||
}
|
||||
const fn default_max_attempts() -> u8 {
|
||||
DEFAULT_MAX_ATTEMPTS
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendJobRequest {
|
||||
/// Stable caller-selected idempotency key within one Workspace.
|
||||
pub job_id: String,
|
||||
/// Stable machine-readable use-site name, for example `ticket_item_check`.
|
||||
pub purpose: String,
|
||||
/// Revision fenced by the structured result.
|
||||
pub input_revision: String,
|
||||
/// Durable domain reference used to reload or explain the input.
|
||||
pub input_ref: String,
|
||||
/// Bounded immutable input snapshot. This must not contain credentials.
|
||||
pub input: Value,
|
||||
/// Bounded task instruction; the profile supplies the system instruction.
|
||||
pub instruction: String,
|
||||
/// Existing configured profile selector. Job dispatch never accepts raw profile source.
|
||||
pub profile: String,
|
||||
/// Writer provenance and optional advisory destination, not parent ownership.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_worker: Option<RuntimeWorkerRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notification_target: Option<RuntimeWorkerRef>,
|
||||
#[serde(default)]
|
||||
pub limits: BackendJobLimits,
|
||||
}
|
||||
|
||||
impl BackendJobRequest {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier("job_id", &self.job_id, 256)?;
|
||||
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
|
||||
validate_identifier("input_revision", &self.input_revision, 256)?;
|
||||
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
|
||||
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
|
||||
validate_identifier("profile", &self.profile, 256)?;
|
||||
if self.profile != "builtin:backend-job" {
|
||||
return Err(Error::InvalidInput(
|
||||
"Backend Jobs must use the dedicated builtin:backend-job profile".to_string(),
|
||||
));
|
||||
}
|
||||
let input = serde_json::to_vec(&self.input).map_err(|error| {
|
||||
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
|
||||
})?;
|
||||
if input.len() > MAX_JOB_INPUT_BYTES {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job input exceeds {MAX_JOB_INPUT_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
self.limits.validate()
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> Result<String> {
|
||||
self.validate()?;
|
||||
let encoded = serde_json::to_vec(self).map_err(|error| {
|
||||
Error::InvalidInput(format!("serialize Backend Job intent: {error}"))
|
||||
})?;
|
||||
Ok(sha256(&encoded))
|
||||
}
|
||||
|
||||
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
|
||||
let input = serde_json::to_string(&self.input).map_err(|error| {
|
||||
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
|
||||
})?;
|
||||
Ok(format!(
|
||||
"{}\n\nBackend Job envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority.",
|
||||
self.instruction, self.job_id, attempt_id, self.input_revision, self.input_ref, input
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BackendJobRecord {
|
||||
pub workspace_id: String,
|
||||
pub request: BackendJobRequest,
|
||||
pub intent_fingerprint: String,
|
||||
pub state: BackendJobState,
|
||||
pub current_attempt: u8,
|
||||
pub result: Option<Value>,
|
||||
pub result_digest: Option<String>,
|
||||
pub failure_category: Option<String>,
|
||||
pub failure_detail: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BackendJobAttemptRecord {
|
||||
pub workspace_id: String,
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
pub attempt: u8,
|
||||
pub input_revision: String,
|
||||
pub state: BackendJobAttemptState,
|
||||
pub worker: Option<RuntimeWorkerRef>,
|
||||
pub runtime_run_id: Option<String>,
|
||||
pub dispatched_at: Option<String>,
|
||||
pub deadline_at: String,
|
||||
pub result: Option<Value>,
|
||||
pub result_digest: Option<String>,
|
||||
pub failure_category: Option<String>,
|
||||
pub failure_detail: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BackendJobReservation {
|
||||
pub job: BackendJobRecord,
|
||||
pub attempt: BackendJobAttemptRecord,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendJobResultSubmission {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
pub input_revision: String,
|
||||
pub result: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BackendJobResultAcceptance {
|
||||
pub job: BackendJobRecord,
|
||||
pub attempt: BackendJobAttemptRecord,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendJobWorkerBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendJobDeliveryRecord {
|
||||
pub workspace_id: String,
|
||||
pub delivery_id: String,
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
pub target: RuntimeWorkerRef,
|
||||
pub state: BackendJobDeliveryState,
|
||||
pub failure_category: Option<String>,
|
||||
pub failure_detail: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub delivered_at: Option<String>,
|
||||
}
|
||||
|
||||
pub fn attempt_id(job_id: &str, attempt: u8) -> String {
|
||||
format!("{job_id}:attempt:{attempt}")
|
||||
}
|
||||
|
||||
pub fn allocation_key(attempt_id: &str) -> String {
|
||||
format!("backend-job:{attempt_id}")
|
||||
}
|
||||
|
||||
pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> {
|
||||
let encoded = serde_json::to_string(result)
|
||||
.map_err(|error| Error::InvalidInput(format!("serialize Backend Job result: {error}")))?;
|
||||
if encoded.len() > max_bytes as usize {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job result exceeds {max_bytes} bytes"
|
||||
)));
|
||||
}
|
||||
Ok((sha256(encoded.as_bytes()), encoded))
|
||||
}
|
||||
|
||||
pub(crate) fn bounded_failure_detail(detail: &str) -> String {
|
||||
if detail.len() <= MAX_JOB_FAILURE_BYTES {
|
||||
return detail.to_string();
|
||||
}
|
||||
let mut end = MAX_JOB_FAILURE_BYTES;
|
||||
while !detail.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
detail[..end].to_string()
|
||||
}
|
||||
|
||||
fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> {
|
||||
validate_bounded_text(field, value, max_bytes)?;
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job {field} must not contain control characters"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> {
|
||||
if value.is_empty() || value.trim() != value {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job {field} must be non-empty and trimmed"
|
||||
)));
|
||||
}
|
||||
if value.len() > max_bytes {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Backend Job {field} exceeds {max_bytes} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut output = String::with_capacity(7 + digest.len() * 2);
|
||||
output.push_str("sha256:");
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(output, "{byte:02x}");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request() -> BackendJobRequest {
|
||||
BackendJobRequest {
|
||||
job_id: "ticket-check:T-1:r7".to_string(),
|
||||
purpose: "ticket_item_check".to_string(),
|
||||
input_revision: "7".to_string(),
|
||||
input_ref: "ticket://T-1/revisions/7".to_string(),
|
||||
input: serde_json::json!({"title": "Check me"}),
|
||||
instruction: "Check this immutable Ticket snapshot.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: Some(RuntimeWorkerRef::new("runtime-a", "worker-a")),
|
||||
notification_target: Some(RuntimeWorkerRef::new("runtime-a", "worker-a")),
|
||||
limits: BackendJobLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_covers_revision_profile_limits_and_notification_provenance() {
|
||||
let request = request();
|
||||
let first = request.fingerprint().unwrap();
|
||||
assert_eq!(first, request.fingerprint().unwrap());
|
||||
for mutate in [
|
||||
|request: &mut BackendJobRequest| request.input_revision = "8".to_string(),
|
||||
|request: &mut BackendJobRequest| request.purpose = "alternate_check".to_string(),
|
||||
|request: &mut BackendJobRequest| request.limits.max_attempts = 3,
|
||||
|request: &mut BackendJobRequest| request.notification_target = None,
|
||||
] {
|
||||
let mut changed = request.clone();
|
||||
mutate(&mut changed);
|
||||
assert_ne!(first, changed.fingerprint().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_is_bounded_before_digesting() {
|
||||
let value = serde_json::json!({"ok": true});
|
||||
let (digest, encoded) = result_digest(&value, 128).unwrap();
|
||||
assert!(digest.starts_with("sha256:"));
|
||||
assert_eq!(encoded, r#"{"ok":true}"#);
|
||||
assert!(result_digest(&serde_json::json!({"body": "too long"}), 4).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_detail_is_bounded_by_utf8_bytes_without_splitting_code_points() {
|
||||
let detail = "界".repeat(MAX_JOB_FAILURE_BYTES);
|
||||
let bounded = bounded_failure_detail(&detail);
|
||||
assert!(bounded.len() <= MAX_JOB_FAILURE_BYTES);
|
||||
assert!(detail.starts_with(&bounded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_limits_reject_unbounded_execution_and_retry() {
|
||||
for limits in [
|
||||
BackendJobLimits {
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
},
|
||||
BackendJobLimits {
|
||||
timeout_seconds: MAX_JOB_TIMEOUT_SECONDS + 1,
|
||||
..Default::default()
|
||||
},
|
||||
BackendJobLimits {
|
||||
max_attempts: 0,
|
||||
..Default::default()
|
||||
},
|
||||
BackendJobLimits {
|
||||
max_attempts: ABSOLUTE_MAX_ATTEMPTS + 1,
|
||||
..Default::default()
|
||||
},
|
||||
BackendJobLimits {
|
||||
max_concurrent_jobs: 0,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(limits.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,11 @@ pub enum WorkerSpawnIntent {
|
||||
WorkspaceCompanion,
|
||||
WorkspaceOrchestrator,
|
||||
WorkspaceCoding,
|
||||
BackendJob {
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
purpose: String,
|
||||
},
|
||||
TicketRole {
|
||||
ticket_id: String,
|
||||
role: TicketWorkerRole,
|
||||
@@ -5890,6 +5895,7 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str {
|
||||
WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion",
|
||||
WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator",
|
||||
WorkerSpawnIntent::WorkspaceCoding => "workspace_coding",
|
||||
WorkerSpawnIntent::BackendJob { .. } => "backend_job",
|
||||
WorkerSpawnIntent::TicketRole { role, .. } => match role {
|
||||
TicketWorkerRole::Intake => "ticket_intake",
|
||||
TicketWorkerRole::Orchestrator => "ticket_orchestrator",
|
||||
|
||||
@@ -806,6 +806,79 @@ CREATE TABLE "worker_registry" (
|
||||
PRIMARY KEY (workspace_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE backend_jobs (
|
||||
workspace_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
input_revision TEXT NOT NULL,
|
||||
input_ref TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL,
|
||||
intent_fingerprint TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'unknown')),
|
||||
current_attempt INTEGER NOT NULL CHECK (current_attempt > 0 AND current_attempt <= 3),
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
failure_category TEXT,
|
||||
failure_detail TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
PRIMARY KEY (workspace_id, job_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK ((result_json IS NULL) = (result_digest IS NULL)),
|
||||
CHECK ((state = 'completed') = (result_json IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX backend_jobs_active
|
||||
ON backend_jobs(workspace_id, state, updated_at);
|
||||
CREATE TABLE backend_job_attempts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3),
|
||||
input_revision TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown')),
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
runtime_run_id TEXT,
|
||||
dispatched_at TEXT,
|
||||
deadline_at TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
failure_category TEXT,
|
||||
failure_detail TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
PRIMARY KEY (workspace_id, job_id, attempt_id),
|
||||
UNIQUE (workspace_id, job_id, attempt),
|
||||
UNIQUE (workspace_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id, job_id)
|
||||
REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE,
|
||||
CHECK ((runtime_id IS NULL) = (worker_id IS NULL)),
|
||||
CHECK ((result_json IS NULL) = (result_digest IS NULL)),
|
||||
CHECK ((state = 'completed') = (result_json IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX backend_job_attempts_recovery
|
||||
ON backend_job_attempts(workspace_id, state, deadline_at);
|
||||
CREATE TABLE backend_job_deliveries (
|
||||
workspace_id TEXT NOT NULL,
|
||||
delivery_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
target_runtime_id TEXT NOT NULL,
|
||||
target_worker_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed')),
|
||||
failure_category TEXT,
|
||||
failure_detail TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
PRIMARY KEY (workspace_id, delivery_id),
|
||||
FOREIGN KEY (workspace_id, job_id, attempt_id)
|
||||
REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX backend_job_deliveries_pending
|
||||
ON backend_job_deliveries(workspace_id, state, updated_at);
|
||||
CREATE TABLE worker_registry_observations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod authority;
|
||||
pub mod backend_job;
|
||||
pub mod companion;
|
||||
pub mod config;
|
||||
pub mod config_source;
|
||||
|
||||
@@ -13,9 +13,7 @@ use axum::http::header::{CONTENT_TYPE, LOCATION, ORIGIN};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
#[cfg(test)]
|
||||
use axum::routing::post;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{Duration, SecondsFormat, Utc};
|
||||
use config_source::ConfigTreeSnapshot;
|
||||
@@ -145,6 +143,11 @@ use crate::authority::{
|
||||
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
||||
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary,
|
||||
};
|
||||
use crate::backend_job::{
|
||||
ABSOLUTE_MAX_CONCURRENT_JOBS, BackendJobAttemptState, BackendJobDeliveryState,
|
||||
BackendJobRequest, BackendJobReservation, BackendJobResultAcceptance,
|
||||
BackendJobResultSubmission, BackendJobState, allocation_key,
|
||||
};
|
||||
use crate::companion::{
|
||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||
@@ -3241,6 +3244,7 @@ impl WorkspaceApi {
|
||||
}
|
||||
recover_workdir_removals(&api)?;
|
||||
recover_runtime_removals(&api).await;
|
||||
api.recover_backend_jobs()?;
|
||||
Ok(api)
|
||||
}
|
||||
|
||||
@@ -3248,6 +3252,387 @@ impl WorkspaceApi {
|
||||
self.config.workspace_id.as_str()
|
||||
}
|
||||
|
||||
/// Reserve durable Job intent and dispatch its current attempt through the
|
||||
/// ordinary embedded Runtime Worker path. Replaying the same request
|
||||
/// converges on the same attempt and Worker allocation.
|
||||
pub fn dispatch_backend_job(
|
||||
&self,
|
||||
request: &BackendJobRequest,
|
||||
) -> Result<BackendJobReservation> {
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let reservation =
|
||||
self.store
|
||||
.reserve_backend_job(&self.config.workspace_id, request, &now)?;
|
||||
self.dispatch_backend_job_reservation(reservation)
|
||||
}
|
||||
|
||||
/// Explicit re-evaluation creates a new bounded attempt. Transport replay
|
||||
/// must call `dispatch_backend_job` with the original immutable request.
|
||||
pub fn retry_backend_job(&self, job_id: &str) -> Result<BackendJobReservation> {
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let reservation =
|
||||
self.store
|
||||
.reserve_backend_job_retry(&self.config.workspace_id, job_id, &now)?;
|
||||
self.dispatch_backend_job_reservation(reservation)
|
||||
}
|
||||
|
||||
fn dispatch_backend_job_reservation(
|
||||
&self,
|
||||
reservation: BackendJobReservation,
|
||||
) -> Result<BackendJobReservation> {
|
||||
if reservation.job.state != BackendJobState::Pending
|
||||
|| reservation.attempt.state != BackendJobAttemptState::Reserved
|
||||
|| reservation.attempt.worker.is_some()
|
||||
{
|
||||
return Ok(reservation);
|
||||
}
|
||||
let request = &reservation.job.request;
|
||||
let worker_input = request.worker_input(&reservation.attempt.attempt_id)?;
|
||||
let control_operation = WorkerControlOperation {
|
||||
operation_id: allocation_key(&reservation.attempt.attempt_id),
|
||||
input_fingerprint: reservation.job.intent_fingerprint.clone(),
|
||||
};
|
||||
let spawned = self
|
||||
.spawn_workspace_worker(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
WorkerSpawnRequest {
|
||||
requested_worker_name: Some(format!("job:{}", request.purpose)),
|
||||
intent: WorkerSpawnIntent::BackendJob {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id: reservation.attempt.attempt_id.clone(),
|
||||
purpose: request.purpose.clone(),
|
||||
},
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin(request.profile.clone()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_control_operation: Some(control_operation),
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.error)?;
|
||||
let worker = match (spawned.state, spawned.worker) {
|
||||
(InternalWorkerOperationState::Accepted, Some(worker)) => worker,
|
||||
_ => {
|
||||
let detail = spawned
|
||||
.diagnostics
|
||||
.first()
|
||||
.map(|diagnostic| diagnostic.message.as_str())
|
||||
.unwrap_or("embedded Runtime rejected Backend Job Worker dispatch");
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"dispatch_rejected",
|
||||
detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
code: "backend_job_dispatch_rejected".to_string(),
|
||||
message: detail.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let bound_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let (commit, newly_bound) = self.store.bind_backend_job_attempt_worker(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
&worker.worker,
|
||||
None,
|
||||
&bound_at,
|
||||
)?;
|
||||
if newly_bound {
|
||||
self.worker_projection.publish_commit(commit)?;
|
||||
} else {
|
||||
let job = self
|
||||
.store
|
||||
.get_backend_job(&self.config.workspace_id, &request.job_id)?
|
||||
.ok_or_else(|| Error::Store("bound Backend Job disappeared".to_string()))?;
|
||||
let attempt = self
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
)?
|
||||
.ok_or_else(|| Error::Store("bound Backend Job attempt disappeared".to_string()))?;
|
||||
return Ok(BackendJobReservation {
|
||||
job,
|
||||
attempt,
|
||||
replayed: true,
|
||||
});
|
||||
}
|
||||
|
||||
let submitted = self
|
||||
.runtime
|
||||
.send_input(
|
||||
&worker.worker,
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::User,
|
||||
content: worker_input,
|
||||
segments: None,
|
||||
},
|
||||
)
|
||||
.map_err(RuntimeRegistryError::into_error)?;
|
||||
if submitted.state != InternalWorkerOperationState::Accepted {
|
||||
let detail = submitted
|
||||
.diagnostics
|
||||
.first()
|
||||
.map(|diagnostic| diagnostic.message.as_str())
|
||||
.unwrap_or("embedded Runtime rejected Backend Job input");
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"input_rejected",
|
||||
detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
code: "backend_job_input_rejected".to_string(),
|
||||
message: detail.to_string(),
|
||||
});
|
||||
}
|
||||
self.schedule_backend_job_timeout(
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
&reservation.attempt.deadline_at,
|
||||
);
|
||||
let job = self
|
||||
.store
|
||||
.get_backend_job(&self.config.workspace_id, &request.job_id)?
|
||||
.ok_or_else(|| Error::Store("dispatched Backend Job disappeared".to_string()))?;
|
||||
let attempt = self
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store("dispatched Backend Job attempt disappeared".to_string())
|
||||
})?;
|
||||
Ok(BackendJobReservation {
|
||||
job,
|
||||
attempt,
|
||||
replayed: reservation.replayed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconcile durable non-terminal attempts after Backend restart. Reserved
|
||||
/// attempts are replay-dispatched through their deterministic allocation;
|
||||
/// dispatched attempts keep running only while their bound Worker remains
|
||||
/// known, and always receive a fresh deadline task.
|
||||
pub fn recover_backend_jobs(&self) -> Result<()> {
|
||||
let attempts = self.store.list_recoverable_backend_job_attempts(
|
||||
&self.config.workspace_id,
|
||||
usize::from(ABSOLUTE_MAX_CONCURRENT_JOBS),
|
||||
)?;
|
||||
for attempt in attempts {
|
||||
match attempt.state {
|
||||
BackendJobAttemptState::Reserved => {
|
||||
let job = self
|
||||
.store
|
||||
.get_backend_job(&self.config.workspace_id, &attempt.job_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store("recoverable Backend Job disappeared".to_string())
|
||||
})?;
|
||||
if let Err(error) =
|
||||
self.dispatch_backend_job_reservation(BackendJobReservation {
|
||||
job,
|
||||
attempt,
|
||||
replayed: true,
|
||||
})
|
||||
{
|
||||
tracing::warn!(%error, "Backend Job reserved-attempt recovery failed");
|
||||
}
|
||||
}
|
||||
BackendJobAttemptState::Dispatched => {
|
||||
let worker_known = attempt
|
||||
.worker
|
||||
.as_ref()
|
||||
.is_some_and(|worker| self.runtime.worker(worker).is_ok());
|
||||
if !worker_known {
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
&attempt.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobAttemptState::Unknown,
|
||||
"recovery_worker_unknown",
|
||||
"the bound Runtime Worker was not recoverable after Backend restart",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
} else {
|
||||
self.schedule_backend_job_timeout(
|
||||
&attempt.job_id,
|
||||
&attempt.attempt_id,
|
||||
&attempt.deadline_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
BackendJobAttemptState::Completed
|
||||
| BackendJobAttemptState::Failed
|
||||
| BackendJobAttemptState::Unknown => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schedule_backend_job_timeout(&self, job_id: &str, attempt_id: &str, deadline_at: &str) {
|
||||
let Ok(deadline) = chrono::DateTime::parse_from_rfc3339(deadline_at) else {
|
||||
return;
|
||||
};
|
||||
let delay = (deadline.with_timezone(&Utc) - Utc::now())
|
||||
.to_std()
|
||||
.unwrap_or_default();
|
||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
let store = self.store.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
let workspace_id = self.config.workspace_id.clone();
|
||||
let job_id = job_id.to_string();
|
||||
let attempt_id = attempt_id.to_string();
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let Ok(Some(attempt)) =
|
||||
store.get_backend_job_attempt(&workspace_id, &job_id, &attempt_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if attempt.state != BackendJobAttemptState::Dispatched {
|
||||
return;
|
||||
}
|
||||
// Fence result acceptance before stopping the Worker so a late
|
||||
// completion cannot turn a timed-out attempt into success.
|
||||
if store
|
||||
.finish_backend_job_attempt(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
&attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"timeout",
|
||||
"Backend Job attempt exceeded its durable deadline",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
if let Some(worker) = attempt.worker {
|
||||
let _ = runtime.stop_worker(
|
||||
&worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some("Backend Job attempt timed out".to_string()),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Accept the only authoritative success signal for a Job attempt. The
|
||||
/// source identity is fenced against the durable Worker binding. Result
|
||||
/// persistence precedes best-effort notification delivery.
|
||||
pub fn accept_backend_job_result(
|
||||
&self,
|
||||
source_worker: &RuntimeWorkerRef,
|
||||
submission: &BackendJobResultSubmission,
|
||||
) -> Result<BackendJobResultAcceptance> {
|
||||
let accepted_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let acceptance = self.store.accept_backend_job_result(
|
||||
&self.config.workspace_id,
|
||||
source_worker,
|
||||
submission,
|
||||
&accepted_at,
|
||||
)?;
|
||||
if let Some(target) = acceptance.job.request.notification_target.as_ref() {
|
||||
self.deliver_backend_job_result(&acceptance, target);
|
||||
}
|
||||
Ok(acceptance)
|
||||
}
|
||||
|
||||
fn deliver_backend_job_result(
|
||||
&self,
|
||||
acceptance: &BackendJobResultAcceptance,
|
||||
target: &RuntimeWorkerRef,
|
||||
) {
|
||||
let delivery_id = format!(
|
||||
"{}:{}:notification",
|
||||
acceptance.job.request.job_id, acceptance.attempt.attempt_id
|
||||
);
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let Ok(delivery) = self.store.reserve_backend_job_delivery(
|
||||
&self.config.workspace_id,
|
||||
&delivery_id,
|
||||
&acceptance.job.request.job_id,
|
||||
target,
|
||||
&now,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if delivery.state == BackendJobDeliveryState::Completed {
|
||||
return;
|
||||
}
|
||||
let content = format!(
|
||||
"Backend Job `{}` completed attempt `{}` for `{}`. Durable result digest: {}.",
|
||||
acceptance.job.request.job_id,
|
||||
acceptance.attempt.attempt_id,
|
||||
acceptance.job.request.purpose,
|
||||
acceptance
|
||||
.job
|
||||
.result_digest
|
||||
.as_deref()
|
||||
.unwrap_or("unavailable")
|
||||
);
|
||||
let delivery_result = self.runtime.send_input(
|
||||
target,
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::Notify,
|
||||
content,
|
||||
segments: None,
|
||||
},
|
||||
);
|
||||
let (state, category, detail): (_, Option<String>, Option<String>) = match delivery_result {
|
||||
Ok(result) if result.state == InternalWorkerOperationState::Accepted => {
|
||||
(BackendJobDeliveryState::Completed, None, None)
|
||||
}
|
||||
Ok(result) => (
|
||||
BackendJobDeliveryState::Failed,
|
||||
Some("notification_rejected".to_string()),
|
||||
result.diagnostics.first().map(|item| item.message.clone()),
|
||||
),
|
||||
Err(error) => (
|
||||
BackendJobDeliveryState::Failed,
|
||||
Some("notification_unavailable".to_string()),
|
||||
Some(error.message()),
|
||||
),
|
||||
};
|
||||
// Delivery is intentionally separate from result authority. A delivery
|
||||
// bookkeeping failure must never roll back or re-run completed work.
|
||||
let _ = self.store.finish_backend_job_delivery(
|
||||
&self.config.workspace_id,
|
||||
&delivery_id,
|
||||
state,
|
||||
category.as_deref(),
|
||||
detail.as_deref(),
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
);
|
||||
}
|
||||
|
||||
fn register_workspace_runtime_binding(
|
||||
&self,
|
||||
binding: WorkspaceRuntimeBinding,
|
||||
@@ -9123,6 +9508,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
_,
|
||||
_,
|
||||
>(workspace, scoped_worker_protocol_ws)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/workers/self/backend-job-result",
|
||||
post(scoped_submit_backend_job_result),
|
||||
)
|
||||
.fallback(get(static_or_spa_fallback))
|
||||
.with_state(api)
|
||||
.merge(generated_workspace_contract_router(contract_service));
|
||||
@@ -14474,6 +14863,49 @@ async fn refresh_current_worker_session_locked(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmitBackendJobResultRequest {
|
||||
result: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn scoped_submit_backend_job_result(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<SubmitBackendJobResultRequest>,
|
||||
) -> ApiResult<Json<BackendJobResultAcceptance>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let worker = current_worker_identity(&api, &path.workspace_id, &headers)?;
|
||||
let binding = api
|
||||
.store
|
||||
.worker_registry_projection(&api.config.workspace_id, &worker)?
|
||||
.and_then(|projection| projection.job)
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput(
|
||||
"current Worker is not bound to an active Backend Job attempt".to_string(),
|
||||
)
|
||||
})?;
|
||||
let attempt = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
&api.config.workspace_id,
|
||||
&binding.job_id,
|
||||
&binding.attempt_id,
|
||||
)?
|
||||
.ok_or_else(|| Error::Store("Backend Job attempt binding disappeared".to_string()))?;
|
||||
let acceptance = api.accept_backend_job_result(
|
||||
&worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: binding.job_id,
|
||||
attempt_id: binding.attempt_id,
|
||||
input_revision: attempt.input_revision,
|
||||
result: request.result,
|
||||
},
|
||||
)?;
|
||||
Ok(Json(acceptance))
|
||||
}
|
||||
|
||||
async fn scoped_attach_current_worker_workdir(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -24800,6 +25232,7 @@ fn record_worker_summary(
|
||||
workspace_id: worker.workspace.workspace_id.clone(),
|
||||
display_name: Some(worker.display_name.clone()),
|
||||
profile: worker.profile.clone(),
|
||||
job: None,
|
||||
workdir_attachments: Vec::new(),
|
||||
};
|
||||
api.worker_projection
|
||||
@@ -26714,6 +27147,7 @@ mod tests {
|
||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||
display_name: Some("Worker A".to_string()),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
job: None,
|
||||
workdir_attachments: Vec::new(),
|
||||
},
|
||||
availability,
|
||||
@@ -26723,6 +27157,7 @@ mod tests {
|
||||
projection_revision: 9,
|
||||
observed_at: "2026-09-24T00:00:01Z".to_string(),
|
||||
}),
|
||||
job: None,
|
||||
};
|
||||
|
||||
let unavailable = worker_summary_from_projection(&projection).unwrap();
|
||||
@@ -30275,6 +30710,7 @@ mod tests {
|
||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
||||
display_name: Some("Paused Worker".to_string()),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
job: None,
|
||||
workdir_attachments: Vec::new(),
|
||||
},
|
||||
"4",
|
||||
@@ -33403,6 +33839,297 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_runner_uses_registered_embedded_worker_and_structured_result_authority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-check-1".to_string(),
|
||||
purpose: "runner_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/input/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
|
||||
let stale_reservation = api
|
||||
.store
|
||||
.reserve_backend_job(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
let dispatched = api
|
||||
.dispatch_backend_job_reservation(stale_reservation.clone())
|
||||
.unwrap();
|
||||
let concurrent_replay = api
|
||||
.dispatch_backend_job_reservation(stale_reservation)
|
||||
.unwrap();
|
||||
assert!(concurrent_replay.replayed);
|
||||
assert_eq!(
|
||||
execution.take_inputs().len(),
|
||||
1,
|
||||
"a stale concurrent dispatch replay must not submit the attempt twice"
|
||||
);
|
||||
assert_eq!(dispatched.attempt.state, BackendJobAttemptState::Dispatched);
|
||||
let worker = dispatched.attempt.worker.clone().expect("bound Job Worker");
|
||||
assert_eq!(worker.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
assert!(
|
||||
api.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"Job dispatch must use the ordinary Workspace Worker registry"
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.worker_registry_projection(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.job,
|
||||
Some(crate::backend_job::BackendJobWorkerBinding {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id: dispatched.attempt.attempt_id.clone(),
|
||||
purpose: request.purpose.clone(),
|
||||
})
|
||||
);
|
||||
let replay = api.dispatch_backend_job(&request).unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.attempt.worker, Some(worker.clone()));
|
||||
|
||||
let accepted = api
|
||||
.accept_backend_job_result(
|
||||
&worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id: dispatched.attempt.attempt_id,
|
||||
input_revision: request.input_revision,
|
||||
result: serde_json::json!({"valid": true}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accepted.job.state, BackendJobState::Completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_input_failure_is_durable_and_retryable_without_reusing_attempt() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
execution.reject_inputs("model transport unavailable");
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-input-failure-1".to_string(),
|
||||
purpose: "failure_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/failure/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
|
||||
assert!(api.dispatch_backend_job(&request).is_err());
|
||||
let failed = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&crate::backend_job::attempt_id(&request.job_id, 1),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(failed.state, BackendJobAttemptState::Failed);
|
||||
assert_eq!(failed.failure_category.as_deref(), Some("input_rejected"));
|
||||
let _retry_error = api.retry_backend_job(&request.job_id).unwrap_err();
|
||||
let second = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&crate::backend_job::attempt_id(&request.job_id, 2),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(second.attempt, 2);
|
||||
assert_eq!(second.state, BackendJobAttemptState::Failed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_recovery_dispatches_reserved_attempt_without_duplicate_worker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-restart-1".to_string(),
|
||||
purpose: "restart_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/restart/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
let reserved = api
|
||||
.store
|
||||
.reserve_backend_job(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reserved.attempt.state, BackendJobAttemptState::Reserved);
|
||||
api.recover_backend_jobs().unwrap();
|
||||
|
||||
let recovered = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&reserved.attempt.attempt_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.state, BackendJobAttemptState::Dispatched);
|
||||
let recovered_worker = recovered.worker.clone().expect("recovered Job Worker");
|
||||
let replay = api.dispatch_backend_job(&request).unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.attempt.worker, Some(recovered_worker));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_recovery_marks_unreconcilable_worker_execution_unknown() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-unknown-1".to_string(),
|
||||
purpose: "unknown_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/unknown/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
let reserved = api
|
||||
.store
|
||||
.reserve_backend_job(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
let missing_worker = RuntimeWorkerRef::new(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
"0199a3ae-ecdf-7eb2-883e-a5b7b5f04a0d",
|
||||
);
|
||||
api.store
|
||||
.upsert_worker_registry(&WorkerRegistryRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
worker: missing_worker.clone(),
|
||||
display_name: "Missing Job Worker".to_string(),
|
||||
profile: Some("builtin:backend-job".to_string()),
|
||||
retention_state: "normal".to_string(),
|
||||
transcript_ref: None,
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: "2026-09-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
api.store
|
||||
.bind_backend_job_attempt_worker(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&reserved.attempt.attempt_id,
|
||||
&missing_worker,
|
||||
Some("missing-runtime-run"),
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
api.recover_backend_jobs().unwrap();
|
||||
let attempt = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&reserved.attempt.attempt_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(attempt.state, BackendJobAttemptState::Unknown);
|
||||
assert_eq!(
|
||||
attempt.failure_category.as_deref(),
|
||||
Some("recovery_worker_unknown")
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_backend_job(TEST_WORKSPACE_ID, &request.job_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state,
|
||||
BackendJobState::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_timeout_fences_late_structured_result() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-timeout-1".to_string(),
|
||||
purpose: "timeout_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/timeout/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Wait for a structured result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits {
|
||||
timeout_seconds: 1,
|
||||
max_attempts: 1,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let dispatched = api.dispatch_backend_job(&request).unwrap();
|
||||
let worker = dispatched.attempt.worker.clone().unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
|
||||
let attempt = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(attempt.state, BackendJobAttemptState::Failed);
|
||||
assert_eq!(attempt.failure_category.as_deref(), Some("timeout"));
|
||||
assert!(
|
||||
api.accept_backend_job_result(
|
||||
&worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: request.job_id,
|
||||
attempt_id: dispatched.attempt.attempt_id,
|
||||
input_revision: request.input_revision,
|
||||
result: serde_json::json!({"late": true}),
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_runtime_workdir_access_requires_workspace_binding() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,9 @@ use crate::{Error, Result};
|
||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"artifacts",
|
||||
"audit_events",
|
||||
"backend_job_deliveries",
|
||||
"backend_job_attempts",
|
||||
"backend_jobs",
|
||||
"external_workdir_grants",
|
||||
"flow_source_revisions",
|
||||
"flow_sources",
|
||||
|
||||
@@ -449,6 +449,7 @@ fn project_registry_worker(
|
||||
workspace_id: Some(record.registry.workspace_id.clone()),
|
||||
display_name: Some(record.registry.display_name.clone()),
|
||||
profile: record.registry.profile.clone(),
|
||||
job: None,
|
||||
workdir_attachments: Vec::new(),
|
||||
}
|
||||
};
|
||||
@@ -457,6 +458,13 @@ fn project_registry_worker(
|
||||
worker.workspace_id = Some(record.registry.workspace_id);
|
||||
worker.display_name = Some(record.registry.display_name);
|
||||
worker.profile = record.registry.profile;
|
||||
worker.job = record
|
||||
.job
|
||||
.map(|job| protocol::subscription::SubscriptionWorkerJob {
|
||||
job_id: job.job_id,
|
||||
attempt_id: job.attempt_id,
|
||||
purpose: job.purpose,
|
||||
});
|
||||
if !project_working_directory(api, &record.registry.worker, &mut worker) {
|
||||
return None;
|
||||
}
|
||||
@@ -616,6 +624,7 @@ mod tests {
|
||||
workspace_id: Some("workspace-1".to_string()),
|
||||
display_name: Some("Companion".to_string()),
|
||||
profile: Some("builtin:companion".to_string()),
|
||||
job: None,
|
||||
workdir_attachments: vec![
|
||||
protocol::subscription::SubscriptionWorkerWorkdirAttachment {
|
||||
alias: "external".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user