feat: add durable backend job runner

This commit is contained in:
2026-10-01 10:06:55 +09:00
parent 6b2f40a2a2
commit ca838e4acf
27 changed files with 2823 additions and 13 deletions
+489
View File
@@ -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());
}
}
}
+6
View File
@@ -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,
+1
View File
@@ -6,6 +6,7 @@
pub mod auth;
pub mod authority;
pub mod backend_job;
pub mod companion;
pub mod config;
pub mod config_source;
+730 -3
View File
@@ -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(),