chore: merge develop into hare/develop
This commit is contained in:
@@ -30,6 +30,9 @@ pub enum ToolError {
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
/// Machine-readable domain conflict that a tool adapter may project as structured output.
|
||||
#[error("{code}: {message}")]
|
||||
StructuredConflict { code: String, message: String },
|
||||
/// Cooperative cancellation completed with bounded terminal output.
|
||||
#[error("Tool execution cancelled")]
|
||||
Cancelled(ToolOutput),
|
||||
|
||||
@@ -3,8 +3,11 @@ use server_api::{
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
|
||||
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
|
||||
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
|
||||
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
@@ -410,6 +413,97 @@ impl BackendWorkspaceProductClient {
|
||||
crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_subjects(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<SubjektivSubjectListResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_subject_list(
|
||||
workspace_id,
|
||||
SubjektivSubjectListQuery {
|
||||
limit: Some(limit),
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_subject(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjektivSubjectResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client.subjektiv_subject_get(workspace_id, subject_id).await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_resident_surface(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjektivResidentSurfaceResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_resident_surface(workspace_id, subject_id)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memories(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
query: &SubjektivMemoryListQuery,
|
||||
) -> Result<SubjektivMemoryQueryResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_list(workspace_id, subject_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_memory(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryDetailQuery,
|
||||
) -> Result<SubjektivMemoryReadResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_detail(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memory_revisions(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryRevisionsQuery,
|
||||
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn launch_ticket_intake(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
@@ -917,6 +1011,45 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_memory_list_encodes_typed_filters_as_bounded_query_scalars() {
|
||||
let (base_url, request, handle) =
|
||||
one_response_server("200 OK", r#"{"items":[],"has_more":false}"#);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response = client
|
||||
.list_subjektiv_memories(
|
||||
"subject/a",
|
||||
&SubjektivMemoryListQuery {
|
||||
query: Some("stable cursor".to_string()),
|
||||
kinds: None,
|
||||
states: Some(vec![
|
||||
server_api::SubjektivMemoryState::Active,
|
||||
server_api::SubjektivMemoryState::Resolved,
|
||||
]),
|
||||
limit: Some(25),
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
let request = request.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("GET /api/w/workspace-a/subjektiv/subjects/subject%2Fa/memories?")
|
||||
);
|
||||
assert!(request.contains("query=stable+cursor"));
|
||||
assert!(request.contains("states=active%2Cresolved"));
|
||||
assert!(request.contains("limit=25"));
|
||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#;
|
||||
|
||||
@@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
description: "Internal Memory consolidation service.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:subjektiv-memory-consolidation"),
|
||||
path: "profiles/subjektiv-memory-consolidation.dcdl",
|
||||
source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"),
|
||||
description: "Delegated subject Memory consolidation service.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -19,9 +19,10 @@ use crate::{
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ResolvedSubjektivFeatureConfig,
|
||||
ScopeConfig, SessionConfig, SkillsConfig, SubjektivFeatureProfileConfig, TicketFeatureConfig,
|
||||
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||
WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
@@ -76,6 +77,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub memory: Option<MemoryFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub subjektiv: Option<SubjektivFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub web: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub image: Option<FeatureFlagConfigPartial>,
|
||||
@@ -104,6 +107,11 @@ impl FeatureConfigPartial {
|
||||
Self {
|
||||
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
||||
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
|
||||
subjektiv: merge_option(
|
||||
self.subjektiv,
|
||||
other.subjektiv,
|
||||
SubjektivFeatureConfigPartial::merge,
|
||||
),
|
||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
|
||||
sub_worker: merge_option(
|
||||
@@ -227,6 +235,28 @@ pub struct MemoryConsolidationProfileConfigPartial {
|
||||
pub request_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||
}
|
||||
|
||||
impl SubjektivFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
extraction: merge_option(
|
||||
self.extraction,
|
||||
other.extraction,
|
||||
MemoryExtractionProfileConfigPartial::merge,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
@@ -331,6 +361,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.memory
|
||||
.map(ResolvedMemoryFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
subjektiv: value
|
||||
.subjektiv
|
||||
.map(ResolvedSubjektivFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
sub_worker: value
|
||||
@@ -447,6 +481,42 @@ impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
|
||||
fn from(value: SubjektivFeatureConfigPartial) -> Self {
|
||||
let extraction = value.extraction.unwrap_or_default();
|
||||
Self {
|
||||
profile: SubjektivFeatureProfileConfig {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
extraction: MemoryExtractionProfileConfig {
|
||||
enabled: extraction.enabled.unwrap_or(true),
|
||||
model: extraction.model,
|
||||
reasoning: extraction.reasoning,
|
||||
threshold: extraction.threshold.or(Some(50_000)),
|
||||
worker_max_turns: extraction
|
||||
.worker_max_turns
|
||||
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||
},
|
||||
},
|
||||
workspace_settings: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
|
||||
fn from(value: ResolvedSubjektivFeatureConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.profile.enabled),
|
||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||
enabled: Some(value.profile.extraction.enabled),
|
||||
model: value.profile.extraction.model,
|
||||
reasoning: value.profile.extraction.reasoning,
|
||||
threshold: value.profile.extraction.threshold,
|
||||
worker_max_turns: value.profile.extraction.worker_max_turns,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
||||
fn from(value: TicketFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
@@ -500,6 +570,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
Self {
|
||||
task: Some(value.task.into()),
|
||||
memory: Some(value.memory.into()),
|
||||
subjektiv: Some(value.subjektiv.into()),
|
||||
web: Some(value.web.into()),
|
||||
image: Some(value.image.into()),
|
||||
sub_worker: Some(value.sub_worker.into()),
|
||||
@@ -2490,6 +2561,36 @@ directories = [".claude/skills", ".cursor/skills"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_config_is_explicit_and_roundtrips_without_subject_identity() {
|
||||
let config = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "subject-worker"
|
||||
|
||||
[feature.subjektiv]
|
||||
enabled = true
|
||||
|
||||
[feature.subjektiv.extraction]
|
||||
enabled = true
|
||||
threshold = 321
|
||||
worker_max_turns = 7
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let partial = config.feature.subjektiv.unwrap();
|
||||
assert_eq!(partial.enabled, Some(true));
|
||||
assert_eq!(partial.extraction.as_ref().unwrap().threshold, Some(321));
|
||||
|
||||
let resolved = ResolvedSubjektivFeatureConfig::from(partial);
|
||||
assert!(resolved.profile.enabled);
|
||||
assert_eq!(resolved.profile.extraction.threshold, Some(321));
|
||||
assert_eq!(resolved.profile.extraction.worker_max_turns, Some(7));
|
||||
let encoded = serde_json::to_value(SubjektivFeatureConfigPartial::from(resolved)).unwrap();
|
||||
assert!(encoded.get("subject_id").is_none());
|
||||
assert_eq!(encoded["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_ref() {
|
||||
let lower = WorkerManifestConfig {
|
||||
|
||||
+109
-1
@@ -108,6 +108,11 @@ pub struct FeatureConfig {
|
||||
pub task: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub memory: ResolvedMemoryFeatureConfig,
|
||||
/// Subject-scoped Memory extraction. Subject identity is resolved by the
|
||||
/// Workspace host from the Worker's keyed singleton lease, never from this
|
||||
/// model-visible manifest.
|
||||
#[serde(default)]
|
||||
pub subjektiv: ResolvedSubjektivFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub web: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
@@ -139,6 +144,7 @@ impl Default for FeatureConfig {
|
||||
Self {
|
||||
task: FeatureFlagConfig::disabled(),
|
||||
memory: ResolvedMemoryFeatureConfig::default(),
|
||||
subjektiv: ResolvedSubjektivFeatureConfig::default(),
|
||||
web: FeatureFlagConfig::disabled(),
|
||||
image: FeatureFlagConfig::disabled(),
|
||||
sub_worker: FeatureFlagConfig::disabled(),
|
||||
@@ -350,6 +356,85 @@ impl ResolvedMemoryFeatureConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile-owned subjektiv activation and extraction policy. The subject id is
|
||||
/// deliberately absent: the Workspace host binds it from `subjektiv:<id>`
|
||||
/// singleton ownership for each operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SubjektivFeatureProfileConfig {
|
||||
pub enabled: bool,
|
||||
pub extraction: MemoryExtractionProfileConfig,
|
||||
}
|
||||
|
||||
impl Default for SubjektivFeatureProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
extraction: MemoryExtractionProfileConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable subjektiv execution configuration persisted in a resolved Worker
|
||||
/// Manifest. Workspace settings provide only trusted extraction language and
|
||||
/// are not subject identity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ResolvedSubjektivFeatureConfig {
|
||||
pub profile: SubjektivFeatureProfileConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
|
||||
}
|
||||
|
||||
impl ResolvedSubjektivFeatureConfig {
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.profile.enabled
|
||||
}
|
||||
|
||||
/// Returns whether the host attached this profile policy to trusted subject
|
||||
/// authority. Profile enablement without a settings snapshot is inert.
|
||||
pub fn execution_enabled(&self) -> bool {
|
||||
self.profile.enabled && self.workspace_settings.is_some()
|
||||
}
|
||||
|
||||
pub fn bind_workspace_settings(
|
||||
&mut self,
|
||||
settings: WorkspaceMemorySettingsSnapshot,
|
||||
) -> Result<(), &'static str> {
|
||||
if !self.profile.enabled {
|
||||
if self.workspace_settings.is_some() {
|
||||
return Err("disabled subjektiv feature must not carry Workspace settings");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.workspace_settings.is_some() {
|
||||
return Err("subjektiv Workspace settings are already bound");
|
||||
}
|
||||
self.workspace_settings = Some(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
||||
self.workspace_settings.clone()
|
||||
}
|
||||
|
||||
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
||||
// `profile.enabled` is reusable policy. Only a host-bound settings
|
||||
// snapshot activates subject execution; an ordinary Worker or Internal
|
||||
// SubWorker with the same Profile remains inert.
|
||||
if !self.profile.enabled && self.workspace_settings.is_some() {
|
||||
return Err("disabled subjektiv feature must not carry Workspace settings");
|
||||
}
|
||||
if let Some(settings) = &self.workspace_settings
|
||||
&& (settings.settings_revision == 0
|
||||
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||
{
|
||||
return Err("subjektiv Workspace settings snapshot metadata is invalid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct TicketFeatureConfig {
|
||||
#[serde(default)]
|
||||
@@ -963,7 +1048,9 @@ impl Default for CompactionConfig {
|
||||
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
||||
self.profile.is_some()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||
}
|
||||
|
||||
/// Parse a manifest from a TOML string.
|
||||
@@ -1638,6 +1725,27 @@ model_id = "claude-sonnet-4-20250514"
|
||||
assert!(disabled.bind_workspace_settings(snapshot).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
|
||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut enabled = ResolvedSubjektivFeatureConfig::default();
|
||||
enabled.profile.enabled = true;
|
||||
assert!(enabled.validate_execution().is_ok());
|
||||
assert!(!enabled.execution_enabled());
|
||||
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
|
||||
assert!(enabled.validate_execution().is_ok());
|
||||
assert!(enabled.execution_enabled());
|
||||
|
||||
let mut disabled = ResolvedSubjektivFeatureConfig::default();
|
||||
disabled.workspace_settings = Some(snapshot.clone());
|
||||
assert!(disabled.validate_execution().is_err());
|
||||
assert!(disabled.bind_workspace_settings(snapshot).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||
|
||||
@@ -144,6 +144,7 @@ pub enum WorkspaceAuthorityRequirement {
|
||||
Flow,
|
||||
ManageWorkdir,
|
||||
Memory,
|
||||
Subjektiv,
|
||||
MergeRequest,
|
||||
Objective,
|
||||
Orchestration,
|
||||
@@ -157,6 +158,7 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
|
||||
Self::Flow => formatter.write_str("feature.flow"),
|
||||
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
||||
Self::Memory => formatter.write_str("feature.memory"),
|
||||
Self::Subjektiv => formatter.write_str("feature.subjektiv"),
|
||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||
Self::Objective => formatter.write_str("feature.objective"),
|
||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
||||
@@ -185,6 +187,9 @@ pub fn validate_profile_execution_target(
|
||||
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||
}
|
||||
if feature.subjektiv.profile.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
|
||||
}
|
||||
if feature.merge_request.show
|
||||
|| feature.merge_request.open
|
||||
|| feature.merge_request.review
|
||||
@@ -1252,6 +1257,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolve = |name: &str| {
|
||||
ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, name),
|
||||
ProfileResolveOptions::with_worker_name(format!("{name}-worker")),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest
|
||||
};
|
||||
|
||||
for name in ["coder", "companion", "intake", "reviewer", "orchestrator"] {
|
||||
let manifest = resolve(name);
|
||||
assert!(
|
||||
manifest.feature.subjektiv.profile.enabled,
|
||||
"{name} must enable subjektiv"
|
||||
);
|
||||
assert!(
|
||||
manifest.feature.subjektiv.profile.extraction.enabled,
|
||||
"{name} must enable subject extraction"
|
||||
);
|
||||
assert!(
|
||||
!manifest.feature.memory.profile.enabled,
|
||||
"{name} must not enable legacy Workspace Memory"
|
||||
);
|
||||
}
|
||||
|
||||
for name in ["default", "standalone", "backend-job"] {
|
||||
let manifest = resolve(name);
|
||||
assert!(!manifest.feature.memory.profile.enabled, "{name}");
|
||||
assert!(!manifest.feature.subjektiv.profile.enabled, "{name}");
|
||||
}
|
||||
|
||||
let legacy_consolidator = resolve("memory-consolidation");
|
||||
assert!(legacy_consolidator.feature.memory.profile.enabled);
|
||||
assert!(legacy_consolidator.feature.memory.profile.staging_tools);
|
||||
assert!(
|
||||
legacy_consolidator
|
||||
.feature
|
||||
.memory
|
||||
.profile
|
||||
.extraction
|
||||
.enabled
|
||||
);
|
||||
assert!(
|
||||
legacy_consolidator
|
||||
.feature
|
||||
.memory
|
||||
.profile
|
||||
.consolidation
|
||||
.request_enabled
|
||||
);
|
||||
assert!(!legacy_consolidator.feature.subjektiv.profile.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_profiles_pin_role_models_and_reasoning() {
|
||||
use crate::model::{ReasoningControl, ReasoningEffort};
|
||||
@@ -1276,6 +1339,7 @@ mod tests {
|
||||
"coder",
|
||||
"reviewer",
|
||||
"memory-consolidation",
|
||||
"subjektiv-memory-consolidation",
|
||||
] {
|
||||
let role = resolve(name);
|
||||
assert_eq!(
|
||||
@@ -1308,7 +1372,7 @@ mod tests {
|
||||
role.manifest.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
||||
);
|
||||
let extraction = &role.manifest.feature.memory.profile.extraction;
|
||||
let extraction = &role.manifest.feature.subjektiv.profile.extraction;
|
||||
assert_eq!(
|
||||
extraction
|
||||
.model
|
||||
@@ -1322,14 +1386,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let consolidation = resolve("memory-consolidation");
|
||||
for name in ["memory-consolidation", "subjektiv-memory-consolidation"] {
|
||||
let consolidation = resolve(name);
|
||||
assert_eq!(
|
||||
consolidation.manifest.model.ref_.as_deref(),
|
||||
Some("codex-oauth/gpt-5.6-luna")
|
||||
);
|
||||
assert_eq!(
|
||||
consolidation.manifest.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_memory_consolidation_profile_is_lifecycle_and_capability_isolated() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(
|
||||
ProfileRegistrySource::Builtin,
|
||||
"subjektiv-memory-consolidation",
|
||||
),
|
||||
ProfileResolveOptions::with_worker_name("subject-consolidator"),
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = resolved.manifest;
|
||||
let feature = &manifest.feature;
|
||||
|
||||
assert!(!feature.memory.profile.enabled);
|
||||
assert!(!feature.memory.profile.staging_tools);
|
||||
assert!(feature.subjektiv.profile.enabled);
|
||||
assert!(!feature.subjektiv.profile.extraction.enabled);
|
||||
assert!(!feature.task.enabled);
|
||||
assert!(!feature.web.enabled);
|
||||
assert!(!feature.image.enabled);
|
||||
assert!(!feature.sub_worker.enabled);
|
||||
assert!(!feature.flow.enabled);
|
||||
assert!(!feature.worker.enabled);
|
||||
assert!(!feature.worker.direct_spawn);
|
||||
assert!(!feature.workspace_worker_discovery.enabled);
|
||||
assert!(!feature.objective.enabled);
|
||||
assert!(!feature.manage_workdir.enabled);
|
||||
assert!(!feature.ticket.enabled);
|
||||
assert!(!feature.ticket.authoring);
|
||||
assert!(!feature.ticket.thread);
|
||||
assert!(!feature.ticket.intake);
|
||||
assert!(!feature.ticket.workflow);
|
||||
assert!(!feature.merge_request.show);
|
||||
assert!(!feature.merge_request.open);
|
||||
assert!(!feature.merge_request.review);
|
||||
assert!(!feature.merge_request.readiness_check);
|
||||
assert!(!feature.merge_request.complete);
|
||||
assert!(!feature.orchestration.enabled);
|
||||
assert_eq!(
|
||||
consolidation.manifest.model.ref_.as_deref(),
|
||||
Some("codex-oauth/gpt-5.6-luna")
|
||||
);
|
||||
assert_eq!(
|
||||
consolidation.manifest.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
|
||||
manifest.web.as_ref().and_then(|web| web.enabled),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1477,7 +1590,8 @@ mod tests {
|
||||
panic!("unexpected error: {error}");
|
||||
};
|
||||
assert_eq!(target, ProfileExecutionTarget::Standalone);
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv));
|
||||
assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
|
||||
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
|
||||
|
||||
@@ -41,6 +41,7 @@ pub enum MemoryBackendHttpResponse {
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MemoryBackendOperationResult {
|
||||
ToolOutput(MemoryToolOutput),
|
||||
ResidentSummary(MemoryResidentSummaryOutput),
|
||||
Acknowledged(MemoryBackendAckOutput),
|
||||
StagingWritten(MemoryStagingWriteOutput),
|
||||
}
|
||||
@@ -52,6 +53,23 @@ pub struct MemoryToolOutput {
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryResidentSummaryAvailability {
|
||||
Ready,
|
||||
Ungenerated,
|
||||
Stale,
|
||||
Failed,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MemoryResidentSummaryOutput {
|
||||
pub availability: MemoryResidentSummaryAvailability,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MemoryQueryOperation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
+117
-7
@@ -766,6 +766,10 @@ pub struct InternalWorkerSnapshot {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
pub session: SessionSnapshot,
|
||||
/// Public execution metadata for this child. Older snapshots and service
|
||||
/// workers may omit it; clients must never substitute the parent's values.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub greeting: Option<Greeting>,
|
||||
#[serde(default)]
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1241,6 +1245,16 @@ pub enum Event {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cache_read_input_tokens: Option<u64>,
|
||||
},
|
||||
/// Authoritative current-context occupancy after a non-request boundary.
|
||||
///
|
||||
/// Unlike [`Event::Usage`], this is not cumulative request traffic. It lets
|
||||
/// connected clients converge with reconnect snapshots after run completion,
|
||||
/// compaction, rewind, and other history rewrites. `None` explicitly means
|
||||
/// the current value is unavailable and stale occupancy must be cleared.
|
||||
ContextUsage {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<ContextUsage>,
|
||||
},
|
||||
RunEnd {
|
||||
result: RunResult,
|
||||
},
|
||||
@@ -1667,11 +1681,45 @@ impl InFlightToolCallState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker self-description rendered by the TUI when a session starts empty.
|
||||
/// Public reasoning configuration resolved for the model client.
|
||||
///
|
||||
/// Built once in the Worker controller from the resolved manifest and
|
||||
/// transmitted alongside `Event::Snapshot` so clients don't need
|
||||
/// their own view of the manifest.
|
||||
/// This deliberately exposes only the effective provider control, not the
|
||||
/// Worker manifest or provider credentials. The tagged shape prevents token
|
||||
/// budgets from being presented as effort labels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ReasoningConfig {
|
||||
Effort { effort: String },
|
||||
BudgetTokens { budget_tokens: i32 },
|
||||
}
|
||||
|
||||
/// Provenance of the current-context token count in a [`Greeting`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextTokenSource {
|
||||
/// Provider-reported input occupancy from the most recent LLM request.
|
||||
Measured,
|
||||
/// Current history projected from the Worker's token accounting records.
|
||||
Estimated,
|
||||
}
|
||||
|
||||
/// Current prompt-context occupancy, distinct from cumulative run traffic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct ContextUsage {
|
||||
pub tokens: u64,
|
||||
pub source: ContextTokenSource,
|
||||
}
|
||||
|
||||
/// Worker self-description rendered by clients when a session starts empty.
|
||||
///
|
||||
/// Snapshot metadata contains only resolved public execution information so
|
||||
/// clients do not need their own copy of the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -1680,14 +1728,22 @@ pub struct Greeting {
|
||||
pub cwd: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
/// Effective reasoning control. Absent means unspecified or unavailable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
pub scope_summary: String,
|
||||
pub tools: Vec<String>,
|
||||
/// Model context window in tokens. Always filled by the Worker greeting.
|
||||
/// Resolved effective model context window in tokens. Legacy producers use
|
||||
/// zero when this is unavailable; clients must treat that as unknown.
|
||||
#[serde(default)]
|
||||
pub context_window: u64,
|
||||
/// Estimated current session context tokens at connect time.
|
||||
/// Legacy numeric mirror retained for mixed-version clients.
|
||||
#[serde(default)]
|
||||
pub context_tokens: u64,
|
||||
/// Typed current-context value. Older snapshots omit it; clients may fall
|
||||
/// back to `context_tokens` only when `context_window` is known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_usage: Option<ContextUsage>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2354,6 +2410,11 @@ mod tests {
|
||||
tools: vec!["Read".into()],
|
||||
context_window: 200_000,
|
||||
context_tokens: 42_000,
|
||||
reasoning: Some(ReasoningConfig::BudgetTokens { budget_tokens: -1 }),
|
||||
context_usage: Some(ContextUsage {
|
||||
tokens: 42_000,
|
||||
source: ContextTokenSource::Measured,
|
||||
}),
|
||||
},
|
||||
state: WorkerStatus::Paused.into(),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
@@ -2372,16 +2433,63 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
|
||||
assert_eq!(
|
||||
parsed["data"]["greeting"]["reasoning"]["kind"],
|
||||
"budget_tokens"
|
||||
);
|
||||
assert_eq!(parsed["data"]["greeting"]["reasoning"]["budget_tokens"], -1);
|
||||
assert_eq!(
|
||||
parsed["data"]["greeting"]["context_usage"]["tokens"],
|
||||
42_000
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["data"]["greeting"]["context_usage"]["source"],
|
||||
"measured"
|
||||
);
|
||||
assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
|
||||
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_event_roundtrips_typed_value_and_unknown() {
|
||||
let event = Event::ContextUsage {
|
||||
usage: Some(ContextUsage {
|
||||
tokens: 25_000,
|
||||
source: ContextTokenSource::Estimated,
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&json).unwrap(),
|
||||
serde_json::json!({
|
||||
"event": "context_usage",
|
||||
"data": {
|
||||
"usage": { "tokens": 25_000, "source": "estimated" }
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<Event>(r#"{"event":"context_usage","data":{}}"#).unwrap(),
|
||||
Event::ContextUsage { usage: None }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
||||
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
|
||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||
match decoded {
|
||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||
Event::Snapshot {
|
||||
greeting,
|
||||
in_flight,
|
||||
..
|
||||
} => {
|
||||
assert!(in_flight.is_empty());
|
||||
assert_eq!(greeting.reasoning, None);
|
||||
assert_eq!(greeting.context_usage, None);
|
||||
assert_eq!(greeting.context_window, 0);
|
||||
assert_eq!(greeting.context_tokens, 0);
|
||||
}
|
||||
other => panic!("expected Snapshot, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -2399,6 +2507,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Running.into(),
|
||||
in_flight: InFlightSnapshot {
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::Event as WorkerProtocolEvent;
|
||||
|
||||
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1;
|
||||
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 2;
|
||||
/// Browser/runtime wire guardrails. These bounds are generated into the Workspace
|
||||
/// Browser validator so aggregate limits remain part of the Rust-owned contract.
|
||||
pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||
@@ -426,7 +426,6 @@ pub enum SubscriptionResponse {
|
||||
request_id: SubscriptionRequestId,
|
||||
subscription_id: SubscriptionId,
|
||||
selector: EventSubscriptionSelector,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
},
|
||||
Unsubscribed {
|
||||
@@ -512,7 +511,6 @@ pub enum SubscriptionTerminationCode {
|
||||
pub enum SubscriptionEvent {
|
||||
Event {
|
||||
subscription_id: SubscriptionId,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
SubscriptionClosed {
|
||||
@@ -666,9 +664,7 @@ pub struct SubscriptionWorker {
|
||||
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
||||
#[serde(default)]
|
||||
pub availability: SubscriptionWorkerAvailability,
|
||||
/// Producer-owned monotonic revision for this Worker subject.
|
||||
pub subject_revision: u64,
|
||||
/// Latest revisioned foreground state observed from the Worker. This remains
|
||||
/// Latest foreground state observed from the Worker. This remains
|
||||
/// absent until an authoritative Worker snapshot/event has been applied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<crate::WorkerStateSnapshot>,
|
||||
@@ -970,7 +966,6 @@ mod tests {
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
availability: SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 0,
|
||||
worker_state: None,
|
||||
state: SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
@@ -1093,7 +1088,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"frame": "request",
|
||||
"message": {
|
||||
"method": "subscribe_events",
|
||||
@@ -1119,7 +1114,6 @@ mod tests {
|
||||
request_id: request_id(),
|
||||
subscription_id: subscription_id(),
|
||||
selector: EventSubscriptionSelector::RuntimeWorkers,
|
||||
snapshot_revision: 7,
|
||||
snapshot: SubscriptionSnapshot::Workers {
|
||||
workers: vec![worker("worker-1")],
|
||||
},
|
||||
@@ -1158,10 +1152,10 @@ mod tests {
|
||||
subscription_id: subscription_id(),
|
||||
},
|
||||
));
|
||||
frame.protocol_version = 2;
|
||||
frame.protocol_version = 1;
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 2 })
|
||||
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1180,7 +1174,6 @@ mod tests {
|
||||
|
||||
let event = SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id(),
|
||||
subject_revision: 8,
|
||||
payload: SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id: worker_id("worker-2"),
|
||||
runtime_id: None,
|
||||
@@ -1211,7 +1204,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::to_value(frame).unwrap(),
|
||||
serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"frame": "worker_protocol",
|
||||
"message": {
|
||||
"subscription_id": "subscription-1",
|
||||
@@ -1253,7 +1246,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::to_value(frame).unwrap(),
|
||||
serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"frame": "event",
|
||||
"message": {
|
||||
"event": "subscription_closed",
|
||||
|
||||
@@ -6,17 +6,18 @@ use ts_rs::{Config, TS};
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
|
||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||
RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
|
||||
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
||||
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
|
||||
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
|
||||
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
|
||||
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
|
||||
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
|
||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
@@ -116,10 +117,13 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<ReasoningConfig>(&cfg, &mut output);
|
||||
push_decl::<ContextTokenSource>(&cfg, &mut output);
|
||||
push_decl::<ContextUsage>(&cfg, &mut output);
|
||||
push_decl::<Greeting>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||
push_decl::<Greeting>(&cfg, &mut output);
|
||||
push_decl::<Alert>(&cfg, &mut output);
|
||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
|
||||
@@ -230,7 +234,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
runtime_id: Some("fixture-runtime".to_string()),
|
||||
resource_key: Some("fixture-worker-resource".to_string()),
|
||||
availability: SubscriptionWorkerAvailability::default(),
|
||||
subject_revision: 1,
|
||||
worker_state: None,
|
||||
state: SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
@@ -250,7 +253,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||
snapshot_revision: 1,
|
||||
snapshot: SubscriptionSnapshot::Workers {
|
||||
workers: vec![worker],
|
||||
},
|
||||
@@ -258,21 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
));
|
||||
let pending =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
subject_revision: 2,
|
||||
subscription_id: subscription_id.clone(),
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id,
|
||||
worker_id: worker_id.clone(),
|
||||
event: Event::PendingSubmissionsChanged {
|
||||
pending: PendingSubmissionsSnapshot::default(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
for fixture in [&subscribed, &pending] {
|
||||
let greeting = Greeting {
|
||||
worker_name: "fixture-worker".into(),
|
||||
cwd: "/fixture".into(),
|
||||
provider: "openai_responses".into(),
|
||||
model: "gpt-6-astra".into(),
|
||||
reasoning: Some(ReasoningConfig::Effort {
|
||||
effort: "high".into(),
|
||||
}),
|
||||
scope_summary: "fixture scope".into(),
|
||||
tools: vec!["Read".into()],
|
||||
context_window: 272_000,
|
||||
context_tokens: 142_000,
|
||||
context_usage: Some(ContextUsage {
|
||||
tokens: 142_000,
|
||||
source: ContextTokenSource::Measured,
|
||||
}),
|
||||
};
|
||||
let snapshot =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id,
|
||||
event: Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: greeting.clone(),
|
||||
state: WorkerStateSnapshot::initial(),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: vec![InternalWorkerSnapshot {
|
||||
worker: InternalWorkerRef {
|
||||
session_id: "fixture-child-session".into(),
|
||||
name: "fixture-child".into(),
|
||||
parent_session_id: Some("fixture-parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: Some(greeting),
|
||||
status: WorkerStatus::Idle,
|
||||
error: None,
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: Vec::new(),
|
||||
}],
|
||||
},
|
||||
},
|
||||
}));
|
||||
for fixture in [&subscribed, &pending, &snapshot] {
|
||||
fixture
|
||||
.validate()
|
||||
.expect("Rust-serialized Browser compatibility fixture must validate");
|
||||
}
|
||||
vec![subscribed, pending]
|
||||
vec![subscribed, pending, snapshot]
|
||||
}
|
||||
|
||||
fn close_object_shapes(schema: &mut Value) {
|
||||
|
||||
@@ -444,6 +444,10 @@ pub struct CreateWorkerRequest {
|
||||
pub workspace_api: Option<WorkspaceApiRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
/// Backend-attested subject or consolidation attachment. Profile policy alone
|
||||
/// never activates subjektiv for an ordinary Workspace Worker.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub subjektiv_attached: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -692,6 +696,183 @@ pub enum WorkerSessionHistoryAvailability {
|
||||
},
|
||||
}
|
||||
|
||||
/// Backend-selected storage source. This type is never exposed as model tool
|
||||
/// input; Runtime paths remain behind the authenticated Workspace capability.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "storage", rename_all = "snake_case")]
|
||||
pub enum SessionPublicSource {
|
||||
Retained { worker_id: WorkerId },
|
||||
Archived { archive_id: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPublicEntryKind {
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPublicToolPart {
|
||||
Input,
|
||||
Output,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPublicReadMode {
|
||||
Compact,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPublicLineageKind {
|
||||
Root,
|
||||
Fork,
|
||||
Compact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPublicLineage {
|
||||
pub kind: SessionPublicLineageKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_segment_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub at_turn_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPublicSearchRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_generation: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub query: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<SessionPublicEntryKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_name: Option<String>,
|
||||
pub tool_part: SessionPublicToolPart,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scan_cursor: Option<String>,
|
||||
pub limit: usize,
|
||||
pub max_scan_bytes: u64,
|
||||
pub max_segments: usize,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPublicReadRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
pub expected_generation: Option<String>,
|
||||
pub segment_id: String,
|
||||
pub entry_ref: String,
|
||||
pub mode: SessionPublicReadMode,
|
||||
pub byte_offset: usize,
|
||||
pub max_content_bytes: usize,
|
||||
pub max_scan_bytes: u64,
|
||||
pub max_segments: usize,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPublicSearchItem {
|
||||
pub segment_id: String,
|
||||
pub entry_ref: String,
|
||||
pub kind: SessionPublicEntryKind,
|
||||
pub origin: protocol::SessionEntryProvenance,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_part: Option<SessionPublicToolPart>,
|
||||
pub compact: String,
|
||||
pub compact_truncated: bool,
|
||||
pub lineage: SessionPublicLineage,
|
||||
/// Opaque Runtime continuation immediately before this result.
|
||||
pub scan_cursor: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPublicSearchPage {
|
||||
pub session_id: String,
|
||||
pub generation: String,
|
||||
pub items: Vec<SessionPublicSearchItem>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub next_scan_cursor: Option<String>,
|
||||
pub has_more: bool,
|
||||
pub scanned_bytes: u64,
|
||||
pub scanned_segments: usize,
|
||||
pub scanned_entries: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub archive_manifest: Option<WorkerSessionArchiveManifest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPublicReadPage {
|
||||
pub session_id: String,
|
||||
pub generation: String,
|
||||
pub segment_id: String,
|
||||
pub entry_ref: String,
|
||||
pub kind: SessionPublicEntryKind,
|
||||
pub origin: protocol::SessionEntryProvenance,
|
||||
pub lineage: SessionPublicLineage,
|
||||
pub mode: SessionPublicReadMode,
|
||||
pub content: String,
|
||||
pub next_byte_offset: Option<usize>,
|
||||
pub has_more: bool,
|
||||
pub scanned_bytes: u64,
|
||||
pub scanned_segments: usize,
|
||||
pub scanned_entries: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub archive_manifest: Option<WorkerSessionArchiveManifest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPublicUnavailableReason {
|
||||
NotFound,
|
||||
RetentionMissing,
|
||||
RetentionExpired,
|
||||
ArchiveIncomplete,
|
||||
CorruptLog,
|
||||
MigrationRequired,
|
||||
StorageUnavailable,
|
||||
InvalidCursor,
|
||||
ResourceLimit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "availability", rename_all = "snake_case")]
|
||||
pub enum SessionPublicSearchAvailability {
|
||||
Page {
|
||||
page: SessionPublicSearchPage,
|
||||
},
|
||||
Unavailable {
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "availability", rename_all = "snake_case")]
|
||||
pub enum SessionPublicReadAvailability {
|
||||
Page {
|
||||
page: SessionPublicReadPage,
|
||||
},
|
||||
Unavailable {
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RetainedSessionIdentity {
|
||||
pub session_id: String,
|
||||
@@ -766,6 +947,18 @@ pub trait RuntimeApi {
|
||||
#[query] request: WorkerSessionHistoryRequest,
|
||||
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/session-public/search", status = 200, error_status = 400)]
|
||||
async fn session_public_search(
|
||||
&self,
|
||||
#[body] request: SessionPublicSearchRequest,
|
||||
) -> Result<SessionPublicSearchAvailability, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/session-public/read", status = 200, error_status = 400)]
|
||||
async fn session_public_read(
|
||||
&self,
|
||||
#[body] request: SessionPublicReadRequest,
|
||||
) -> Result<SessionPublicReadAvailability, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/workers", status = 200, error_status = 400)]
|
||||
async fn create_worker(
|
||||
&self,
|
||||
@@ -1112,6 +1305,26 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
async fn session_public_search(
|
||||
&self,
|
||||
_request: SessionPublicSearchRequest,
|
||||
) -> Result<SessionPublicSearchAvailability, RuntimeApiError> {
|
||||
Ok(SessionPublicSearchAvailability::Unavailable {
|
||||
reason: SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "unsupported".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn session_public_read(
|
||||
&self,
|
||||
_request: SessionPublicReadRequest,
|
||||
) -> Result<SessionPublicReadAvailability, RuntimeApiError> {
|
||||
Ok(SessionPublicReadAvailability::Unavailable {
|
||||
reason: SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "unsupported".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_worker(
|
||||
&self,
|
||||
_request: CreateWorkerRequest,
|
||||
@@ -1248,7 +1461,7 @@ mod tests {
|
||||
#[test]
|
||||
fn contract_inventory_is_complete_and_unique() {
|
||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||
assert_eq!(operations.len(), 17);
|
||||
assert_eq!(operations.len(), 19);
|
||||
let mut routes = operations
|
||||
.iter()
|
||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||
|
||||
+1382
-1
File diff suppressed because it is too large
Load Diff
@@ -211,7 +211,11 @@ impl FsStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
|
||||
fn parse_jsonl(
|
||||
content: &[u8],
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<Vec<LogEntry>, StoreError> {
|
||||
let complete = Self::complete_jsonl_prefix(content);
|
||||
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||
line: complete[..error.valid_up_to()]
|
||||
@@ -222,15 +226,19 @@ impl FsStore {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let mut entries = Vec::new();
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
let mut record_start = 0_u64;
|
||||
for (index, record) in content.split_inclusive('\n').enumerate() {
|
||||
let line = record.strip_suffix('\n').unwrap_or(record);
|
||||
if !line.trim().is_empty() {
|
||||
let mut entry: LogEntry =
|
||||
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
|
||||
line: index + 1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
|
||||
entries.push(entry);
|
||||
}
|
||||
let entry: T = serde_json::from_str(line).map_err(|e| StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
entries.push(entry);
|
||||
record_start = record_start.saturating_add(record.len() as u64);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -294,7 +302,7 @@ impl Store for FsStore {
|
||||
return Err(StoreError::NotFound(segment_id));
|
||||
}
|
||||
let content = fs::read(&path)?;
|
||||
Self::parse_jsonl(&content)
|
||||
Self::parse_jsonl(&content, session_id, segment_id)
|
||||
}
|
||||
|
||||
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||
|
||||
@@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::LoggedItem;
|
||||
|
||||
/// Stable logical identity of one model-visible history entry.
|
||||
/// Stable logical identity of one durable public Session entry.
|
||||
///
|
||||
/// Model-visible history entries and typed Run transitions share this identity
|
||||
/// space so every projection can reconcile the same committed record.
|
||||
///
|
||||
/// This value is generated at the trusted Worker session boundary and copied
|
||||
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod history;
|
||||
mod legacy_session_log;
|
||||
pub mod logged_item;
|
||||
mod paste_artifact;
|
||||
pub mod public_index;
|
||||
pub mod public_snapshot;
|
||||
pub mod segment;
|
||||
pub mod segment_log;
|
||||
@@ -95,6 +96,16 @@ pub use history::{
|
||||
};
|
||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||
pub use paste_artifact::PasteArtifactLimits;
|
||||
pub use public_index::{
|
||||
DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES,
|
||||
DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES,
|
||||
SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind,
|
||||
SessionPublicIndexEntryRead, SessionPublicIndexLimits, SessionPublicIndexLineage,
|
||||
SessionPublicIndexOriginKind, SessionPublicIndexPage, SessionPublicIndexPageEntry,
|
||||
SessionPublicIndexReadError, SessionPublicIndexScanPosition, SessionPublicIndexSegment,
|
||||
SessionPublicIndexToolPart, read_session_public_index, read_session_public_index_entry,
|
||||
read_session_public_index_page,
|
||||
};
|
||||
pub use public_snapshot::{
|
||||
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -978,7 +978,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
|
||||
project_session_snapshot_for_segment(session_id, None, log)
|
||||
}
|
||||
|
||||
fn project_session_snapshot_for_segment(
|
||||
pub(crate) fn project_session_snapshot_for_segment(
|
||||
session_id: SessionId,
|
||||
segment_id: Option<SegmentId>,
|
||||
log: &[LogEntry],
|
||||
@@ -1041,15 +1041,16 @@ fn project_session_snapshot_for_segment(
|
||||
)),
|
||||
LogEntry::RunYielded {
|
||||
ts,
|
||||
entry_id,
|
||||
reason,
|
||||
active_run_turn_count: count,
|
||||
} => {
|
||||
active_run_turn_count = Some(*count);
|
||||
entries.push(legacy_entry(
|
||||
entries.push(run_transition_entry(
|
||||
entry_id.as_ref(),
|
||||
&session_key,
|
||||
segment_id.as_ref(),
|
||||
log_index,
|
||||
0,
|
||||
*ts,
|
||||
SessionSnapshotEntryData::RunYielded {
|
||||
reason: *reason,
|
||||
@@ -1059,15 +1060,16 @@ fn project_session_snapshot_for_segment(
|
||||
}
|
||||
LogEntry::RunResumed {
|
||||
ts,
|
||||
entry_id,
|
||||
source,
|
||||
active_run_turn_count: count,
|
||||
} => {
|
||||
active_run_turn_count = Some(*count);
|
||||
entries.push(legacy_entry(
|
||||
entries.push(run_transition_entry(
|
||||
entry_id.as_ref(),
|
||||
&session_key,
|
||||
segment_id.as_ref(),
|
||||
log_index,
|
||||
0,
|
||||
*ts,
|
||||
SessionSnapshotEntryData::RunResumed {
|
||||
source: *source,
|
||||
@@ -1075,29 +1077,30 @@ fn project_session_snapshot_for_segment(
|
||||
},
|
||||
));
|
||||
}
|
||||
LogEntry::RunCancelled { ts } => {
|
||||
LogEntry::RunCancelled { ts, entry_id } => {
|
||||
active_run_turn_count = None;
|
||||
entries.push(legacy_entry(
|
||||
entries.push(run_transition_entry(
|
||||
entry_id.as_ref(),
|
||||
&session_key,
|
||||
segment_id.as_ref(),
|
||||
log_index,
|
||||
0,
|
||||
*ts,
|
||||
SessionSnapshotEntryData::RunCancelled,
|
||||
));
|
||||
}
|
||||
LogEntry::RunErrored {
|
||||
ts,
|
||||
entry_id,
|
||||
message,
|
||||
failure,
|
||||
..
|
||||
} => {
|
||||
active_run_turn_count = None;
|
||||
entries.push(legacy_entry(
|
||||
entries.push(run_transition_entry(
|
||||
entry_id.as_ref(),
|
||||
&session_key,
|
||||
segment_id.as_ref(),
|
||||
log_index,
|
||||
0,
|
||||
*ts,
|
||||
SessionSnapshotEntryData::RunError {
|
||||
message: message.clone(),
|
||||
@@ -1234,6 +1237,25 @@ fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn run_transition_entry(
|
||||
persisted_entry_id: Option<&crate::LoggedSessionHistoryEntryId>,
|
||||
session_key: &SessionId,
|
||||
segment_id: Option<&SegmentId>,
|
||||
log_index: usize,
|
||||
timestamp: u64,
|
||||
data: SessionSnapshotEntryData,
|
||||
) -> SessionSnapshotEntry {
|
||||
SessionSnapshotEntry {
|
||||
entry_id: persisted_entry_id
|
||||
.map(|entry_id| entry_id.0.clone())
|
||||
.unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)),
|
||||
timestamp,
|
||||
provenance: SessionEntryProvenance::LegacyUnknown,
|
||||
derived_from: Vec::new(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_entry(
|
||||
session_key: &SessionId,
|
||||
segment_id: Option<&SegmentId>,
|
||||
@@ -2812,22 +2834,344 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn run_entry_ids(entries: &[SessionSnapshotEntry]) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.data,
|
||||
SessionSnapshotEntryData::RunYielded { .. }
|
||||
| SessionSnapshotEntryData::RunResumed { .. }
|
||||
| SessionSnapshotEntryData::RunCancelled
|
||||
| SessionSnapshotEntryData::RunError { .. }
|
||||
)
|
||||
})
|
||||
.map(|entry| entry.entry_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_run_transition_ids_match_snapshot_retained_and_paged_history() {
|
||||
let worker_name = "worker-run-identities";
|
||||
let session_id = crate::new_session_id();
|
||||
let segment_id = crate::new_segment_id();
|
||||
let initial = vec![
|
||||
segment_start(session_id, Vec::new(), None, None),
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
segments: vec![Segment::text("compact")],
|
||||
history: vec![user_message(1)],
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
];
|
||||
let (_root, aggregate_root) = persist_history_fixture(
|
||||
worker_name,
|
||||
session_id,
|
||||
segment_id,
|
||||
vec![(segment_id, initial)],
|
||||
);
|
||||
let store = WorkerSessionStore::new(aggregate_root.join("session")).unwrap();
|
||||
let transitions = [
|
||||
LogEntry::RunYielded {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
reason: protocol::RunYieldReason::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
LogEntry::RunResumed {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
source: protocol::RunResumeSource::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "mid-run compaction failed: unavailable".into(),
|
||||
failure: Some(protocol::RunFailureKind::Compaction),
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "mid-run compaction failed: unavailable".into(),
|
||||
failure: Some(protocol::RunFailureKind::Compaction),
|
||||
},
|
||||
LogEntry::RunCancelled {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
},
|
||||
];
|
||||
for entry in transitions {
|
||||
crate::segment::append_entry(&store, session_id, segment_id, entry).unwrap();
|
||||
}
|
||||
|
||||
let stored = store.read_all(session_id, segment_id).unwrap();
|
||||
let stored_ids = run_entry_ids(
|
||||
&project_session_snapshot_for_segment(session_id, Some(segment_id), &stored).entries,
|
||||
);
|
||||
assert_eq!(stored_ids.len(), 5);
|
||||
assert_eq!(stored_ids.iter().collect::<HashSet<_>>().len(), 5);
|
||||
assert!(
|
||||
stored_ids
|
||||
.iter()
|
||||
.all(|entry_id| !entry_id.starts_with("l-run-"))
|
||||
);
|
||||
|
||||
let retained = read_retained_session_snapshot(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(run_entry_ids(&retained.snapshot.entries), stored_ids);
|
||||
|
||||
let page = read_retained_session_history_page(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
None,
|
||||
Some(1),
|
||||
RetainedHistoryReadLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page.turns.len(), 1);
|
||||
assert_eq!(run_entry_ids(&page.turns[0].entries), stored_ids);
|
||||
|
||||
let reconnect = read_retained_session_snapshot(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(run_entry_ids(&reconnect.snapshot.entries), stored_ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_transition_ids_survive_segment_lineage_and_history_page_boundaries() {
|
||||
let worker_name = "worker-run-lineage-identities";
|
||||
let session_id = crate::new_session_id();
|
||||
let source_segment = crate::new_segment_id();
|
||||
let active_segment = crate::new_segment_id();
|
||||
let source = vec![
|
||||
segment_start(session_id, Vec::new(), None, None),
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
segments: vec![Segment::text("source turn")],
|
||||
history: vec![user_message(1)],
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
];
|
||||
let (_root, aggregate_root) = persist_history_fixture(
|
||||
worker_name,
|
||||
session_id,
|
||||
source_segment,
|
||||
vec![(source_segment, source)],
|
||||
);
|
||||
let store = WorkerSessionStore::new(aggregate_root.join("session")).unwrap();
|
||||
crate::segment::append_entry(
|
||||
&store,
|
||||
session_id,
|
||||
source_segment,
|
||||
LogEntry::RunYielded {
|
||||
ts: 3,
|
||||
entry_id: None,
|
||||
reason: protocol::RunYieldReason::Compaction,
|
||||
active_run_turn_count: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
source_segment,
|
||||
&LogEntry::TurnEnd {
|
||||
ts: 4,
|
||||
turn_count: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
active_segment,
|
||||
&[
|
||||
segment_start(
|
||||
session_id,
|
||||
vec![user_message(1)],
|
||||
None,
|
||||
Some(SegmentOrigin {
|
||||
segment_id: source_segment,
|
||||
at_turn_index: 1,
|
||||
}),
|
||||
),
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 5,
|
||||
segments: vec![Segment::text("active turn")],
|
||||
history: vec![user_message(2)],
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
for entry in [
|
||||
LogEntry::RunResumed {
|
||||
ts: 6,
|
||||
entry_id: None,
|
||||
source: protocol::RunResumeSource::Compaction,
|
||||
active_run_turn_count: 1,
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 7,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "mid-run compaction failed".into(),
|
||||
failure: Some(protocol::RunFailureKind::Compaction),
|
||||
},
|
||||
] {
|
||||
crate::segment::append_entry(&store, session_id, active_segment, entry).unwrap();
|
||||
}
|
||||
WorkerAggregateStore::new(&aggregate_root, worker_name)
|
||||
.unwrap()
|
||||
.write(&crate::WorkerMetadata::new(
|
||||
worker_name,
|
||||
Some(crate::WorkerActiveSegmentRef::active_segment(
|
||||
session_id,
|
||||
active_segment,
|
||||
)),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let active_ids = run_entry_ids(
|
||||
&project_session_snapshot_for_segment(
|
||||
session_id,
|
||||
Some(active_segment),
|
||||
&store.read_all(session_id, active_segment).unwrap(),
|
||||
)
|
||||
.entries,
|
||||
);
|
||||
let source_ids = run_entry_ids(
|
||||
&project_session_snapshot_for_segment(
|
||||
session_id,
|
||||
Some(source_segment),
|
||||
&store.read_all(session_id, source_segment).unwrap(),
|
||||
)
|
||||
.entries,
|
||||
);
|
||||
assert_eq!(active_ids.len(), 2);
|
||||
assert_eq!(source_ids.len(), 1);
|
||||
|
||||
let newest = read_retained_session_history_page(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
None,
|
||||
Some(1),
|
||||
RetainedHistoryReadLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(run_entry_ids(&newest.turns[0].entries), active_ids);
|
||||
assert!(newest.has_more);
|
||||
let older = read_retained_session_history_page(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
newest.next_cursor.as_deref(),
|
||||
Some(1),
|
||||
RetainedHistoryReadLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(run_entry_ids(&older.turns[0].entries), source_ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_run_transition_ids_use_stable_distinct_record_positions() {
|
||||
let worker_name = "worker-legacy-run-identities";
|
||||
let session_id = crate::new_session_id();
|
||||
let segment_id = crate::new_segment_id();
|
||||
let log = vec![
|
||||
segment_start(session_id, Vec::new(), None, None),
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
segments: vec![Segment::text("legacy")],
|
||||
history: vec![user_message(1)],
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "same legacy failure".into(),
|
||||
failure: None,
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "same legacy failure".into(),
|
||||
failure: None,
|
||||
},
|
||||
];
|
||||
let (_root, aggregate_root) =
|
||||
persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]);
|
||||
let log_path = aggregate_root
|
||||
.join("session")
|
||||
.join("segments")
|
||||
.join(format!("{segment_id}.jsonl"));
|
||||
let persisted_before = std::fs::read(&log_path).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&persisted_before).contains("entry_id\":\"l-run-"));
|
||||
|
||||
let retained = read_retained_session_snapshot(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot_ids = run_entry_ids(&retained.snapshot.entries);
|
||||
assert_eq!(snapshot_ids.len(), 2);
|
||||
assert_ne!(snapshot_ids[0], snapshot_ids[1]);
|
||||
assert!(
|
||||
snapshot_ids
|
||||
.iter()
|
||||
.all(|entry_id| entry_id.starts_with("l-run-"))
|
||||
);
|
||||
|
||||
let page = read_retained_session_history_page(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
None,
|
||||
Some(1),
|
||||
RetainedHistoryReadLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(run_entry_ids(&page.turns[0].entries), snapshot_ids);
|
||||
let reread = read_retained_session_snapshot(
|
||||
&aggregate_root,
|
||||
worker_name,
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(run_entry_ids(&reread.snapshot.entries), snapshot_ids);
|
||||
assert_eq!(std::fs::read(log_path).unwrap(), persisted_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_transitions_project_with_timestamps_and_typed_causes() {
|
||||
let session_id = crate::new_session_id();
|
||||
let log = vec![
|
||||
LogEntry::RunYielded {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
reason: protocol::RunYieldReason::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
LogEntry::RunResumed {
|
||||
ts: 20,
|
||||
entry_id: None,
|
||||
source: protocol::RunResumeSource::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 30,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "mid-run compaction failed: unavailable".into(),
|
||||
failure: Some(protocol::RunFailureKind::Compaction),
|
||||
@@ -2869,7 +3213,10 @@ mod tests {
|
||||
ts: 10,
|
||||
trigger: protocol::InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::RunCancelled { ts: 20 },
|
||||
LogEntry::RunCancelled {
|
||||
ts: 20,
|
||||
entry_id: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ pub fn save_run_yielded(
|
||||
segment_id,
|
||||
LogEntry::RunYielded {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
reason,
|
||||
active_run_turn_count,
|
||||
},
|
||||
@@ -367,6 +368,7 @@ pub fn save_run_resumed(
|
||||
segment_id,
|
||||
LogEntry::RunResumed {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
source,
|
||||
active_run_turn_count,
|
||||
},
|
||||
@@ -390,6 +392,7 @@ pub fn save_run_errored(
|
||||
segment_id,
|
||||
LogEntry::RunErrored {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
interrupted,
|
||||
message,
|
||||
failure: Some(RunFailureKind::Engine),
|
||||
@@ -568,7 +571,8 @@ pub fn append_entry(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
entry: LogEntry,
|
||||
mut entry: LogEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
entry.ensure_session_entry_id();
|
||||
store.append(session_id, segment_id, &entry)
|
||||
}
|
||||
|
||||
@@ -140,6 +140,12 @@ pub enum LogEntry {
|
||||
/// preceding Invoke and a later RunResumed or terminal record.
|
||||
RunYielded {
|
||||
ts: u64,
|
||||
/// Stable public identity allocated before the record is committed.
|
||||
/// `None` is accepted only when reading logs written before this field
|
||||
/// existed; read paths derive the same legacy identity from the durable
|
||||
/// record position without rewriting the log.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
|
||||
reason: RunYieldReason,
|
||||
active_run_turn_count: usize,
|
||||
},
|
||||
@@ -149,19 +155,27 @@ pub enum LogEntry {
|
||||
/// It deliberately does not assert that a subsequent model call occurred.
|
||||
RunResumed {
|
||||
ts: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
|
||||
source: RunResumeSource,
|
||||
active_run_turn_count: usize,
|
||||
},
|
||||
|
||||
/// The logical Run ended because the user intentionally cancelled it.
|
||||
/// This is terminal, but is not an execution failure.
|
||||
RunCancelled { ts: u64 },
|
||||
RunCancelled {
|
||||
ts: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
|
||||
},
|
||||
|
||||
/// `run()` / `resume()` が `EngineError` で終了した。
|
||||
/// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。
|
||||
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
||||
RunErrored {
|
||||
ts: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
|
||||
interrupted: bool,
|
||||
message: String,
|
||||
/// Older records omit this field. New records distinguish failures in
|
||||
@@ -236,6 +250,48 @@ pub struct SegmentOrigin {
|
||||
pub at_turn_index: usize,
|
||||
}
|
||||
|
||||
impl LogEntry {
|
||||
/// Allocate the durable public identity for a newly committed Run
|
||||
/// transition. Calling this repeatedly is idempotent so the exact value
|
||||
/// written to storage is also the value published to live subscribers.
|
||||
pub fn ensure_session_entry_id(&mut self) {
|
||||
let entry_id = match self {
|
||||
Self::RunYielded { entry_id, .. }
|
||||
| Self::RunResumed { entry_id, .. }
|
||||
| Self::RunCancelled { entry_id, .. }
|
||||
| Self::RunErrored { entry_id, .. } => entry_id,
|
||||
_ => return,
|
||||
};
|
||||
if entry_id.is_none() {
|
||||
*entry_id = Some(crate::LoggedSessionHistoryEntryId::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Supply an in-memory compatibility identity for a legacy Run transition
|
||||
/// that predates persisted entry IDs. The physical record position is
|
||||
/// stable within the append-only Segment and distinguishes equal payloads.
|
||||
/// This helper never writes the synthesized value back to the log.
|
||||
pub(crate) fn ensure_legacy_session_entry_id(
|
||||
&mut self,
|
||||
session_id: crate::SessionId,
|
||||
segment_id: crate::SegmentId,
|
||||
record_start: u64,
|
||||
) {
|
||||
let entry_id = match self {
|
||||
Self::RunYielded { entry_id, .. }
|
||||
| Self::RunResumed { entry_id, .. }
|
||||
| Self::RunCancelled { entry_id, .. }
|
||||
| Self::RunErrored { entry_id, .. } => entry_id,
|
||||
_ => return,
|
||||
};
|
||||
if entry_id.is_none() {
|
||||
*entry_id = Some(crate::LoggedSessionHistoryEntryId(format!(
|
||||
"l-run-{session_id}-{segment_id}-{record_start}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State collected from log entries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RestoredState {
|
||||
@@ -878,6 +934,7 @@ mod tests {
|
||||
},
|
||||
LogEntry::RunYielded {
|
||||
ts: 300,
|
||||
entry_id: None,
|
||||
reason: RunYieldReason::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
@@ -888,6 +945,7 @@ mod tests {
|
||||
},
|
||||
LogEntry::RunResumed {
|
||||
ts: 500,
|
||||
entry_id: None,
|
||||
source: RunResumeSource::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
@@ -909,7 +967,10 @@ mod tests {
|
||||
ts: 200,
|
||||
turn_count: 1,
|
||||
},
|
||||
LogEntry::RunCancelled { ts: 300 },
|
||||
LogEntry::RunCancelled {
|
||||
ts: 300,
|
||||
entry_id: None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!state.last_run_interrupted);
|
||||
|
||||
@@ -157,6 +157,16 @@ pub enum SystemItem {
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// Restore-boundary refresh of the current resident Memory projection.
|
||||
/// The exact rendered body is retained so replay is byte-identical; a
|
||||
/// refresh with no ready summary explicitly supersedes older resident
|
||||
/// context without deleting or rewriting history.
|
||||
ResidentSummaryRefresh {
|
||||
body: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// Synthetic note inserted after an interrupted turn before the next
|
||||
/// user input. `body` is the exact LLM-context text explaining that the
|
||||
/// previous turn was cut short.
|
||||
@@ -181,6 +191,7 @@ impl SystemItem {
|
||||
format!("Ignored legacy procedure item: /{slug}")
|
||||
}
|
||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||
}
|
||||
}
|
||||
@@ -202,6 +213,7 @@ impl SystemItem {
|
||||
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||
SystemItem::Interrupt { .. } => "interrupt",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ pub(crate) struct RetainedLogRecord {
|
||||
|
||||
pub(crate) struct RetainedSegmentReader {
|
||||
file: File,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
read_end: u64,
|
||||
buffer_start: u64,
|
||||
buffer: Vec<u8>,
|
||||
@@ -103,7 +105,12 @@ impl RetainedSegmentReader {
|
||||
message: "empty retained Segment log record".to_string(),
|
||||
});
|
||||
}
|
||||
let entry = serde_json::from_slice(&record)?;
|
||||
let mut entry: LogEntry = serde_json::from_slice(&record)?;
|
||||
entry.ensure_legacy_session_entry_id(
|
||||
self.session_id,
|
||||
self.segment_id,
|
||||
record_start,
|
||||
);
|
||||
return Ok((
|
||||
Some(RetainedLogRecord {
|
||||
start_offset: record_start,
|
||||
@@ -255,7 +262,10 @@ impl WorkerSessionStore {
|
||||
if byte_len > max_bytes {
|
||||
return Err(StoreError::ReadLimitExceeded);
|
||||
}
|
||||
Ok((parse_jsonl(&bytes)?, byte_len))
|
||||
Ok((
|
||||
parse_session_log_jsonl(&bytes, session_id, segment_id)?,
|
||||
byte_len,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_first_log_record_read_only_bounded(
|
||||
@@ -264,6 +274,7 @@ impl WorkerSessionStore {
|
||||
segment_id: SegmentId,
|
||||
max_bytes: u64,
|
||||
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
|
||||
let record_start = 0;
|
||||
self.validate_retained_session(session_id)?;
|
||||
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
@@ -289,7 +300,8 @@ impl WorkerSessionStore {
|
||||
message: "empty retained Segment log record".to_string(),
|
||||
});
|
||||
}
|
||||
let entry = serde_json::from_slice(&record)?;
|
||||
let mut entry: LogEntry = serde_json::from_slice(&record)?;
|
||||
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
|
||||
Ok((
|
||||
Some(RetainedLogRecord {
|
||||
start_offset: 0,
|
||||
@@ -308,6 +320,7 @@ impl WorkerSessionStore {
|
||||
start_offset: u64,
|
||||
max_bytes: u64,
|
||||
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
|
||||
let record_start = start_offset;
|
||||
self.validate_retained_session(session_id)?;
|
||||
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
@@ -334,7 +347,8 @@ impl WorkerSessionStore {
|
||||
message: "empty retained Segment log record".to_string(),
|
||||
});
|
||||
}
|
||||
let entry = serde_json::from_slice(&record)?;
|
||||
let mut entry: LogEntry = serde_json::from_slice(&record)?;
|
||||
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
|
||||
Ok((
|
||||
Some(RetainedLogRecord {
|
||||
start_offset,
|
||||
@@ -353,6 +367,7 @@ impl WorkerSessionStore {
|
||||
end_offset: u64,
|
||||
max_bytes: u64,
|
||||
) -> Result<RetainedLogRecord, StoreError> {
|
||||
let record_start = start_offset;
|
||||
self.validate_retained_session(session_id)?;
|
||||
let record_len =
|
||||
end_offset
|
||||
@@ -387,7 +402,8 @@ impl WorkerSessionStore {
|
||||
});
|
||||
}
|
||||
record.pop();
|
||||
let entry = serde_json::from_slice(&record)?;
|
||||
let mut entry: LogEntry = serde_json::from_slice(&record)?;
|
||||
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
|
||||
Ok(RetainedLogRecord {
|
||||
start_offset,
|
||||
end_offset,
|
||||
@@ -429,6 +445,8 @@ impl WorkerSessionStore {
|
||||
}
|
||||
Ok(RetainedSegmentReader {
|
||||
file,
|
||||
session_id,
|
||||
segment_id,
|
||||
read_end: before_offset,
|
||||
buffer_start: before_offset,
|
||||
buffer: Vec::new(),
|
||||
@@ -450,6 +468,39 @@ impl WorkerSessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enumerate canonical Segment logs in ascending identity order without
|
||||
/// creating or migrating state. Unlike the general [`Store`] listing, this
|
||||
/// observation boundary rejects malformed names and non-regular files so a
|
||||
/// persisted Segment cannot be silently omitted from a public index.
|
||||
pub(crate) fn list_segments_read_only(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<SegmentId>, StoreError> {
|
||||
self.validate_retained_session(session_id)?;
|
||||
segment_log_paths(&self.root).map(|paths| {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|(segment_id, _)| segment_id)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn segment_log_observation(
|
||||
&self,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<(u64, u128), StoreError> {
|
||||
let metadata = fs::metadata(self.log_path(segment_id))?;
|
||||
let modified = metadata
|
||||
.modified()?
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_err(|_| StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: "retained Segment modification time predates the Unix epoch".to_string(),
|
||||
})?
|
||||
.as_nanos();
|
||||
Ok((metadata.len(), modified))
|
||||
}
|
||||
|
||||
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
||||
Ok(fs::metadata(self.log_path(segment_id))?.len())
|
||||
}
|
||||
@@ -645,7 +696,7 @@ impl Store for WorkerSessionStore {
|
||||
if !path.exists() {
|
||||
return Err(StoreError::NotFound(segment_id));
|
||||
}
|
||||
parse_jsonl(&fs::read(path)?)
|
||||
parse_session_log_jsonl(&fs::read(path)?, session_id, segment_id)
|
||||
}
|
||||
|
||||
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||
@@ -1154,6 +1205,38 @@ fn complete_jsonl_prefix(content: &[u8]) -> &[u8] {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn parse_session_log_jsonl(
|
||||
content: &[u8],
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<Vec<LogEntry>, StoreError> {
|
||||
let complete = complete_jsonl_prefix(content);
|
||||
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||
line: complete[..error.valid_up_to()]
|
||||
.iter()
|
||||
.filter(|byte| **byte == b'\n')
|
||||
.count()
|
||||
+ 1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let mut entries = Vec::new();
|
||||
let mut record_start = 0_u64;
|
||||
for (index, record) in content.split_inclusive('\n').enumerate() {
|
||||
let line = record.strip_suffix('\n').unwrap_or(record);
|
||||
if !line.trim().is_empty() {
|
||||
let mut entry: LogEntry =
|
||||
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
|
||||
line: index + 1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
|
||||
entries.push(entry);
|
||||
}
|
||||
record_start = record_start.saturating_add(record.len() as u64);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
|
||||
let complete = complete_jsonl_prefix(content);
|
||||
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||
|
||||
+187
-10
@@ -247,6 +247,9 @@ pub struct App {
|
||||
/// Latest session context tokens reported by the Worker. This is the raw
|
||||
/// `input_tokens` value and is independent from per-run upload totals.
|
||||
pub session_context_tokens: u64,
|
||||
/// Availability and provenance for `session_context_tokens`. `None` means
|
||||
/// the current value is unknown and clients must not render a fabricated 0%.
|
||||
pub session_context_source: Option<protocol::ContextTokenSource>,
|
||||
pub context_window: u64,
|
||||
pub turn_index: usize,
|
||||
pub current_tool: Option<String>,
|
||||
@@ -352,6 +355,7 @@ impl App {
|
||||
run_upload_tokens: 0,
|
||||
run_output_tokens: 0,
|
||||
session_context_tokens: 0,
|
||||
session_context_source: None,
|
||||
context_window: 0,
|
||||
turn_index: 0,
|
||||
current_tool: None,
|
||||
@@ -1403,7 +1407,12 @@ impl App {
|
||||
output_tokens,
|
||||
cache_read_input_tokens,
|
||||
} => {
|
||||
self.session_context_tokens = input_tokens.unwrap_or(0);
|
||||
if let Some(input_tokens) = input_tokens {
|
||||
self.session_context_tokens = input_tokens;
|
||||
self.session_context_source = Some(protocol::ContextTokenSource::Measured);
|
||||
} else {
|
||||
self.session_context_source = None;
|
||||
}
|
||||
// Subtract the cache-hit portion so a tool loop that
|
||||
// re-sends the same prefix on every request doesn't
|
||||
// re-count it. cache_creation stays in (it is full
|
||||
@@ -1414,6 +1423,14 @@ impl App {
|
||||
self.run_upload_tokens += net_input;
|
||||
self.run_output_tokens += output_tokens.unwrap_or(0);
|
||||
}
|
||||
Event::ContextUsage { usage } => {
|
||||
if let Some(usage) = usage {
|
||||
self.session_context_tokens = usage.tokens;
|
||||
self.session_context_source = Some(usage.source);
|
||||
} else {
|
||||
self.session_context_source = None;
|
||||
}
|
||||
}
|
||||
Event::Error { code, message } => {
|
||||
self.handle_error(code, message);
|
||||
}
|
||||
@@ -1475,7 +1492,6 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
self.active_compaction = None;
|
||||
self.session_context_tokens = 0;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
.as_deref()
|
||||
@@ -2337,8 +2353,12 @@ impl App {
|
||||
) -> InternalWorkerView {
|
||||
let mut app = App::new(snapshot.worker.name.clone());
|
||||
app.mode = mode;
|
||||
app.restore_session(&snapshot.session, None);
|
||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||
if let Some(greeting) = snapshot.greeting {
|
||||
app.restore_snapshot(&snapshot.session, greeting, snapshot.in_flight);
|
||||
} else {
|
||||
app.restore_session(&snapshot.session, None);
|
||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||
}
|
||||
app.set_worker_status(snapshot.status);
|
||||
if let Some(error) = snapshot.error {
|
||||
let _ = app.handle_worker_event(Event::Error {
|
||||
@@ -2424,7 +2444,14 @@ impl App {
|
||||
) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
let context_usage = greeting.context_usage.or_else(|| {
|
||||
(greeting.context_window > 0).then_some(protocol::ContextUsage {
|
||||
tokens: greeting.context_tokens,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
})
|
||||
});
|
||||
self.session_context_tokens = context_usage.map(|usage| usage.tokens).unwrap_or_default();
|
||||
self.session_context_source = context_usage.map(|usage| usage.source);
|
||||
self.restore_session(session, Some(greeting));
|
||||
self.apply_in_flight_snapshot(in_flight);
|
||||
}
|
||||
@@ -2441,10 +2468,16 @@ impl App {
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let greeting = greeting.map(|mut greeting| {
|
||||
greeting.context_tokens = 0;
|
||||
greeting.context_usage = None;
|
||||
greeting
|
||||
});
|
||||
if let Some(greeting) = greeting.clone() {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.session_context_tokens = 0;
|
||||
self.session_context_source = None;
|
||||
}
|
||||
let missing_greeting = greeting.is_none();
|
||||
self.restore_session(session, greeting);
|
||||
@@ -2584,6 +2617,7 @@ impl App {
|
||||
}
|
||||
session_store::SystemItem::FileAttachment { body, .. }
|
||||
| session_store::SystemItem::SkillActivation { body, .. }
|
||||
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::SystemMessage { text: body });
|
||||
@@ -2835,6 +2869,9 @@ mod rewind_refresh_tests {
|
||||
assert!(!blocks_contain(&app, "old post-target output"));
|
||||
assert_eq!(composer_text(&app), "selected rewind input");
|
||||
assert!(blocks_contain(&app, "Rewound session"));
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
assert_eq!(app.session_context_source, None);
|
||||
assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2945,6 +2982,8 @@ mod rewind_refresh_tests {
|
||||
tools: vec![],
|
||||
context_window: 100,
|
||||
context_tokens: 10,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3800,6 +3839,7 @@ mod completion_flow_tests {
|
||||
|
||||
let run_errored = session_store::LogEntry::RunErrored {
|
||||
ts: 3,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "provider unavailable".into(),
|
||||
failure: None,
|
||||
@@ -4019,6 +4059,7 @@ mod completion_flow_tests {
|
||||
},
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
@@ -4029,6 +4070,76 @@ mod completion_flow_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_worker_snapshot_uses_child_greeting_metadata() {
|
||||
let mut app = App::new("parent".into());
|
||||
let mut child = test_internal_worker_snapshot("child", "research", 1);
|
||||
let mut greeting = test_greeting();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
greeting.context_window = 64_000;
|
||||
greeting.context_usage = Some(protocol::ContextUsage {
|
||||
tokens: 12_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
});
|
||||
child.greeting = Some(greeting);
|
||||
|
||||
app.replace_internal_worker_snapshots(vec![child]);
|
||||
|
||||
let child_app = &app.internal_workers[0].app;
|
||||
assert_eq!(child_app.greeting.as_ref().unwrap().model, "child-model");
|
||||
assert_eq!(child_app.context_window, 64_000);
|
||||
assert_eq!(child_app.session_context_tokens, 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
|
||||
let mut app = App::new("parent".into());
|
||||
let worker = test_internal_worker_snapshot("child", "research", 1).worker;
|
||||
let mut greeting = test_greeting();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
greeting.context_window = 64_000;
|
||||
greeting.context_usage = Some(protocol::ContextUsage {
|
||||
tokens: 12_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision: 1,
|
||||
event: Box::new(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting,
|
||||
state: WorkerStatus::Running.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
}),
|
||||
});
|
||||
app.handle_worker_event(Event::InternalWorker {
|
||||
worker,
|
||||
revision: 2,
|
||||
event: Box::new(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 14_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
let child = &app.internal_workers[0].app;
|
||||
assert_eq!(child.greeting.as_ref().unwrap().model, "child-model");
|
||||
assert_eq!(child.context_window, 64_000);
|
||||
assert_eq!(child.session_context_tokens, 14_000);
|
||||
assert_eq!(
|
||||
child.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Estimated)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
|
||||
let mut app = App::new("parent".into());
|
||||
@@ -4307,6 +4418,7 @@ mod completion_flow_tests {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: None,
|
||||
status: WorkerStatus::Running,
|
||||
error: None,
|
||||
in_flight: Default::default(),
|
||||
@@ -4503,15 +4615,21 @@ mod completion_flow_tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_initializes_context_usage() {
|
||||
fn snapshot_prefers_typed_context_usage_over_legacy_mirror() {
|
||||
let mut app = App::new("test".into());
|
||||
let mut greeting = test_greeting();
|
||||
greeting.context_window = 123_000;
|
||||
greeting.context_tokens = 45_000;
|
||||
greeting.context_tokens = 1;
|
||||
greeting.context_usage = Some(protocol::ContextUsage {
|
||||
tokens: 45_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
@@ -4526,6 +4644,36 @@ mod completion_flow_tests {
|
||||
|
||||
assert_eq!(app.context_window, 123_000);
|
||||
assert_eq!(app.session_context_tokens, 45_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Measured)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_falls_back_to_legacy_context_tokens() {
|
||||
let mut app = App::new("test".into());
|
||||
let mut greeting = test_greeting();
|
||||
greeting.context_window = 100_000;
|
||||
greeting.context_tokens = 25_000;
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting,
|
||||
state: test_worker_state(WorkerStatus::Idle),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(app.context_window, 100_000);
|
||||
assert_eq!(app.session_context_tokens, 25_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Estimated)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4539,10 +4687,39 @@ mod completion_flow_tests {
|
||||
});
|
||||
|
||||
assert_eq!(app.session_context_tokens, 42_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Measured)
|
||||
);
|
||||
assert_eq!(app.run_upload_tokens, 2_000);
|
||||
assert_eq!(app.run_output_tokens, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_event_converges_live_state_without_changing_run_traffic() {
|
||||
let mut app = App::new("test".into());
|
||||
app.run_upload_tokens = 2_000;
|
||||
app.run_output_tokens = 9;
|
||||
|
||||
app.handle_worker_event(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 45_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(app.session_context_tokens, 45_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Estimated)
|
||||
);
|
||||
assert_eq!(app.run_upload_tokens, 2_000);
|
||||
assert_eq!(app.run_output_tokens, 9);
|
||||
|
||||
app.handle_worker_event(Event::ContextUsage { usage: None });
|
||||
assert_eq!(app.session_context_source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_worker_event_updates_actionbar_state() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -4564,7 +4741,7 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_done_resets_session_context_tokens() {
|
||||
fn legacy_compact_done_does_not_fabricate_zero_context_usage() {
|
||||
let mut app = App::new("test".into());
|
||||
app.session_context_tokens = 42_000;
|
||||
|
||||
@@ -4572,7 +4749,7 @@ mod completion_flow_tests {
|
||||
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
assert_eq!(app.session_context_tokens, 42_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||
use std::error::Error;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::io::{self, BufRead, IsTerminal, Write};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
type PickerResult<T> = Result<T, Box<dyn Error>>;
|
||||
@@ -179,18 +179,50 @@ fn prompt_create_request() -> PickerResult<Option<CreateBackendWorkspaceRequest>
|
||||
}
|
||||
|
||||
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||
println!("Create Workspace (leave display name empty to cancel)");
|
||||
let display_name = prompt_line("Workspace display name: ")?;
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
prompt_create_request_with_io(&mut stdin.lock(), &mut stdout.lock())
|
||||
}
|
||||
|
||||
fn prompt_create_request_with_io(
|
||||
input: &mut impl BufRead,
|
||||
output: &mut impl Write,
|
||||
) -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||
writeln!(
|
||||
output,
|
||||
"Create Workspace (leave display name empty to cancel)"
|
||||
)?;
|
||||
let Some(display_name) = prompt_line(input, output, "Workspace display name: ")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let display_name = display_name.trim().to_string();
|
||||
if display_name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let uri = prompt_line("Initial repository absolute path/URI: ")?;
|
||||
let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let uri = uri.trim().to_string();
|
||||
if uri.is_empty() {
|
||||
println!("Repository path/URI is required.");
|
||||
writeln!(output, "Repository path/URI is required.")?;
|
||||
return Ok(None);
|
||||
}
|
||||
let repository_key = prompt_line("Repository key [main]: ")?;
|
||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
||||
let repository_key = loop {
|
||||
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if repository_key.trim().is_empty() {
|
||||
writeln!(output, "Repository key is required.")?;
|
||||
continue;
|
||||
}
|
||||
break repository_key;
|
||||
};
|
||||
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let default_ref = default_ref.trim().to_string();
|
||||
let operation_key = format!(
|
||||
"tui-workspace-create-{}-{}",
|
||||
std::process::id(),
|
||||
@@ -204,27 +236,43 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
||||
display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
uri,
|
||||
repository_key: if repository_key.is_empty() {
|
||||
"main".to_string()
|
||||
} else {
|
||||
repository_key
|
||||
},
|
||||
repository_key,
|
||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn prompt_line(prompt: &str) -> PickerResult<String> {
|
||||
print!("{prompt}");
|
||||
io::stdout().flush()?;
|
||||
fn prompt_line(
|
||||
input: &mut impl BufRead,
|
||||
output: &mut impl Write,
|
||||
prompt: &str,
|
||||
) -> PickerResult<Option<String>> {
|
||||
write!(output, "{prompt}")?;
|
||||
output.flush()?;
|
||||
let mut value = String::new();
|
||||
io::stdin().read_line(&mut value)?;
|
||||
Ok(value.trim().to_string())
|
||||
if input.read_line(&mut value)? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.ends_with('\n') {
|
||||
value.pop();
|
||||
if value.ends_with('\r') {
|
||||
value.pop();
|
||||
}
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn prompt_create(input: &str) -> (Option<CreateBackendWorkspaceRequest>, String) {
|
||||
let mut input = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
let request = prompt_create_request_with_io(&mut input, &mut output).unwrap();
|
||||
(request, String::from_utf8(output).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
||||
@@ -237,4 +285,58 @@ mod tests {
|
||||
WorkspacePickerAction::Cancel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
|
||||
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
|
||||
let request = request.expect("complete input should create a request");
|
||||
|
||||
assert_eq!(request.display_name, "Platform");
|
||||
assert_eq!(request.repository.uri, "/srv/platform");
|
||||
assert_eq!(request.repository.repository_key, "platform");
|
||||
assert_eq!(request.repository.default_ref.as_deref(), Some("develop"));
|
||||
assert!(output.contains("Repository key (required): "));
|
||||
assert!(!output.contains("Repository key [main]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_reprompts_for_blank_repository_keys() {
|
||||
let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n");
|
||||
let request = request.expect("a later explicit key should create a request");
|
||||
|
||||
assert_eq!(request.repository.repository_key, "platform");
|
||||
assert_eq!(output.matches("Repository key is required.").count(), 2);
|
||||
assert_eq!(output.matches("Repository key (required): ").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_does_not_normalize_a_nonblank_repository_key() {
|
||||
let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n");
|
||||
|
||||
assert_eq!(
|
||||
request.unwrap().repository.repository_key,
|
||||
" platform ",
|
||||
"the Backend must validate the creator's exact Repository key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_accepts_explicit_main_repository_key() {
|
||||
let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n");
|
||||
|
||||
assert_eq!(
|
||||
request.unwrap().repository.repository_key,
|
||||
"main",
|
||||
"main remains valid when the creator enters it explicitly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_cancels_on_eof_instead_of_retrying_forever() {
|
||||
let (request, output) = prompt_create("Workspace\n/srv/repository\n \n");
|
||||
|
||||
assert!(request.is_none());
|
||||
assert_eq!(output.matches("Repository key is required.").count(), 1);
|
||||
assert_eq!(output.matches("Repository key (required): ").count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,10 +499,12 @@ impl RetainedSnapshotSocket {
|
||||
cwd: String::new(),
|
||||
provider: "retained session".to_string(),
|
||||
model: String::new(),
|
||||
reasoning: None,
|
||||
scope_summary: "read-only retained session".to_string(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStateSnapshot {
|
||||
last_command_id: 0,
|
||||
@@ -654,10 +656,12 @@ async fn run_e2e_rewind_fixture(
|
||||
cwd: workspace_root.display().to_string(),
|
||||
provider: "e2e-fixture".to_string(),
|
||||
model: "canned".to_string(),
|
||||
reasoning: None,
|
||||
scope_summary: "isolated e2e rewind fixture".to_string(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2784,10 +2788,12 @@ mod tests {
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
reasoning: None,
|
||||
scope_summary: "".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-5
@@ -1830,13 +1830,20 @@ fn draw_separator(frame: &mut Frame, area: Rect) {
|
||||
}
|
||||
|
||||
fn context_usage_text(app: &App) -> String {
|
||||
let pct = if app.context_window == 0 {
|
||||
0
|
||||
} else {
|
||||
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64
|
||||
let Some(source) = app.session_context_source else {
|
||||
return "Context unavailable".to_string();
|
||||
};
|
||||
if app.context_window == 0 {
|
||||
return "Context unavailable".to_string();
|
||||
}
|
||||
let pct =
|
||||
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64;
|
||||
let estimate = matches!(source, protocol::ContextTokenSource::Estimated)
|
||||
.then_some("~")
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{} / {} ({}%)",
|
||||
"{}{} / {} ({}%)",
|
||||
estimate,
|
||||
fmt_tokens(app.session_context_tokens),
|
||||
fmt_tokens(app.context_window),
|
||||
pct
|
||||
@@ -2194,6 +2201,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_distinguishes_unknown_measured_and_estimated_values() {
|
||||
let mut app = App::new("worker".into());
|
||||
app.context_window = 100_000;
|
||||
app.session_context_tokens = 0;
|
||||
app.session_context_source = None;
|
||||
assert_eq!(context_usage_text(&app), "Context unavailable");
|
||||
|
||||
app.session_context_tokens = 25_000;
|
||||
app.session_context_source = Some(protocol::ContextTokenSource::Measured);
|
||||
assert_eq!(context_usage_text(&app), "25.0k / 100.0k (25%)");
|
||||
|
||||
app.session_context_tokens = 20_000;
|
||||
app.session_context_source = Some(protocol::ContextTokenSource::Estimated);
|
||||
assert_eq!(context_usage_text(&app), "~20.0k / 100.0k (20%)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_elapsed_uses_console_style_units() {
|
||||
assert_eq!(fmt_run_elapsed(9), "9s");
|
||||
|
||||
@@ -298,6 +298,10 @@ pub struct CreateWorkerRequest {
|
||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
/// Backend-attested subject or consolidation attachment. A reusable Profile
|
||||
/// may enable subjektiv policy, but only this host-owned bit activates it.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub subjektiv_attached: bool,
|
||||
}
|
||||
|
||||
/// Last persisted Worker lifecycle status.
|
||||
|
||||
@@ -1334,7 +1334,6 @@ async fn runtime_protocol_ws_session(
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: selector.clone(),
|
||||
snapshot_revision: subscription.snapshot_revision(),
|
||||
snapshot: subscription.snapshot().clone(),
|
||||
},
|
||||
));
|
||||
@@ -1354,7 +1353,6 @@ async fn runtime_protocol_ws_session(
|
||||
SubscriptionFramePayload::Event(
|
||||
SubscriptionEvent::Event {
|
||||
subscription_id: event_subscription_id.clone(),
|
||||
subject_revision: update.subject_revision,
|
||||
payload: update.payload,
|
||||
},
|
||||
),
|
||||
@@ -2314,6 +2312,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
||||
return Some("workers:delete");
|
||||
}
|
||||
if path.starts_with("/v1/session-public/") && *method == Method::POST {
|
||||
return Some("workers:read");
|
||||
}
|
||||
if path.starts_with("/v1/workers/") && *method == Method::GET {
|
||||
return Some("workers:read");
|
||||
}
|
||||
@@ -3101,6 +3102,7 @@ mod tests {
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3994,6 +3996,8 @@ mod ws_tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStateSnapshot::initial(),
|
||||
in_flight: protocol::InFlightSnapshot {
|
||||
@@ -4078,6 +4082,7 @@ mod ws_tests {
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
.map_err(api_error)
|
||||
}
|
||||
|
||||
async fn session_public_search(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, runtime_api::RuntimeApiError> {
|
||||
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_session_public_scope_required",
|
||||
"Session public search requires a Workspace-scoped capability",
|
||||
)
|
||||
})?;
|
||||
if scope.workspace_id != request.workspace_id {
|
||||
return Err(runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_session_public_workspace_scope_mismatch",
|
||||
"Session public search Workspace scope does not match the authenticated capability",
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "fs-store")]
|
||||
{
|
||||
self.state
|
||||
.runtime
|
||||
.session_public_search_scoped(&scope, &request)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)
|
||||
}
|
||||
#[cfg(not(feature = "fs-store"))]
|
||||
{
|
||||
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "Session public storage is unavailable".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn session_public_read(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, runtime_api::RuntimeApiError> {
|
||||
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_session_public_scope_required",
|
||||
"Session public read requires a Workspace-scoped capability",
|
||||
)
|
||||
})?;
|
||||
if scope.workspace_id != request.workspace_id {
|
||||
return Err(runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_session_public_workspace_scope_mismatch",
|
||||
"Session public read Workspace scope does not match the authenticated capability",
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "fs-store")]
|
||||
{
|
||||
self.state
|
||||
.runtime
|
||||
.session_public_read_scoped(&scope, &request)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)
|
||||
}
|
||||
#[cfg(not(feature = "fs-store"))]
|
||||
{
|
||||
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "Session public storage is unavailable".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_worker(
|
||||
&self,
|
||||
value: runtime_api::CreateWorkerRequest,
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod resource;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub mod retention;
|
||||
mod runtime;
|
||||
#[cfg(feature = "fs-store")]
|
||||
mod session_public;
|
||||
pub mod ssh_host_key_probe;
|
||||
pub mod worker_backend;
|
||||
pub mod worker_source;
|
||||
@@ -36,5 +38,8 @@ pub mod workspace_request;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
pub use management::RuntimeOptions;
|
||||
pub use runtime::{Runtime, RuntimeWorkspaceScope};
|
||||
pub use runtime::{
|
||||
Runtime, RuntimeEventSelectorSubscription, RuntimeSubscriptionRecvError,
|
||||
RuntimeSubscriptionUpdate, RuntimeWorkspaceScope,
|
||||
};
|
||||
pub use session_store::UploadedFileUploadContext;
|
||||
|
||||
@@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider {
|
||||
.join(archive_id))
|
||||
}
|
||||
|
||||
pub(crate) fn session_archive(
|
||||
&self,
|
||||
archive_id: &str,
|
||||
) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> {
|
||||
let archive_dir = self.archive_dir(archive_id)?;
|
||||
let manifest: WorkerSessionArchiveManifest = read_json(
|
||||
&archive_dir.join("manifest.json"),
|
||||
"read Worker Session archive",
|
||||
)?;
|
||||
if manifest.schema_version != ARCHIVE_SCHEMA_VERSION || manifest.archive_id != archive_id {
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "read Worker Session archive",
|
||||
path: archive_dir.join("manifest.json"),
|
||||
message: "archive manifest identity mismatch".to_string(),
|
||||
});
|
||||
}
|
||||
let manifest = validate_existing_archive(&archive_dir, &manifest)?;
|
||||
Ok((manifest, archive_dir.join("session")))
|
||||
}
|
||||
|
||||
fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
|
||||
validate_id("operation_id", operation_id)?;
|
||||
Ok(self
|
||||
|
||||
@@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope {
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256;
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
fn session_public_source_unavailable(
|
||||
source: &runtime_api::SessionPublicSource,
|
||||
error: &RuntimeError,
|
||||
) -> Option<(runtime_api::SessionPublicUnavailableReason, &'static str)> {
|
||||
use runtime_api::SessionPublicUnavailableReason as Reason;
|
||||
|
||||
match (source, error) {
|
||||
(
|
||||
runtime_api::SessionPublicSource::Retained { .. },
|
||||
RuntimeError::WorkerNotFound { .. },
|
||||
) => Some((
|
||||
Reason::RetentionMissing,
|
||||
"retained Session storage is missing",
|
||||
)),
|
||||
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreMissing { .. }) => {
|
||||
Some((
|
||||
Reason::ArchiveIncomplete,
|
||||
"committed Session archive is incomplete",
|
||||
))
|
||||
}
|
||||
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreCorrupt { .. }) => {
|
||||
Some((
|
||||
Reason::ArchiveIncomplete,
|
||||
"committed Session archive failed integrity validation",
|
||||
))
|
||||
}
|
||||
(_, RuntimeError::StoreIo { .. } | RuntimeError::StoreCommitOutcomeUnknown { .. }) => {
|
||||
Some((Reason::StorageUnavailable, "Session storage is unavailable"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed";
|
||||
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
|
||||
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
|
||||
@@ -151,7 +186,6 @@ impl RuntimeWorkerRestoreResult {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeSubscriptionUpdate {
|
||||
pub subject_revision: u64,
|
||||
pub payload: SubscriptionEventPayload,
|
||||
}
|
||||
|
||||
@@ -168,7 +202,6 @@ pub enum RuntimeSubscriptionRecvError {
|
||||
pub struct RuntimeEventSelectorSubscription {
|
||||
subscription_id: u64,
|
||||
selector: EventSubscriptionSelector,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
receiver: mpsc::Receiver<RuntimeSubscriptionUpdate>,
|
||||
lagged: Arc<AtomicBool>,
|
||||
@@ -180,10 +213,6 @@ impl RuntimeEventSelectorSubscription {
|
||||
&self.selector
|
||||
}
|
||||
|
||||
pub fn snapshot_revision(&self) -> u64 {
|
||||
self.snapshot_revision
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> &SubscriptionSnapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
@@ -1280,7 +1309,6 @@ impl Runtime {
|
||||
state.persist_runtime_snapshot()?;
|
||||
}
|
||||
let snapshot = state.subscription_snapshot(scope, &selector)?;
|
||||
let snapshot_revision = state.subscription_revision;
|
||||
let subscription_id = state.next_event_subscription_id;
|
||||
state.next_event_subscription_id = state.next_event_subscription_id.saturating_add(1);
|
||||
let (sender, receiver) = mpsc::channel(SUBSCRIPTION_QUEUE_CAPACITY);
|
||||
@@ -1298,7 +1326,6 @@ impl Runtime {
|
||||
Ok(RuntimeEventSelectorSubscription {
|
||||
subscription_id,
|
||||
selector,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
receiver,
|
||||
lagged,
|
||||
@@ -2217,9 +2244,9 @@ impl Runtime {
|
||||
if let Some(payload) = input_protocol_event(&input) {
|
||||
state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||
}
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
|
||||
Ok(WorkerInteractionAck {
|
||||
worker_ref: worker_ref.clone(),
|
||||
@@ -2500,9 +2527,9 @@ impl Runtime {
|
||||
worker.workdir_attachments = workdir_attachments;
|
||||
worker.detail()
|
||||
};
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
@@ -2541,8 +2568,8 @@ impl Runtime {
|
||||
state
|
||||
.observation_events
|
||||
.retain(|event| event.worker_ref != *worker_ref);
|
||||
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2564,9 +2591,9 @@ impl Runtime {
|
||||
let mut state = self.lock()?;
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.apply_worker_state(&snapshot);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2870,8 +2897,8 @@ impl Runtime {
|
||||
state
|
||||
.observation_events
|
||||
.retain(|event| event.worker_ref != *worker_ref);
|
||||
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
|
||||
Ok(WorkerDeleteResult {
|
||||
worker_id: removed.worker_id,
|
||||
deleted: true,
|
||||
@@ -2959,13 +2986,13 @@ impl Runtime {
|
||||
let worker_state_changed =
|
||||
state.project_protocol_event_to_worker_state(worker_ref, &payload);
|
||||
let activity_changed = state.project_internal_worker_activity(worker_ref, &payload);
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed {
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||
Ok(event)
|
||||
}
|
||||
@@ -3021,13 +3048,13 @@ impl Runtime {
|
||||
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
||||
let activity_changed =
|
||||
state.project_internal_worker_activity(&worker_ref, &payload);
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed {
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
Ok(state.push_worker_observation_event(worker_ref, payload))
|
||||
}),
|
||||
)
|
||||
@@ -3054,13 +3081,13 @@ impl Runtime {
|
||||
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
||||
let activity_changed =
|
||||
state.project_internal_worker_activity(&worker_ref, &payload);
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed {
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
Ok(state.push_worker_observation_event(worker_ref, payload))
|
||||
}),
|
||||
)
|
||||
@@ -3454,8 +3481,8 @@ impl Runtime {
|
||||
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
|
||||
candidate.workdir_attachments = workdir_attachments;
|
||||
state.workers.insert(worker_ref.worker_id, candidate);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3551,6 +3578,123 @@ impl Runtime {
|
||||
)
|
||||
}
|
||||
|
||||
/// Search the committed public projection of one Backend-selected retained
|
||||
/// Session or verified archive without restoring a Worker.
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub fn session_public_search_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
request: &runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeError> {
|
||||
let (session_root, archive) =
|
||||
match self.resolve_session_public_source(scope, &request.source) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
if let Some((reason, message)) =
|
||||
session_public_source_unavailable(&request.source, &error)
|
||||
{
|
||||
return Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||
reason,
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(crate::session_public::search(
|
||||
&session_root,
|
||||
request,
|
||||
archive.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Read one exact public entry from a Backend-selected retained Session or
|
||||
/// verified archive. No fallback Worker, Segment, or entry is selected.
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub fn session_public_read_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
request: &runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeError> {
|
||||
let (session_root, archive) =
|
||||
match self.resolve_session_public_source(scope, &request.source) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
if let Some((reason, message)) =
|
||||
session_public_source_unavailable(&request.source, &error)
|
||||
{
|
||||
return Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||
reason,
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(crate::session_public::read(
|
||||
&session_root,
|
||||
request,
|
||||
archive.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
fn resolve_session_public_source(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
source: &runtime_api::SessionPublicSource,
|
||||
) -> Result<
|
||||
(
|
||||
std::path::PathBuf,
|
||||
Option<crate::retention::WorkerSessionArchiveManifest>,
|
||||
),
|
||||
RuntimeError,
|
||||
> {
|
||||
let state = self.lock()?;
|
||||
let store = state.fs_store().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest(
|
||||
"Session public reads require an fs-backed Runtime".to_string(),
|
||||
)
|
||||
})?;
|
||||
match source {
|
||||
runtime_api::SessionPublicSource::Retained { worker_id } => {
|
||||
let worker_ref = WorkerRef::new(*worker_id);
|
||||
let worker = state.worker(&worker_ref)?;
|
||||
if worker.workspace_id.as_deref() != Some(scope.workspace_id.as_str()) {
|
||||
return Err(RuntimeError::WorkerNotFound {
|
||||
worker_id: *worker_id,
|
||||
});
|
||||
}
|
||||
Ok((
|
||||
store
|
||||
.runtime_dir()
|
||||
.join("workers")
|
||||
.join(worker_id.to_string())
|
||||
.join("session"),
|
||||
None,
|
||||
))
|
||||
}
|
||||
runtime_api::SessionPublicSource::Archived { archive_id } => {
|
||||
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest(
|
||||
"Runtime identity is not bound for Session archive reads".to_string(),
|
||||
)
|
||||
})?;
|
||||
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
|
||||
let (manifest, session_root) = provider.session_archive(archive_id)?;
|
||||
if manifest.workspace_id != scope.workspace_id
|
||||
|| manifest.source_runtime_id != runtime_id
|
||||
{
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Session archive scope does not match the authenticated Runtime capability"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok((session_root, Some(manifest)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate host-authoritative Runtime inventory for Backend orphan
|
||||
/// reconciliation. Runtime identity and Workspace scope are derived here,
|
||||
/// not accepted in a diagnostic payload.
|
||||
@@ -3702,8 +3846,6 @@ struct RuntimeState {
|
||||
workspace_config_latest: BTreeMap<String, ConfigBundleRef>,
|
||||
workspace_config_fetch_gates: BTreeMap<String, Arc<Mutex<()>>>,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
subscription_revision: u64,
|
||||
worker_subject_revisions: BTreeMap<WorkerId, u64>,
|
||||
next_event_subscription_id: u64,
|
||||
subscriptions: BTreeMap<u64, SubscriptionSink>,
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3733,8 +3875,6 @@ impl RuntimeState {
|
||||
workspace_config_latest: BTreeMap::new(),
|
||||
workspace_config_fetch_gates: BTreeMap::new(),
|
||||
diagnostics: Vec::new(),
|
||||
subscription_revision: 0,
|
||||
worker_subject_revisions: BTreeMap::new(),
|
||||
next_event_subscription_id: 1,
|
||||
subscriptions: BTreeMap::new(),
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3765,8 +3905,6 @@ impl RuntimeState {
|
||||
workspace_config_latest: BTreeMap::new(),
|
||||
workspace_config_fetch_gates: BTreeMap::new(),
|
||||
diagnostics: Vec::new(),
|
||||
subscription_revision: 0,
|
||||
worker_subject_revisions: BTreeMap::new(),
|
||||
next_event_subscription_id: 1,
|
||||
subscriptions: BTreeMap::new(),
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3895,8 +4033,6 @@ impl RuntimeState {
|
||||
workspace_config_fetch_gates: BTreeMap::new(),
|
||||
workspace_owners: persisted.workspace_owners,
|
||||
diagnostics,
|
||||
subscription_revision: 0,
|
||||
worker_subject_revisions: BTreeMap::new(),
|
||||
next_event_subscription_id: 1,
|
||||
subscriptions: BTreeMap::new(),
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -4215,11 +4351,6 @@ impl RuntimeState {
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: self
|
||||
.worker_subject_revisions
|
||||
.get(&worker.worker_id)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
worker_state: worker.worker_state.clone(),
|
||||
state: subscription_worker_state(worker.status),
|
||||
has_running_internal_workers: worker.internal_workers.has_running_worker(),
|
||||
@@ -4232,25 +4363,17 @@ impl RuntimeState {
|
||||
}
|
||||
|
||||
fn publish_worker_upsert(&mut self, worker_id: WorkerId) -> Result<(), RuntimeError> {
|
||||
self.subscription_revision = self.subscription_revision.saturating_add(1);
|
||||
let subject_revision = {
|
||||
let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0);
|
||||
*revision = revision.saturating_add(1);
|
||||
*revision
|
||||
};
|
||||
let worker = self
|
||||
.workers
|
||||
.get(&worker_id)
|
||||
.ok_or(RuntimeError::WorkerNotFound { worker_id })?;
|
||||
let workspace_id = worker.workspace_id.clone();
|
||||
let mut projected = self.subscription_worker(worker)?;
|
||||
projected.subject_revision = subject_revision;
|
||||
let projected = self.subscription_worker(worker)?;
|
||||
let projected_worker_id = projected.worker_id.clone();
|
||||
self.deliver_worker_subscription_update(
|
||||
&projected_worker_id,
|
||||
workspace_id.as_deref(),
|
||||
RuntimeSubscriptionUpdate {
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker: projected },
|
||||
},
|
||||
);
|
||||
@@ -4262,19 +4385,12 @@ impl RuntimeState {
|
||||
worker_id: WorkerId,
|
||||
workspace_id: Option<&str>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.subscription_revision = self.subscription_revision.saturating_add(1);
|
||||
let subject_revision = {
|
||||
let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0);
|
||||
*revision = revision.saturating_add(1);
|
||||
*revision
|
||||
};
|
||||
let worker_id = SubscriptionWorkerId::new(worker_id.to_string())
|
||||
.map_err(subscription_validation_error)?;
|
||||
self.deliver_worker_subscription_update(
|
||||
&worker_id,
|
||||
workspace_id,
|
||||
RuntimeSubscriptionUpdate {
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id: worker_id.clone(),
|
||||
runtime_id: None,
|
||||
@@ -4417,8 +4533,8 @@ impl RuntimeState {
|
||||
worker_ref: Some(worker_ref.clone()),
|
||||
});
|
||||
self.workers.insert(worker_ref.worker_id, candidate);
|
||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
self.persist_runtime_snapshot()?;
|
||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5520,6 +5636,7 @@ mod tests {
|
||||
worker,
|
||||
revision,
|
||||
status,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
@@ -5545,6 +5662,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStatus::Idle.into(),
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
@@ -5883,6 +6002,7 @@ mod tests {
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6647,7 +6767,6 @@ mod tests {
|
||||
let mut subscription = runtime
|
||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap();
|
||||
assert_eq!(subscription.snapshot_revision(), 0);
|
||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||
panic!("runtime_workers must return a Worker snapshot");
|
||||
};
|
||||
@@ -6655,11 +6774,9 @@ mod tests {
|
||||
|
||||
let created = runtime.create_worker(task_request("live")).unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 1);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||
assert_eq!(worker.subject_revision, 1);
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
}
|
||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||
@@ -6676,7 +6793,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 2);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
@@ -6695,7 +6811,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 3);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
@@ -6737,7 +6852,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 4);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
@@ -6748,7 +6862,6 @@ mod tests {
|
||||
|
||||
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 5);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||
@@ -6760,6 +6873,87 @@ mod tests {
|
||||
runtime.stop_runtime().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_snapshot_and_worker_creation_cross_the_boundary_exactly_once() {
|
||||
let runtime = runtime_with_backend();
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let subscribing_runtime = runtime.clone();
|
||||
let subscribing_barrier = barrier.clone();
|
||||
let subscriber = std::thread::spawn(move || {
|
||||
subscribing_barrier.wait();
|
||||
subscribing_runtime
|
||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap()
|
||||
});
|
||||
let creating_runtime = runtime.clone();
|
||||
let creating_barrier = barrier.clone();
|
||||
let creator = std::thread::spawn(move || {
|
||||
creating_barrier.wait();
|
||||
creating_runtime
|
||||
.create_worker(task_request("concurrent-boundary"))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let mut subscription = subscriber.join().unwrap();
|
||||
let created = creator.join().unwrap();
|
||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||
panic!("runtime_workers must return a Worker snapshot");
|
||||
};
|
||||
let in_snapshot = workers
|
||||
.iter()
|
||||
.any(|worker| worker.worker_id.as_str() == created.worker_id.to_string());
|
||||
let in_event = match subscription.receiver.try_recv() {
|
||||
Ok(RuntimeSubscriptionUpdate {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
}) => worker.worker_id.as_str() == created.worker_id.to_string(),
|
||||
Ok(update) => panic!("unexpected subscription update: {update:?}"),
|
||||
Err(mpsc::error::TryRecvError::Empty) => false,
|
||||
Err(error) => panic!("unexpected subscription receive error: {error}"),
|
||||
};
|
||||
assert_ne!(
|
||||
in_snapshot, in_event,
|
||||
"the concurrent creation must appear in the snapshot or its ordered suffix, never both"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
#[test]
|
||||
fn restarted_runtime_subscription_starts_from_persisted_authoritative_snapshot() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let options = crate::fs_store::FsRuntimeStoreOptions {
|
||||
root: root.path().join("runtime"),
|
||||
runtime_id: "subscription-restart".to_string(),
|
||||
display_name: None,
|
||||
};
|
||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||
options.clone(),
|
||||
Arc::new(TestExecutionBackend::default()),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let worker = runtime
|
||||
.create_worker(task_request("persisted-subscription-worker"))
|
||||
.unwrap();
|
||||
drop(runtime);
|
||||
|
||||
let restarted = Runtime::with_fs_store_and_execution_backend(
|
||||
options,
|
||||
Arc::new(TestExecutionBackend::default()),
|
||||
)
|
||||
.unwrap();
|
||||
let subscription = restarted
|
||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap();
|
||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||
panic!("runtime_workers must return a Worker snapshot");
|
||||
};
|
||||
assert!(workers.iter().any(|candidate| {
|
||||
candidate.worker_id.as_str() == worker.worker_id.to_string()
|
||||
&& candidate.availability
|
||||
== protocol::subscription::SubscriptionWorkerAvailability::Observed
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_subscription_delivers_only_selected_workers() {
|
||||
let runtime = runtime_with_backend();
|
||||
@@ -8108,6 +8302,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 128,
|
||||
context_tokens: 64,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStatus::Running.into(),
|
||||
in_flight: protocol::InFlightSnapshot {
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
use std::path::Path;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use runtime_api::{
|
||||
SessionPublicEntryKind, SessionPublicLineage, SessionPublicLineageKind,
|
||||
SessionPublicReadAvailability, SessionPublicReadMode, SessionPublicReadPage,
|
||||
SessionPublicReadRequest, SessionPublicSearchAvailability, SessionPublicSearchItem,
|
||||
SessionPublicSearchPage, SessionPublicSearchRequest, SessionPublicToolPart,
|
||||
SessionPublicUnavailableReason, WorkerSessionArchiveManifest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::{
|
||||
SessionPublicIndexEntry, SessionPublicIndexEntryKind, SessionPublicIndexLimits,
|
||||
SessionPublicIndexLineage, SessionPublicIndexOriginKind, SessionPublicIndexReadError,
|
||||
SessionPublicIndexScanPosition, SessionPublicIndexToolPart, read_session_public_index_entry,
|
||||
read_session_public_index_page,
|
||||
};
|
||||
|
||||
use crate::retention::WorkerSessionArchiveManifest as DomainArchiveManifest;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SessionPublicRuntimeCursor {
|
||||
generation: String,
|
||||
position: SessionPublicIndexScanPosition,
|
||||
}
|
||||
|
||||
pub(crate) fn search(
|
||||
session_root: &Path,
|
||||
request: &SessionPublicSearchRequest,
|
||||
archive_manifest: Option<&DomainArchiveManifest>,
|
||||
) -> SessionPublicSearchAvailability {
|
||||
if request.limit == 0 || request.limit > 100 {
|
||||
return unavailable(
|
||||
SessionPublicUnavailableReason::ResourceLimit,
|
||||
"Session public search limit must be between 1 and 100",
|
||||
);
|
||||
}
|
||||
let cursor = match request.scan_cursor.as_deref().map(decode_scan_cursor) {
|
||||
Some(Ok(cursor)) => Some(cursor),
|
||||
Some(Err(())) => {
|
||||
return unavailable(
|
||||
SessionPublicUnavailableReason::InvalidCursor,
|
||||
"Session public search cursor is invalid",
|
||||
);
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if let (Some(expected), Some(cursor)) =
|
||||
(request.expected_generation.as_deref(), cursor.as_ref())
|
||||
&& expected != cursor.generation
|
||||
{
|
||||
return unavailable(
|
||||
SessionPublicUnavailableReason::InvalidCursor,
|
||||
"Session public search cursor generation changed",
|
||||
);
|
||||
}
|
||||
|
||||
let query = request
|
||||
.query
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_lowercase);
|
||||
let expected_generation = request
|
||||
.expected_generation
|
||||
.as_deref()
|
||||
.or_else(|| cursor.as_ref().map(|cursor| cursor.generation.as_str()));
|
||||
let index = match read_session_public_index_page(
|
||||
session_root,
|
||||
SessionPublicIndexLimits {
|
||||
max_bytes: request.max_scan_bytes,
|
||||
max_segments: request.max_segments,
|
||||
max_entries: request.max_entries,
|
||||
},
|
||||
expected_generation,
|
||||
cursor.as_ref().map(|cursor| cursor.position.clone()),
|
||||
request.limit,
|
||||
|entry| matches_entry(entry, request, query.as_deref()),
|
||||
) {
|
||||
Ok(index) => index,
|
||||
Err(error) => {
|
||||
let (reason, message) = map_index_error(error);
|
||||
return unavailable(reason, message);
|
||||
}
|
||||
};
|
||||
if index.session_id != request.expected_session_id {
|
||||
return unavailable(
|
||||
SessionPublicUnavailableReason::NotFound,
|
||||
"Session identity does not match the authorized source",
|
||||
);
|
||||
}
|
||||
|
||||
let mut items = Vec::with_capacity(index.entries.len());
|
||||
for item in index.entries {
|
||||
let scan_cursor = match encode_scan_cursor(&SessionPublicRuntimeCursor {
|
||||
generation: index.generation.clone(),
|
||||
position: item.scan_from,
|
||||
}) {
|
||||
Ok(cursor) => cursor,
|
||||
Err(()) => {
|
||||
return unavailable(
|
||||
SessionPublicUnavailableReason::StorageUnavailable,
|
||||
"Session public search cursor could not be encoded",
|
||||
);
|
||||
}
|
||||
};
|
||||
let entry = item.entry;
|
||||
items.push(SessionPublicSearchItem {
|
||||
segment_id: item.segment_id,
|
||||
entry_ref: entry.entry_ref,
|
||||
kind: entry_kind(entry.kind),
|
||||
origin: entry.provenance,
|
||||
tool_name: entry.tool_name,
|
||||
tool_part: entry.tool_part.map(tool_part),
|
||||
compact: entry.compact_text,
|
||||
compact_truncated: entry.compact_text_truncated,
|
||||
lineage: lineage(&item.lineage),
|
||||
scan_cursor,
|
||||
});
|
||||
}
|
||||
let next_scan_cursor = match index.next_position {
|
||||
Some(position) => match encode_scan_cursor(&SessionPublicRuntimeCursor {
|
||||
generation: index.generation.clone(),
|
||||
position,
|
||||
}) {
|
||||
Ok(cursor) => Some(cursor),
|
||||
Err(()) => {
|
||||
return unavailable(
|
||||
SessionPublicUnavailableReason::StorageUnavailable,
|
||||
"Session public search cursor could not be encoded",
|
||||
);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
SessionPublicSearchAvailability::Page {
|
||||
page: SessionPublicSearchPage {
|
||||
session_id: index.session_id,
|
||||
generation: index.generation,
|
||||
items,
|
||||
next_scan_cursor,
|
||||
has_more: index.has_more,
|
||||
scanned_bytes: index.scanned_bytes,
|
||||
scanned_segments: index.scanned_segments,
|
||||
scanned_entries: index.scanned_entries,
|
||||
archive_manifest: archive_manifest.map(api_archive_manifest),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_scan_cursor(cursor: &SessionPublicRuntimeCursor) -> Result<String, ()> {
|
||||
serde_json::to_vec(cursor)
|
||||
.map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
fn decode_scan_cursor(cursor: &str) -> Result<SessionPublicRuntimeCursor, ()> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| ())?;
|
||||
serde_json::from_slice(&bytes).map_err(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn read(
|
||||
session_root: &Path,
|
||||
request: &SessionPublicReadRequest,
|
||||
archive_manifest: Option<&DomainArchiveManifest>,
|
||||
) -> SessionPublicReadAvailability {
|
||||
if request.max_content_bytes == 0 {
|
||||
return read_unavailable(
|
||||
SessionPublicUnavailableReason::ResourceLimit,
|
||||
"Session public read content limit must be positive",
|
||||
);
|
||||
}
|
||||
let selected = match read_session_public_index_entry(
|
||||
session_root,
|
||||
SessionPublicIndexLimits {
|
||||
max_bytes: request.max_scan_bytes,
|
||||
max_segments: request.max_segments,
|
||||
max_entries: request.max_entries,
|
||||
},
|
||||
request.expected_generation.as_deref(),
|
||||
&request.segment_id,
|
||||
&request.entry_ref,
|
||||
) {
|
||||
Ok(Some(selected)) => selected,
|
||||
Ok(None) => {
|
||||
return read_unavailable(
|
||||
SessionPublicUnavailableReason::NotFound,
|
||||
"Session entry was not found in the authorized source",
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let (reason, message) = map_index_error(error);
|
||||
return read_unavailable(reason, message);
|
||||
}
|
||||
};
|
||||
if selected.session_id != request.expected_session_id {
|
||||
return read_unavailable(
|
||||
SessionPublicUnavailableReason::NotFound,
|
||||
"Session identity does not match the authorized source",
|
||||
);
|
||||
}
|
||||
let item = selected.item;
|
||||
let entry = item.entry;
|
||||
let content = match request.mode {
|
||||
SessionPublicReadMode::Compact => &entry.compact_text,
|
||||
SessionPublicReadMode::Full => &entry.full_text,
|
||||
};
|
||||
if request.byte_offset > content.len() || !content.is_char_boundary(request.byte_offset) {
|
||||
return read_unavailable(
|
||||
SessionPublicUnavailableReason::InvalidCursor,
|
||||
"Session public read position is stale",
|
||||
);
|
||||
}
|
||||
let mut end = request
|
||||
.byte_offset
|
||||
.saturating_add(request.max_content_bytes)
|
||||
.min(content.len());
|
||||
while end > request.byte_offset && !content.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
if end == request.byte_offset && request.byte_offset < content.len() {
|
||||
return read_unavailable(
|
||||
SessionPublicUnavailableReason::ResourceLimit,
|
||||
"Session public read cannot fit one UTF-8 scalar in the content limit",
|
||||
);
|
||||
}
|
||||
let has_more = end < content.len();
|
||||
SessionPublicReadAvailability::Page {
|
||||
page: SessionPublicReadPage {
|
||||
session_id: selected.session_id,
|
||||
generation: selected.generation,
|
||||
segment_id: item.segment_id,
|
||||
entry_ref: entry.entry_ref.clone(),
|
||||
kind: entry_kind(entry.kind),
|
||||
origin: entry.provenance.clone(),
|
||||
lineage: lineage(&item.lineage),
|
||||
mode: request.mode,
|
||||
content: content[request.byte_offset..end].to_string(),
|
||||
next_byte_offset: has_more.then_some(end),
|
||||
has_more,
|
||||
scanned_bytes: selected.scanned_bytes,
|
||||
scanned_segments: selected.scanned_segments,
|
||||
scanned_entries: selected.scanned_entries,
|
||||
archive_manifest: archive_manifest.map(api_archive_manifest),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_entry(
|
||||
entry: &SessionPublicIndexEntry,
|
||||
request: &SessionPublicSearchRequest,
|
||||
query: Option<&str>,
|
||||
) -> bool {
|
||||
if request
|
||||
.kind
|
||||
.is_some_and(|kind| kind != entry_kind(entry.kind))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if request.tool_name.is_some() || request.tool_part != SessionPublicToolPart::Both {
|
||||
if entry.kind != SessionPublicIndexEntryKind::Tool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if request
|
||||
.tool_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| entry.tool_name.as_deref() != Some(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if request.tool_part != SessionPublicToolPart::Both
|
||||
&& entry.tool_part.map(tool_part) != Some(request.tool_part)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
query.is_none_or(|query| entry.full_text.to_lowercase().contains(query))
|
||||
}
|
||||
|
||||
fn entry_kind(kind: SessionPublicIndexEntryKind) -> SessionPublicEntryKind {
|
||||
match kind {
|
||||
SessionPublicIndexEntryKind::User => SessionPublicEntryKind::User,
|
||||
SessionPublicIndexEntryKind::Assistant => SessionPublicEntryKind::Assistant,
|
||||
SessionPublicIndexEntryKind::Tool => SessionPublicEntryKind::Tool,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_part(part: SessionPublicIndexToolPart) -> SessionPublicToolPart {
|
||||
match part {
|
||||
SessionPublicIndexToolPart::Input => SessionPublicToolPart::Input,
|
||||
SessionPublicIndexToolPart::Output => SessionPublicToolPart::Output,
|
||||
}
|
||||
}
|
||||
|
||||
fn lineage(value: &SessionPublicIndexLineage) -> SessionPublicLineage {
|
||||
SessionPublicLineage {
|
||||
kind: match value.origin_kind {
|
||||
SessionPublicIndexOriginKind::Root => SessionPublicLineageKind::Root,
|
||||
SessionPublicIndexOriginKind::Fork => SessionPublicLineageKind::Fork,
|
||||
SessionPublicIndexOriginKind::Compact => SessionPublicLineageKind::Compact,
|
||||
},
|
||||
parent_segment_id: value.parent_segment_id.clone(),
|
||||
at_turn_index: value.parent_turn_index,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_index_error(
|
||||
error: SessionPublicIndexReadError,
|
||||
) -> (SessionPublicUnavailableReason, &'static str) {
|
||||
match error {
|
||||
SessionPublicIndexReadError::MigrationRequired => (
|
||||
SessionPublicUnavailableReason::MigrationRequired,
|
||||
"Session storage requires migration",
|
||||
),
|
||||
SessionPublicIndexReadError::Missing => (
|
||||
SessionPublicUnavailableReason::RetentionMissing,
|
||||
"Session storage is missing",
|
||||
),
|
||||
SessionPublicIndexReadError::Corrupt => (
|
||||
SessionPublicUnavailableReason::CorruptLog,
|
||||
"Session log is corrupt",
|
||||
),
|
||||
SessionPublicIndexReadError::Storage => (
|
||||
SessionPublicUnavailableReason::StorageUnavailable,
|
||||
"Session storage is unavailable",
|
||||
),
|
||||
SessionPublicIndexReadError::ResourceLimit => (
|
||||
SessionPublicUnavailableReason::ResourceLimit,
|
||||
"Session scan exceeds a resource limit",
|
||||
),
|
||||
SessionPublicIndexReadError::StaleCursor => (
|
||||
SessionPublicUnavailableReason::InvalidCursor,
|
||||
"Session public search cursor is stale",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: impl Into<String>,
|
||||
) -> SessionPublicSearchAvailability {
|
||||
SessionPublicSearchAvailability::Unavailable {
|
||||
reason,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_unavailable(
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: impl Into<String>,
|
||||
) -> SessionPublicReadAvailability {
|
||||
SessionPublicReadAvailability::Unavailable {
|
||||
reason,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiveManifest {
|
||||
WorkerSessionArchiveManifest {
|
||||
schema_version: manifest.schema_version,
|
||||
archive_id: manifest.archive_id.clone(),
|
||||
workspace_id: manifest.workspace_id.clone(),
|
||||
source_runtime_id: manifest.source_runtime_id.clone(),
|
||||
source_worker_id: manifest.source_worker_id,
|
||||
source_session_id: manifest.source_session_id.clone(),
|
||||
segment_ids: manifest.segment_ids.clone(),
|
||||
source_created_at: manifest.source_created_at.clone(),
|
||||
removed_at: manifest.removed_at.clone(),
|
||||
archived_at_unix_seconds: manifest.archived_at_unix_seconds,
|
||||
effective_profile: manifest.effective_profile.clone(),
|
||||
retention_class: manifest.retention_class.clone(),
|
||||
content_checksum_sha256: manifest.content_checksum_sha256.clone(),
|
||||
content_bytes: manifest.content_bytes,
|
||||
content_file_count: manifest.content_file_count,
|
||||
policy_id: manifest.policy_id.clone(),
|
||||
policy_revision: manifest.policy_revision,
|
||||
operation_id: manifest.operation_id.clone(),
|
||||
input_fingerprint: manifest.input_fingerprint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use agen::llm_client::RequestConfig;
|
||||
use session_store::{
|
||||
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
||||
Store, WorkerSessionStore,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn message(id: &str, text: &str) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::User,
|
||||
content: vec![LoggedContentPart::Text { text: text.into() }],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId(id.to_string()),
|
||||
origin: LoggedSessionHistoryOrigin::HumanInput {
|
||||
account_id: "account-1".to_string(),
|
||||
},
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn request(
|
||||
session_id: session_store::SessionId,
|
||||
generation: Option<String>,
|
||||
scan_cursor: Option<String>,
|
||||
) -> SessionPublicSearchRequest {
|
||||
SessionPublicSearchRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
source: runtime_api::SessionPublicSource::Retained {
|
||||
worker_id: crate::identity::WorkerId::now_v7(),
|
||||
},
|
||||
expected_session_id: session_id.to_string(),
|
||||
expected_generation: generation,
|
||||
query: Some("needle".to_string()),
|
||||
kind: None,
|
||||
tool_name: None,
|
||||
tool_part: SessionPublicToolPart::Both,
|
||||
scan_cursor,
|
||||
limit: 20,
|
||||
max_scan_bytes: 64 * 1024 * 1024,
|
||||
max_segments: 64,
|
||||
max_entries: 100_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_continues_beyond_segment_budget_and_stales_after_mutation() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path().join("session");
|
||||
let store = WorkerSessionStore::new(&root).unwrap();
|
||||
let session_id = session_store::new_session_id();
|
||||
let mut last_segment = None;
|
||||
for index in 0..65_u128 {
|
||||
let segment_id = uuid::Uuid::from_u128(10_000 + index);
|
||||
last_segment = Some(segment_id);
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
segment_id,
|
||||
&[LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![message(
|
||||
&format!("entry-{index}"),
|
||||
if index == 64 { "needle" } else { "ordinary" },
|
||||
)],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let first = match search(&root, &request(session_id, None, None), None) {
|
||||
SessionPublicSearchAvailability::Page { page } => page,
|
||||
other => panic!("unexpected first search result: {other:?}"),
|
||||
};
|
||||
assert!(first.items.is_empty());
|
||||
assert!(first.has_more);
|
||||
let cursor = first.next_scan_cursor.clone().unwrap();
|
||||
|
||||
let second = match search(
|
||||
&root,
|
||||
&request(
|
||||
session_id,
|
||||
Some(first.generation.clone()),
|
||||
Some(cursor.clone()),
|
||||
),
|
||||
None,
|
||||
) {
|
||||
SessionPublicSearchAvailability::Page { page } => page,
|
||||
other => panic!("unexpected continued search result: {other:?}"),
|
||||
};
|
||||
assert_eq!(second.items.len(), 1);
|
||||
assert_eq!(second.items[0].compact, "needle");
|
||||
assert!(!second.has_more);
|
||||
|
||||
let read_page = match read(
|
||||
&root,
|
||||
&SessionPublicReadRequest {
|
||||
workspace_id: "workspace-1".into(),
|
||||
source: runtime_api::SessionPublicSource::Retained {
|
||||
worker_id: crate::identity::WorkerId::now_v7(),
|
||||
},
|
||||
expected_session_id: session_id.to_string(),
|
||||
expected_generation: Some(second.generation.clone()),
|
||||
segment_id: last_segment.unwrap().to_string(),
|
||||
entry_ref: "Eentry-64".into(),
|
||||
mode: SessionPublicReadMode::Full,
|
||||
byte_offset: 0,
|
||||
max_content_bytes: 16 * 1024,
|
||||
max_scan_bytes: 64 * 1024 * 1024,
|
||||
max_segments: 64,
|
||||
max_entries: 100_000,
|
||||
},
|
||||
None,
|
||||
) {
|
||||
SessionPublicReadAvailability::Page { page } => page,
|
||||
other => panic!("unexpected direct read result: {other:?}"),
|
||||
};
|
||||
assert_eq!(read_page.content, "needle");
|
||||
assert_eq!(read_page.segment_id, last_segment.unwrap().to_string());
|
||||
assert!(!read_page.has_more);
|
||||
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
last_segment.unwrap(),
|
||||
&LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2,
|
||||
entry: message("mutation", "changed"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
search(
|
||||
&root,
|
||||
&request(session_id, Some(first.generation), Some(cursor)),
|
||||
None,
|
||||
),
|
||||
SessionPublicSearchAvailability::Unavailable {
|
||||
reason: SessionPublicUnavailableReason::InvalidCursor,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_then_read_crosses_entry_budget_within_one_segment() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path().join("session");
|
||||
let store = WorkerSessionStore::new(&root).unwrap();
|
||||
let session_id = session_store::new_session_id();
|
||||
let segment_id = uuid::Uuid::from_u128(20_000);
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
segment_id,
|
||||
&[LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![message("seed", "ordinary")],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
for index in 0..4 {
|
||||
let id = if index == 3 {
|
||||
"target".to_string()
|
||||
} else {
|
||||
format!("ordinary-{index}")
|
||||
};
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
segment_id,
|
||||
&LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2 + index,
|
||||
entry: message(&id, if index == 3 { "needle" } else { "ordinary" }),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut generation = None;
|
||||
let mut scan_cursor = None;
|
||||
let found = loop {
|
||||
let mut search_request = request(session_id, generation.clone(), scan_cursor.clone());
|
||||
search_request.max_entries = 2;
|
||||
let page = match search(&root, &search_request, None) {
|
||||
SessionPublicSearchAvailability::Page { page } => page,
|
||||
other => panic!("unexpected search result: {other:?}"),
|
||||
};
|
||||
if let Some(item) = page.items.into_iter().next() {
|
||||
break (page.generation, item);
|
||||
}
|
||||
assert!(page.has_more);
|
||||
generation = Some(page.generation);
|
||||
scan_cursor = page.next_scan_cursor;
|
||||
};
|
||||
assert_eq!(found.1.entry_ref, "Etarget");
|
||||
|
||||
let read_page = match read(
|
||||
&root,
|
||||
&SessionPublicReadRequest {
|
||||
workspace_id: "workspace-1".into(),
|
||||
source: runtime_api::SessionPublicSource::Retained {
|
||||
worker_id: crate::identity::WorkerId::now_v7(),
|
||||
},
|
||||
expected_session_id: session_id.to_string(),
|
||||
expected_generation: Some(found.0),
|
||||
segment_id: segment_id.to_string(),
|
||||
entry_ref: found.1.entry_ref,
|
||||
mode: SessionPublicReadMode::Full,
|
||||
byte_offset: 0,
|
||||
max_content_bytes: 16 * 1024,
|
||||
max_scan_bytes: 64 * 1024 * 1024,
|
||||
max_segments: 64,
|
||||
max_entries: 2,
|
||||
},
|
||||
None,
|
||||
) {
|
||||
SessionPublicReadAvailability::Page { page } => page,
|
||||
other => panic!("unexpected direct read result: {other:?}"),
|
||||
};
|
||||
assert_eq!(read_page.content, "needle");
|
||||
assert!(read_page.scanned_entries > 2);
|
||||
}
|
||||
}
|
||||
@@ -944,7 +944,7 @@ fn bind_workspace_memory_settings(
|
||||
request: &CreateWorkerRequest,
|
||||
) -> Result<(), String> {
|
||||
let Some(snapshot) = request.memory_settings.as_ref() else {
|
||||
if request.workspace_api.is_some() {
|
||||
if request.workspace_api.is_some() || request.subjektiv_attached {
|
||||
return Err(
|
||||
"Workspace Worker request is missing its bound Memory settings snapshot"
|
||||
.to_string(),
|
||||
@@ -960,14 +960,33 @@ fn bind_workspace_memory_settings(
|
||||
snapshot.workspace_id, workspace_api.workspace_id
|
||||
));
|
||||
}
|
||||
if manifest.feature.memory.profile.enabled {
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(snapshot.clone())
|
||||
.map_err(str::to_string)?;
|
||||
}
|
||||
if request.subjektiv_attached {
|
||||
if !manifest.feature.subjektiv.profile.enabled {
|
||||
return Err(
|
||||
"subject-attached Worker profile does not enable subjektiv policy".to_string(),
|
||||
);
|
||||
}
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(snapshot.clone())
|
||||
.map_err(str::to_string)?;
|
||||
}
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(snapshot.clone())
|
||||
.validate_execution()
|
||||
.map_err(str::to_string)?;
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.subjektiv
|
||||
.validate_execution()
|
||||
.map_err(str::to_string)?;
|
||||
Ok(())
|
||||
@@ -978,6 +997,12 @@ fn validate_worker_memory_settings(
|
||||
request: &CreateWorkerRequest,
|
||||
) -> Result<(), String> {
|
||||
let Some(expected) = request.memory_settings.as_ref() else {
|
||||
if request.subjektiv_attached {
|
||||
return Err(
|
||||
"subject-attached Worker restore is missing its trusted settings snapshot"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
manifest
|
||||
@@ -985,17 +1010,21 @@ fn validate_worker_memory_settings(
|
||||
.memory
|
||||
.validate_execution()
|
||||
.map_err(str::to_string)?;
|
||||
if !manifest.feature.memory.profile.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let actual = manifest
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.workspace_settings()
|
||||
.ok_or_else(|| {
|
||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||
})?;
|
||||
if &actual != expected {
|
||||
.subjektiv
|
||||
.validate_execution()
|
||||
.map_err(str::to_string)?;
|
||||
if manifest.feature.memory.profile.enabled
|
||||
&& manifest.feature.memory.workspace_settings().as_ref() != Some(expected)
|
||||
{
|
||||
let actual = manifest
|
||||
.feature
|
||||
.memory
|
||||
.workspace_settings()
|
||||
.ok_or_else(|| {
|
||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||
})?;
|
||||
return Err(format!(
|
||||
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||
expected.workspace_id,
|
||||
@@ -1004,6 +1033,33 @@ fn validate_worker_memory_settings(
|
||||
actual.settings_revision
|
||||
));
|
||||
}
|
||||
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
|
||||
if request.subjektiv_attached {
|
||||
if !manifest.feature.subjektiv.profile.enabled {
|
||||
return Err(
|
||||
"subject-attached Worker restored with a Profile that disables subjektiv policy"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if subjektiv_settings.as_ref() != Some(expected) {
|
||||
let actual = subjektiv_settings.ok_or_else(|| {
|
||||
"subject-attached Worker restored without its bound subjektiv settings snapshot"
|
||||
.to_string()
|
||||
})?;
|
||||
return Err(format!(
|
||||
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||
expected.workspace_id,
|
||||
expected.settings_revision,
|
||||
actual.workspace_id,
|
||||
actual.settings_revision
|
||||
));
|
||||
}
|
||||
} else if subjektiv_settings.is_some() {
|
||||
return Err(
|
||||
"ordinary Workspace Worker restored with an unauthorized subjektiv attachment"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1241,10 +1297,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
}
|
||||
}
|
||||
|
||||
worker
|
||||
.materialize_durable_session_head()
|
||||
.await
|
||||
.map_err(|error| format!("materialize durable Worker session head: {error}"))?;
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let started = prepared.start().await.map_err(|error| match error {
|
||||
WorkerBootstrapError::Worker(source) => {
|
||||
@@ -4391,9 +4443,70 @@ mod tests {
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: None,
|
||||
subjektiv_attached: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_attachment_alone_activates_subjektiv_profile_policy() {
|
||||
let manifest = || {
|
||||
let mut manifest = WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "subjektiv-attachment-test"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
auth = { kind = "none" }
|
||||
|
||||
[engine]
|
||||
max_tokens = 100
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "read"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.feature.subjektiv.profile.enabled = true;
|
||||
manifest.feature.subjektiv.profile.extraction.enabled = true;
|
||||
manifest
|
||||
};
|
||||
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-subject".to_string(),
|
||||
settings_revision: 3,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut ordinary_request = create_request("ordinary");
|
||||
ordinary_request.workspace_api = Some(WorkspaceApiRef {
|
||||
workspace_id: settings.workspace_id.clone(),
|
||||
base_url: "http://workspace.invalid".to_string(),
|
||||
});
|
||||
ordinary_request.memory_settings = Some(settings.clone());
|
||||
let mut ordinary = manifest();
|
||||
bind_workspace_memory_settings(&mut ordinary, &ordinary_request).unwrap();
|
||||
assert!(ordinary.feature.subjektiv.profile.enabled);
|
||||
assert!(!ordinary.feature.subjektiv.execution_enabled());
|
||||
validate_worker_memory_settings(&ordinary, &ordinary_request).unwrap();
|
||||
|
||||
let mut attached_request = ordinary_request.clone();
|
||||
attached_request.subjektiv_attached = true;
|
||||
let mut attached = manifest();
|
||||
bind_workspace_memory_settings(&mut attached, &attached_request).unwrap();
|
||||
assert!(attached.feature.subjektiv.execution_enabled());
|
||||
assert_eq!(
|
||||
attached.feature.subjektiv.workspace_settings(),
|
||||
Some(settings.clone())
|
||||
);
|
||||
validate_worker_memory_settings(&attached, &attached_request).unwrap();
|
||||
assert!(validate_worker_memory_settings(&attached, &ordinary_request).is_err());
|
||||
|
||||
attached_request.memory_settings = None;
|
||||
assert!(bind_workspace_memory_settings(&mut manifest(), &attached_request).is_err());
|
||||
}
|
||||
|
||||
fn git(path: &std::path::Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -4499,6 +4612,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 1_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
));
|
||||
hub.workers.lock().unwrap().insert(
|
||||
|
||||
@@ -564,6 +564,30 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
.await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
|
||||
generated_workspace_response(
|
||||
client
|
||||
.subjektiv_stage_candidate(workspace_id, request)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivRecordSession(request) => {
|
||||
generated_workspace_response(
|
||||
client.subjektiv_record_session(workspace_id, request).await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivMemory(request) => {
|
||||
generated_workspace_response(
|
||||
client.subjektiv_memory_backend(workspace_id, request).await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivSession(request) => {
|
||||
generated_workspace_response(
|
||||
client
|
||||
.subjektiv_session_backend(workspace_id, request)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+298
-13
@@ -151,7 +151,7 @@ impl WorkerHandle {
|
||||
.snapshot();
|
||||
let event = Event::Snapshot {
|
||||
session,
|
||||
greeting: self.shared_state.greeting.clone(),
|
||||
greeting: self.shared_state.greeting(),
|
||||
state: self.shared_state.snapshot(),
|
||||
in_flight,
|
||||
internal_workers: self.spawned_registry.internal_worker_snapshots(),
|
||||
@@ -269,6 +269,17 @@ async fn set_controller_state(
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn publish_context_usage(
|
||||
shared_state: &Arc<WorkerSharedState>,
|
||||
working_event_tx: &broadcast::Sender<Event>,
|
||||
tokens: Option<u64>,
|
||||
source: protocol::ContextTokenSource,
|
||||
) {
|
||||
shared_state.update_context_usage(tokens, source);
|
||||
let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
|
||||
let _ = working_event_tx.send(Event::ContextUsage { usage });
|
||||
}
|
||||
|
||||
async fn set_controller_status(
|
||||
shared_state: &Arc<WorkerSharedState>,
|
||||
runtime_dir: &RuntimeDir,
|
||||
@@ -303,6 +314,12 @@ async fn finish_controller_run<C, St>(
|
||||
// the terminal run boundary so reconnect snapshots cannot append stale
|
||||
// partial text/tool arguments after newer entries.
|
||||
worker.clear_in_flight_events();
|
||||
publish_context_usage(
|
||||
shared_state,
|
||||
working_event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
||||
}
|
||||
|
||||
@@ -767,6 +784,19 @@ impl WorkerController {
|
||||
// the actual registered set instead of a hand-maintained mirror.
|
||||
worker.engine().tool_server_handle().flush_pending();
|
||||
|
||||
// Runtime-owned Workers persist their current model-visible head before
|
||||
// controller exposure. Feature installation above must happen first so a
|
||||
// fresh subject Worker captures the current resident surface in its durable
|
||||
// prompt. Restored Workers defer their one-shot append-only resident refresh
|
||||
// until the next model-visible context materialization, after the Runtime has
|
||||
// committed restoration of the existing durable head.
|
||||
if runtime_managed && worker.needs_initial_session_head_materialization() {
|
||||
worker
|
||||
.materialize_durable_session_head()
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
}
|
||||
|
||||
// === 4. Initial runtime files + WorkerSharedState + WorkerHandle +
|
||||
// SocketServer ===
|
||||
let manifest_toml = toml::to_string_pretty(worker.manifest()).unwrap_or_default();
|
||||
@@ -781,6 +811,16 @@ impl WorkerController {
|
||||
manifest_toml.clone(),
|
||||
greeting,
|
||||
));
|
||||
let usage_state = shared_state.clone();
|
||||
let usage_events = working_event_tx.clone();
|
||||
worker.engine_mut().on_usage(move |event| {
|
||||
publish_context_usage(
|
||||
&usage_state,
|
||||
&usage_events,
|
||||
event.input_tokens,
|
||||
protocol::ContextTokenSource::Measured,
|
||||
);
|
||||
});
|
||||
if let Some(fs_for_view) = fs_for_view {
|
||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
||||
}
|
||||
@@ -1141,6 +1181,22 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||
// per-item commit channel is wired at the top of this function.
|
||||
}
|
||||
|
||||
fn validate_memory_lifecycle_targets(
|
||||
memory: &manifest::MemoryFeatureProfileConfig,
|
||||
subjektiv: &manifest::SubjektivFeatureProfileConfig,
|
||||
) -> std::io::Result<()> {
|
||||
let legacy_lifecycle =
|
||||
memory.enabled && (memory.extraction.enabled || memory.consolidation.request_enabled);
|
||||
let subjektiv_lifecycle = subjektiv.enabled && subjektiv.extraction.enabled;
|
||||
if legacy_lifecycle && subjektiv_lifecycle {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Workspace Memory and subjektiv lifecycles cannot both be enabled for one Worker",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register the builtin file-manipulation tools, optional memory tools,
|
||||
/// and the Worker-orchestration tools (SubWorkerSpawn + comm) on the Worker's
|
||||
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
|
||||
@@ -1250,28 +1306,95 @@ where
|
||||
),
|
||||
);
|
||||
}
|
||||
let memory_profile = &worker.manifest().feature.memory.profile;
|
||||
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
|
||||
validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?;
|
||||
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts().load_full(),
|
||||
)?;
|
||||
let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||
let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||
(
|
||||
plan.resident_summary_source.clone(),
|
||||
plan.system_prompt_override.clone(),
|
||||
)
|
||||
});
|
||||
let memory_lifecycle_config = memory_install_plan
|
||||
.as_ref()
|
||||
.map(|plan| plan.resolved_config.clone());
|
||||
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
|
||||
if let Some(plan) = memory_install_plan {
|
||||
feature_registry.add_module(plan.module);
|
||||
}
|
||||
if let Some(memory_config) = memory_lifecycle_config
|
||||
&& let Some(memory_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||
let subjektiv_consolidation_plan =
|
||||
crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts().load_full(),
|
||||
)?;
|
||||
if let Some(plan) = subjektiv_consolidation_plan {
|
||||
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
|
||||
feature_registry.add_module(plan.module);
|
||||
}
|
||||
let ordinary_subjektiv_features_enabled =
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest());
|
||||
if let Some(resident_summary_source) =
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
{
|
||||
feature_prompt_contribution = Some((Some(resident_summary_source), None));
|
||||
}
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_memory) =
|
||||
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(subjektiv_memory);
|
||||
}
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_sessions) =
|
||||
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(subjektiv_sessions);
|
||||
}
|
||||
if let Some(memory_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
memory_lifecycle_config,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(memory_lifecycle);
|
||||
}
|
||||
if let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
)? {
|
||||
feature_registry.add_module(surface_lifecycle);
|
||||
}
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
memory_config,
|
||||
worker.manifest().feature.subjektiv.clone(),
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
@@ -1282,7 +1405,7 @@ where
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(memory_lifecycle);
|
||||
feature_registry.add_module(subjektiv_lifecycle);
|
||||
}
|
||||
if sub_worker_enabled && !worker_enabled {
|
||||
feature_registry.add_module(
|
||||
@@ -1490,7 +1613,7 @@ where
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution {
|
||||
if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution {
|
||||
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
|
||||
}
|
||||
worker.attach_tracker(tracker);
|
||||
@@ -2443,6 +2566,12 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
};
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
&working_event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
if !matches!(
|
||||
result,
|
||||
Err(WorkerError::Store(_))
|
||||
@@ -2510,6 +2639,12 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
{
|
||||
worker.clear_in_flight_events();
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
&working_event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
let snapshot = shared_state.transition(WorkerState::Idle);
|
||||
let _ = working_event_tx.send(Event::WorkerState { snapshot });
|
||||
}
|
||||
@@ -3317,7 +3452,24 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
||||
fn public_reasoning_config(
|
||||
reasoning: &agen::llm_client::capability::ReasoningControl,
|
||||
) -> protocol::ReasoningConfig {
|
||||
match reasoning {
|
||||
agen::llm_client::capability::ReasoningControl::Effort(effort) => {
|
||||
protocol::ReasoningConfig::Effort {
|
||||
effort: effort.as_str().to_owned(),
|
||||
}
|
||||
}
|
||||
agen::llm_client::capability::ReasoningControl::BudgetTokens(budget_tokens) => {
|
||||
protocol::ReasoningConfig::BudgetTokens {
|
||||
budget_tokens: *budget_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
@@ -3360,6 +3512,7 @@ where
|
||||
.into_iter()
|
||||
.map(|def| def.name)
|
||||
.collect();
|
||||
let context_tokens = worker.total_tokens().tokens;
|
||||
protocol::Greeting {
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
cwd: worker
|
||||
@@ -3368,10 +3521,19 @@ where
|
||||
.unwrap_or_default(),
|
||||
provider: provider_name,
|
||||
model: model_id,
|
||||
reasoning: manifest
|
||||
.engine
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.map(public_reasoning_config),
|
||||
scope_summary: worker.scope_snapshot().summary(),
|
||||
tools: tool_names,
|
||||
context_window,
|
||||
context_tokens: worker.total_tokens().tokens,
|
||||
context_tokens,
|
||||
context_usage: Some(protocol::ContextUsage {
|
||||
tokens: context_tokens,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3410,6 +3572,123 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
#[test]
|
||||
fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() {
|
||||
let production = include_str!("controller.rs")
|
||||
.split_once("#[cfg(test)]\nmod tests")
|
||||
.map(|(production, _)| production)
|
||||
.expect("controller test module marker");
|
||||
let startup = production
|
||||
.split_once("async fn spawn_initialized")
|
||||
.map(|(_, startup)| startup)
|
||||
.expect("controller startup implementation");
|
||||
let feature_install = startup
|
||||
.find("let fs_for_view = register_worker_tools(")
|
||||
.expect("feature installation call");
|
||||
let materialize = startup
|
||||
.find(".materialize_durable_session_head()")
|
||||
.expect("runtime session-head materialization");
|
||||
let exposure = startup
|
||||
.find("let handle = WorkerHandle")
|
||||
.expect("controller handle exposure");
|
||||
let materialize_gate = startup[..materialize]
|
||||
.rfind("if runtime_managed && worker.needs_initial_session_head_materialization() {")
|
||||
.expect("fresh runtime-managed materialization gate");
|
||||
|
||||
assert!(feature_install < materialize_gate);
|
||||
assert!(materialize_gate < materialize);
|
||||
assert!(materialize < exposure);
|
||||
assert!(
|
||||
startup[materialize_gate..materialize]
|
||||
.contains("needs_initial_session_head_materialization")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_reasoning_config_preserves_effort_and_signed_budget() {
|
||||
let effort =
|
||||
public_reasoning_config(&agen::llm_client::capability::ReasoningControl::Effort(
|
||||
agen::llm_client::capability::ReasoningEffort::Other("provider-native".into()),
|
||||
));
|
||||
assert_eq!(
|
||||
effort,
|
||||
protocol::ReasoningConfig::Effort {
|
||||
effort: "provider-native".into(),
|
||||
}
|
||||
);
|
||||
|
||||
let budget = public_reasoning_config(
|
||||
&agen::llm_client::capability::ReasoningControl::BudgetTokens(-1),
|
||||
);
|
||||
assert_eq!(
|
||||
budget,
|
||||
protocol::ReasoningConfig::BudgetTokens { budget_tokens: -1 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_publication_updates_reconnect_state_and_live_clients() {
|
||||
let shared_state = Arc::new(WorkerSharedState::new(
|
||||
"worker".into(),
|
||||
session_store::new_segment_id(),
|
||||
String::new(),
|
||||
protocol::Greeting {
|
||||
worker_name: "worker".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "provider".into(),
|
||||
model: "model".into(),
|
||||
reasoning: None,
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 100_000,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
));
|
||||
let (events, mut receiver) = broadcast::channel(4);
|
||||
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
&events,
|
||||
Some(25_000),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
|
||||
let expected = protocol::ContextUsage {
|
||||
tokens: 25_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
};
|
||||
assert_eq!(shared_state.greeting().context_usage, Some(expected));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(Event::ContextUsage {
|
||||
usage: Some(usage)
|
||||
}) if usage == expected
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_subjektiv_lifecycles_are_mutually_exclusive() {
|
||||
let mut memory = manifest::MemoryFeatureProfileConfig::default();
|
||||
memory.enabled = true;
|
||||
memory.extraction.enabled = true;
|
||||
let mut subjektiv = manifest::SubjektivFeatureProfileConfig::default();
|
||||
subjektiv.enabled = true;
|
||||
subjektiv.extraction.enabled = true;
|
||||
|
||||
let error = validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(error.to_string().contains("cannot both be enabled"));
|
||||
|
||||
memory.extraction.enabled = false;
|
||||
assert!(
|
||||
validate_memory_lifecycle_targets(&memory, &subjektiv).is_err(),
|
||||
"legacy consolidation still installs the legacy lifecycle"
|
||||
);
|
||||
memory.consolidation.request_enabled = false;
|
||||
validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_controller_parent_notification_uses_durable_pending_authority() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -3551,6 +3830,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
));
|
||||
let notify_buffer = NotifyBuffer::new();
|
||||
@@ -3600,6 +3881,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -4308,6 +4591,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 1,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -1507,6 +1507,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1543,6 +1545,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1634,6 +1638,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1661,6 +1667,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1765,6 +1773,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Paused.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1820,6 +1830,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
|
||||
@@ -11,11 +11,15 @@ pub mod manage_worker;
|
||||
pub mod memory;
|
||||
pub(crate) mod memory_lifecycle;
|
||||
pub mod memory_staging_output;
|
||||
pub(crate) mod memory_surface_lifecycle;
|
||||
pub(crate) mod memory_surface_output;
|
||||
pub mod merge_request;
|
||||
pub mod objective;
|
||||
pub mod orchestration;
|
||||
mod resource_projection;
|
||||
pub mod session_explore;
|
||||
pub(crate) mod subjektiv_memory;
|
||||
pub(crate) mod subjektiv_session;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
pub mod worker_observation;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -33,10 +33,17 @@ pub(crate) struct MemoryStagingOutputState {
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
destination: MemoryStagingDestination,
|
||||
staged: Arc<Mutex<Vec<String>>>,
|
||||
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MemoryStagingDestination {
|
||||
WorkspaceMemory,
|
||||
Subjektiv { session_id: String },
|
||||
}
|
||||
|
||||
impl MemoryStagingOutputState {
|
||||
pub(crate) fn new(
|
||||
view: SessionCapture,
|
||||
@@ -49,6 +56,25 @@ impl MemoryStagingOutputState {
|
||||
workspace_client,
|
||||
source,
|
||||
extract_run_id,
|
||||
destination: MemoryStagingDestination::WorkspaceMemory,
|
||||
staged: Arc::new(Mutex::new(Vec::new())),
|
||||
finished: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_subjektiv(
|
||||
view: SessionCapture,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
session_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
view: Arc::new(view),
|
||||
workspace_client,
|
||||
source,
|
||||
extract_run_id,
|
||||
destination: MemoryStagingDestination::Subjektiv { session_id },
|
||||
staged: Arc::new(Mutex::new(Vec::new())),
|
||||
finished: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
@@ -201,34 +227,72 @@ impl Tool for StageMemoryCandidateTool {
|
||||
staleness: params.staleness,
|
||||
evidence_ids: params.entry_refs,
|
||||
};
|
||||
let result = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
||||
MemoryStageCandidateOperation {
|
||||
source: self.state.source.clone(),
|
||||
extract_run_id: self.state.extract_run_id.clone(),
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(map_memory_stage_error)?;
|
||||
let staging_ids = match result {
|
||||
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
|
||||
output.staging_ids
|
||||
let operation = MemoryStageCandidateOperation {
|
||||
source: self.state.source.clone(),
|
||||
extract_run_id: self.state.extract_run_id.clone(),
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
};
|
||||
let staging_ids = match &self.state.destination {
|
||||
MemoryStagingDestination::WorkspaceMemory => {
|
||||
let result = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
||||
operation,
|
||||
))
|
||||
.await
|
||||
.map_err(map_memory_stage_error)?;
|
||||
match result {
|
||||
MemoryBackendOperationResult::StagingWritten(output)
|
||||
if output.staging_count == 1 =>
|
||||
{
|
||||
output.staging_ids
|
||||
}
|
||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"StageMemoryCandidate expected one staging record, backend wrote {}",
|
||||
output.staging_count
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"StageMemoryCandidate expected one staging record, backend wrote {}",
|
||||
output.staging_count
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
||||
)));
|
||||
MemoryStagingDestination::Subjektiv { session_id } => {
|
||||
let response = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_server_operation(
|
||||
crate::worker::WorkspaceServerOperation::SubjektivStageCandidate(
|
||||
server_api::SubjektivStageCandidateRequest {
|
||||
session_id: session_id.clone(),
|
||||
operation,
|
||||
},
|
||||
),
|
||||
)
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"write subjektiv staging failed: {error}"
|
||||
))
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"write subjektiv staging returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
let output: server_api::SubjektivStageCandidateResponse =
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"decode subjektiv staging response: {error}"
|
||||
))
|
||||
})?;
|
||||
vec![output.staging_id]
|
||||
}
|
||||
};
|
||||
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
|
||||
@@ -346,7 +410,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin
|
||||
}
|
||||
}
|
||||
|
||||
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
||||
pub(crate) fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
||||
StagingEvidence {
|
||||
id: entry.entry_ref.to_string(),
|
||||
kind: evidence_kind(entry),
|
||||
@@ -357,7 +421,7 @@ fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
||||
}
|
||||
}
|
||||
|
||||
fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
|
||||
pub(crate) fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
|
||||
SourceEvidenceRef {
|
||||
segment_id: Some(entry.segment_id.clone()),
|
||||
entry_range: Some(entry.entry_range),
|
||||
@@ -425,10 +489,45 @@ fn truncate_line(text: &str, max_chars: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use agen::Item;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RecordingSubjektivClient {
|
||||
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingSubjektivClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"subjektiv-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse {
|
||||
staging_id: "subject-candidate-1".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn state() -> MemoryStagingOutputState {
|
||||
MemoryStagingOutputState::new(
|
||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
@@ -503,6 +602,39 @@ mod tests {
|
||||
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_destination_sends_committed_session_without_subject_scope() {
|
||||
let client = Arc::new(RecordingSubjektivClient::default());
|
||||
let state = MemoryStagingOutputState::new_subjektiv(
|
||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
client.clone(),
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
},
|
||||
"run-1".to_string(),
|
||||
"session-committed".to_string(),
|
||||
);
|
||||
let tool = StageMemoryCandidateTool {
|
||||
state: state.clone(),
|
||||
};
|
||||
tool.execute(
|
||||
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.staged(), vec!["subject-candidate-1"]);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].path.ends_with("/subjektiv/staging"));
|
||||
let body = requests[0].body.as_deref().unwrap();
|
||||
assert!(body.contains("session-committed"));
|
||||
assert!(body.contains("why_useful"));
|
||||
assert!(!body.contains("subject_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
||||
let tool = StageMemoryCandidateTool { state: state() };
|
||||
|
||||
@@ -0,0 +1,977 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::llm_client::event::Event as LlmEvent;
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
use arc_swap::ArcSwap;
|
||||
use async_trait::async_trait;
|
||||
use futures_util::Stream;
|
||||
|
||||
use crate::PromptCatalog;
|
||||
use crate::Scope;
|
||||
use crate::feature::background::{
|
||||
BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger,
|
||||
FeatureBackgroundTask,
|
||||
};
|
||||
use crate::feature::builtin::memory::{
|
||||
WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile,
|
||||
};
|
||||
use crate::feature::builtin::memory_surface_output::{
|
||||
MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition,
|
||||
};
|
||||
use crate::feature::{
|
||||
BackgroundTaskDeclaration, FeatureDescriptor, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureModule, FeatureRegistryBuilder,
|
||||
};
|
||||
use crate::hook::{HookError, HookErrorCategory};
|
||||
use crate::internal_worker::{
|
||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec,
|
||||
run_internal_worker_with_cancel_sender,
|
||||
};
|
||||
use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext, WorkspaceClient};
|
||||
use manifest::WorkerManifest;
|
||||
|
||||
const TASK_NAME: &str = "subjektiv-memory-surface";
|
||||
const TASK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const MAX_GENERATION_ATTEMPTS: usize = 2;
|
||||
const EDITOR_MAX_TURNS: u32 = 3;
|
||||
const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SubjektivSurfaceLifecycleFeature {
|
||||
task: SubjektivSurfaceLifecycleTask,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SubjektivSurfaceLifecycleTask {
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
manifest: WorkerManifest,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
}
|
||||
|
||||
const INPUT_BUDGET_ERROR_MARKER: &str = "surface_editor_input_budget_exceeded";
|
||||
|
||||
struct InputBudgetLlmClient {
|
||||
inner: Box<dyn LlmClient>,
|
||||
input_token_budget: usize,
|
||||
}
|
||||
|
||||
impl Clone for InputBudgetLlmClient {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone_boxed(),
|
||||
input_token_budget: self.input_token_budget,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for InputBudgetLlmClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
let estimated = estimated_editor_request_tokens(&request)
|
||||
.map_err(|error| ClientError::Config(error.to_string()))?;
|
||||
if estimated > self.input_token_budget {
|
||||
return Err(ClientError::Config(format!(
|
||||
"{INPUT_BUDGET_ERROR_MARKER}: request estimate {estimated} exceeds {}",
|
||||
self.input_token_budget
|
||||
)));
|
||||
}
|
||||
self.inner.stream(request).await
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivSurfaceLifecycleFeature {
|
||||
pub(crate) fn from_manifest(
|
||||
lifecycle_enabled: bool,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
manifest: WorkerManifest,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest);
|
||||
if !lifecycle_enabled || !dedicated {
|
||||
return Ok(None);
|
||||
}
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !manifest.feature.subjektiv.execution_enabled() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv surface generation requires trusted subject attachment",
|
||||
));
|
||||
}
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv surface generation requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
task: SubjektivSurfaceLifecycleTask {
|
||||
workspace_client,
|
||||
manifest,
|
||||
client,
|
||||
prompts,
|
||||
workspace_context,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for SubjektivSurfaceLifecycleFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle")
|
||||
.with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.")
|
||||
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||
TASK_NAME,
|
||||
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let declaration = BackgroundTaskDeclaration::worker_managed(
|
||||
TASK_NAME,
|
||||
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
|
||||
);
|
||||
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
|
||||
spec.trigger = BackgroundTaskTrigger::RunCommitted;
|
||||
context.background_tasks().register(spec, self.task.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct SurfaceEditorFailure {
|
||||
error: HookError,
|
||||
reason_code: &'static str,
|
||||
}
|
||||
|
||||
impl SurfaceEditorFailure {
|
||||
fn input_budget(error: HookError) -> Self {
|
||||
Self {
|
||||
error,
|
||||
reason_code: "input_budget_exhausted",
|
||||
}
|
||||
}
|
||||
|
||||
fn editor(error: HookError) -> Self {
|
||||
Self {
|
||||
error,
|
||||
reason_code: "editor_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask {
|
||||
async fn run(
|
||||
&self,
|
||||
context: BackgroundTaskContext,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
) -> Result<(), HookError> {
|
||||
for attempt in 0..MAX_GENERATION_ATTEMPTS {
|
||||
context.generation_fence.ensure_current()?;
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let generation = self
|
||||
.workspace_client
|
||||
.prepare_subjektiv_memory_surface()
|
||||
.await
|
||||
.map_err(surface_hook_error)?;
|
||||
if generation.materials.is_empty() && generation.active_memory_count > 0 {
|
||||
self.record_failure(&generation.generation_id, "input_budget_exhausted")
|
||||
.await;
|
||||
tracing::warn!(
|
||||
active_memory_count = generation.active_memory_count,
|
||||
"subject Memory surface materials could not fit the bounded input"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let points = if generation.materials.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
match self.edit_surface(&generation, cancellation.clone()).await {
|
||||
Ok(points) => points,
|
||||
Err(failure) => {
|
||||
self.record_failure(&generation.generation_id, failure.reason_code)
|
||||
.await;
|
||||
tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "subject Memory surface editor failed");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
context.generation_fence.ensure_current()?;
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let publish = self
|
||||
.workspace_client
|
||||
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
|
||||
generation_id: generation.generation_id.clone(),
|
||||
points,
|
||||
})
|
||||
.await;
|
||||
match publish {
|
||||
Ok(output) => {
|
||||
tracing::debug!(
|
||||
snapshot_id = output.snapshot_id,
|
||||
store_revision = output.built_from_store_revision,
|
||||
empty = output.empty,
|
||||
"published subject Memory surface"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(WorkspaceMemoryBackendError::Http { status, .. })
|
||||
if status == reqwest::StatusCode::CONFLICT
|
||||
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
|
||||
{
|
||||
// Confirmed Memory moved while editing. Discard the stale
|
||||
// output and rebuild once from a fresh bounded generation.
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
self.record_failure(&generation.generation_id, "publish_failed")
|
||||
.await;
|
||||
tracing::warn!(%error, "subject Memory surface publication failed");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivSurfaceLifecycleTask {
|
||||
async fn edit_surface(
|
||||
&self,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
) -> Result<Vec<server_api::SubjektivSurfacePoint>, SurfaceEditorFailure> {
|
||||
let language = self
|
||||
.manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.workspace_settings()
|
||||
.map(|settings| settings.language)
|
||||
.ok_or_else(|| {
|
||||
SurfaceEditorFailure::editor(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
"surface editor requires bound subjektiv Workspace settings",
|
||||
))
|
||||
})?;
|
||||
let system_prompt = self
|
||||
.prompts
|
||||
.load_full()
|
||||
.subjektiv_memory_surface_system(&language)
|
||||
.map_err(|error| {
|
||||
SurfaceEditorFailure::editor(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
error.to_string(),
|
||||
))
|
||||
})?;
|
||||
let (input, editor_materials) = bounded_editor_input(system_prompt.as_str(), generation)
|
||||
.map_err(SurfaceEditorFailure::input_budget)?;
|
||||
let output_state =
|
||||
MemorySurfaceOutputState::new(editor_materials, generation.body_token_budget);
|
||||
let features = FeatureRegistryBuilder::new()
|
||||
.with_module(MemorySurfaceOutputFeature::new(output_state.clone()));
|
||||
let cancellation_observer = cancellation.clone();
|
||||
let cancel_observer = move |sender: tokio::sync::mpsc::Sender<()>| {
|
||||
tokio::spawn(async move {
|
||||
cancellation_observer.cancelled().await;
|
||||
let _ = sender.send(()).await;
|
||||
});
|
||||
};
|
||||
let result = run_internal_worker_with_cancel_sender(
|
||||
InternalWorkerSpec {
|
||||
identity: InternalWorkerIdentity {
|
||||
kind: "subjektiv-memory-surface",
|
||||
run_id: uuid::Uuid::now_v7(),
|
||||
},
|
||||
manifest: self.manifest.clone(),
|
||||
client: Box::new(InputBudgetLlmClient {
|
||||
inner: self.client.clone_boxed(),
|
||||
input_token_budget: generation.input_token_budget,
|
||||
}),
|
||||
system_prompt,
|
||||
input,
|
||||
cache_key: None,
|
||||
max_turns: Some(EDITOR_MAX_TURNS),
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &["SubmitMemorySurface"],
|
||||
authority: InternalWorkerAuthority {
|
||||
workspace: self.workspace_context.clone(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
},
|
||||
cancel_observer,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
let message = format!("surface editor Internal Worker failed: {}", error.source);
|
||||
let error = HookError::new(HookErrorCategory::Internal, message.clone());
|
||||
return Err(if message.contains(INPUT_BUDGET_ERROR_MARKER) {
|
||||
SurfaceEditorFailure::input_budget(error)
|
||||
} else {
|
||||
SurfaceEditorFailure::editor(error)
|
||||
});
|
||||
}
|
||||
output_state
|
||||
.submitted()
|
||||
.ok_or_else(|| {
|
||||
HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
"surface editor finished without valid structured output",
|
||||
)
|
||||
})
|
||||
.map_err(SurfaceEditorFailure::editor)
|
||||
}
|
||||
|
||||
async fn record_failure(&self, generation_id: &str, reason_code: &str) {
|
||||
if let Err(error) = self
|
||||
.workspace_client
|
||||
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
|
||||
generation_id: generation_id.to_string(),
|
||||
reason_code: reason_code.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%error, "could not record subject Memory surface failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_editor_input(
|
||||
system_prompt: &str,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
) -> Result<(String, Vec<server_api::SubjektivSurfaceMaterial>), HookError> {
|
||||
let empty_tokens = editor_request_tokens_for_materials(system_prompt, generation, &[])?;
|
||||
if empty_tokens > generation.input_token_budget {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
format!(
|
||||
"surface editor fixed prompt, JSON framing, and tool schema exceed the {} token input budget (estimated {empty_tokens})",
|
||||
generation.input_token_budget
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut populated_kinds = Vec::new();
|
||||
for material in &generation.materials {
|
||||
if !populated_kinds.contains(&material.kind) {
|
||||
populated_kinds.push(material.kind.clone());
|
||||
}
|
||||
}
|
||||
let material_token_budget = generation.input_token_budget - empty_tokens;
|
||||
let per_kind_token_budget = material_token_budget / populated_kinds.len().max(1);
|
||||
let mut accepted = vec![false; generation.materials.len()];
|
||||
let mut deferred = Vec::new();
|
||||
|
||||
for (index, material) in generation.materials.iter().enumerate() {
|
||||
let kind_materials = generation
|
||||
.materials
|
||||
.iter()
|
||||
.zip(&accepted)
|
||||
.filter(|(selected, accepted)| **accepted && selected.kind == material.kind)
|
||||
.map(|(selected, _)| selected)
|
||||
.chain(std::iter::once(material))
|
||||
.collect::<Vec<_>>();
|
||||
let all_materials = generation
|
||||
.materials
|
||||
.iter()
|
||||
.zip(&accepted)
|
||||
.filter(|(_, accepted)| **accepted)
|
||||
.map(|(selected, _)| selected)
|
||||
.chain(std::iter::once(material))
|
||||
.collect::<Vec<_>>();
|
||||
let kind_cost =
|
||||
editor_request_tokens_for_materials(system_prompt, generation, &kind_materials)?
|
||||
.saturating_sub(empty_tokens);
|
||||
let all_tokens =
|
||||
editor_request_tokens_for_materials(system_prompt, generation, &all_materials)?;
|
||||
if kind_cost <= per_kind_token_budget && all_tokens <= generation.input_token_budget {
|
||||
accepted[index] = true;
|
||||
} else {
|
||||
deferred.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Equal per-kind shares reserve room in the final normalized request. Any
|
||||
// unused share is reclaimed in the original canonical round-robin order.
|
||||
for index in deferred {
|
||||
let material = &generation.materials[index];
|
||||
let all_materials = generation
|
||||
.materials
|
||||
.iter()
|
||||
.zip(&accepted)
|
||||
.filter(|(_, accepted)| **accepted)
|
||||
.map(|(selected, _)| selected)
|
||||
.chain(std::iter::once(material))
|
||||
.collect::<Vec<_>>();
|
||||
if editor_request_tokens_for_materials(system_prompt, generation, &all_materials)?
|
||||
<= generation.input_token_budget
|
||||
{
|
||||
accepted[index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let materials = generation
|
||||
.materials
|
||||
.iter()
|
||||
.zip(accepted)
|
||||
.filter_map(|(material, accepted)| accepted.then_some(material.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if materials.is_empty() && !generation.materials.is_empty() {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
format!(
|
||||
"surface editor fixed prompt and material overhead left no grounded material within the {} token input budget",
|
||||
generation.input_token_budget
|
||||
),
|
||||
));
|
||||
}
|
||||
let material_refs = materials.iter().collect::<Vec<_>>();
|
||||
let input = serialize_editor_input(generation, &material_refs)?;
|
||||
Ok((input, materials))
|
||||
}
|
||||
|
||||
fn editor_request_tokens_for_materials(
|
||||
system_prompt: &str,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
materials: &[&server_api::SubjektivSurfaceMaterial],
|
||||
) -> Result<usize, HookError> {
|
||||
let input = serialize_editor_input(generation, materials)?;
|
||||
let request = Request::new()
|
||||
.system(system_prompt)
|
||||
.user(input)
|
||||
.tool(submit_llm_tool_definition());
|
||||
estimated_editor_request_tokens(&request)
|
||||
}
|
||||
|
||||
fn serialize_editor_input(
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
materials: &[&server_api::SubjektivSurfaceMaterial],
|
||||
) -> Result<String, HookError> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"question": EDITOR_QUESTION,
|
||||
"body_token_budget": generation.body_token_budget,
|
||||
"input_token_budget": generation.input_token_budget,
|
||||
"materials": materials,
|
||||
}))
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))
|
||||
}
|
||||
|
||||
fn estimated_editor_request_tokens(request: &Request) -> Result<usize, HookError> {
|
||||
// Serialize the actual prompt-bearing Agen request fields. This is the
|
||||
// stable provider-independent boundary; provider-specific HTTP envelopes
|
||||
// are intentionally outside the configured editor-input budget.
|
||||
let request = serde_json::to_vec(&serde_json::json!({
|
||||
"system_prompt": &request.system_prompt,
|
||||
"items": &request.items,
|
||||
"tools": &request.tools,
|
||||
}))
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?;
|
||||
Ok(request.len().saturating_add(3) / 4)
|
||||
}
|
||||
|
||||
fn surface_hook_error(error: WorkspaceMemoryBackendError) -> HookError {
|
||||
HookError::new(HookErrorCategory::Internal, error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::{ClientError, Request};
|
||||
use futures::Stream;
|
||||
use memory::extract::CandidateKind;
|
||||
|
||||
use super::*;
|
||||
use crate::feature::FeatureId;
|
||||
use crate::feature::background::FeatureBackgroundTaskRegistryBuilder;
|
||||
use crate::hook::HookInvocationContext;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
|
||||
fn material(id: &str, body_bytes: usize) -> server_api::SubjektivSurfaceMaterial {
|
||||
material_with_body(id, CandidateKind::Constraint, "x".repeat(body_bytes))
|
||||
}
|
||||
|
||||
fn material_with_body(
|
||||
id: &str,
|
||||
kind: CandidateKind,
|
||||
body_md: String,
|
||||
) -> server_api::SubjektivSurfaceMaterial {
|
||||
server_api::SubjektivSurfaceMaterial {
|
||||
memory_id: id.into(),
|
||||
revision: 1,
|
||||
kind,
|
||||
body_md,
|
||||
why_useful: "budget fixture".into(),
|
||||
staleness: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generation(
|
||||
input_token_budget: usize,
|
||||
materials: Vec<server_api::SubjektivSurfaceMaterial>,
|
||||
) -> server_api::SubjektivSurfacePrepareResponse {
|
||||
server_api::SubjektivSurfacePrepareResponse {
|
||||
generation_id: "generation-1".into(),
|
||||
store_revision: 1,
|
||||
active_memory_count: materials.len(),
|
||||
materials,
|
||||
body_token_budget: 1_024,
|
||||
input_token_budget,
|
||||
per_kind_limit: 8,
|
||||
total_material_limit: 24,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_request(system_prompt: &str, input: &str) -> Request {
|
||||
Request::new()
|
||||
.system(system_prompt)
|
||||
.user(input)
|
||||
.tool(submit_llm_tool_definition())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SurfaceWorkspaceClient {
|
||||
conflict_count: usize,
|
||||
input_token_budget: usize,
|
||||
prepare_calls: AtomicUsize,
|
||||
publish_calls: AtomicUsize,
|
||||
failure_reasons: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SurfaceWorkspaceClient {
|
||||
fn new(conflict_count: usize) -> Self {
|
||||
Self {
|
||||
conflict_count,
|
||||
input_token_budget: 12_000,
|
||||
prepare_calls: AtomicUsize::new(0),
|
||||
publish_calls: AtomicUsize::new(0),
|
||||
failure_reasons: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_input_token_budget(input_token_budget: usize) -> Self {
|
||||
Self {
|
||||
input_token_budget,
|
||||
..Self::new(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&value).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for SurfaceWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"surface-lifecycle-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let request: server_api::SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default())
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
match request.operation {
|
||||
server_api::SubjektivMemoryBackendOperation::PrepareSurface(_) => {
|
||||
let call = self.prepare_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let mut prepared =
|
||||
generation(self.input_token_budget, vec![material("memory-1", 32)]);
|
||||
prepared.generation_id = format!("generation-{call}");
|
||||
prepared.store_revision = call as u64;
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
|
||||
))
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::PublishSurface(input) => {
|
||||
assert_eq!(input.points.len(), 1);
|
||||
let call = self.publish_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if call <= self.conflict_count {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: "store generation moved".into(),
|
||||
});
|
||||
}
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
|
||||
server_api::SubjektivSurfacePublishResponse {
|
||||
snapshot_id: format!("surface-{call}"),
|
||||
built_from_store_revision: call as u64,
|
||||
empty: false,
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::FailSurface(input) => {
|
||||
self.failure_reasons.lock().unwrap().push(input.reason_code);
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
|
||||
server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: self.prepare_calls.load(Ordering::SeqCst) as u64,
|
||||
status: "failed".into(),
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
_ => Err(WorkspaceClientError::Unavailable(
|
||||
"surface lifecycle test only accepts surface operations".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SurfaceEditorClient {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for SurfaceEditorClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
let call = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let events = if call % 2 == 0 {
|
||||
vec![
|
||||
LlmEvent::tool_use_start(0, format!("submit-{call}"), "SubmitMemorySurface"),
|
||||
LlmEvent::tool_input_delta(
|
||||
0,
|
||||
serde_json::json!({
|
||||
"points": [{
|
||||
"body_md": "- Keep the confirmed constraint.",
|
||||
"memory_refs": [{"memory_id": "memory-1", "revision": 1}]
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
LlmEvent::tool_use_stop(0),
|
||||
LlmEvent::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
LlmEvent::text_block_start(0),
|
||||
LlmEvent::text_delta(0, "submitted"),
|
||||
LlmEvent::text_block_stop(0, None),
|
||||
LlmEvent::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
};
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
|
||||
fn test_manifest() -> WorkerManifest {
|
||||
let mut manifest = WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "surface-lifecycle-test"
|
||||
scope = "main"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/surface-lifecycle-test"
|
||||
permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.feature.subjektiv.profile.enabled = true;
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".into(),
|
||||
settings_revision: 1,
|
||||
language: "English".into(),
|
||||
})
|
||||
.unwrap();
|
||||
manifest
|
||||
}
|
||||
|
||||
fn test_task(
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
) -> SubjektivSurfaceLifecycleTask {
|
||||
SubjektivSurfaceLifecycleTask {
|
||||
workspace_client,
|
||||
manifest: test_manifest(),
|
||||
client: Box::new(SurfaceEditorClient { calls }),
|
||||
prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
|
||||
workspace_context: WorkerWorkspaceContext::no_workspace(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_task(task: SubjektivSurfaceLifecycleTask) {
|
||||
let declaration = BackgroundTaskDeclaration::worker_managed(
|
||||
TASK_NAME,
|
||||
"surface lifecycle integration test",
|
||||
);
|
||||
let mut spec = BackgroundTaskSpec::single_flight(declaration, Duration::from_secs(5));
|
||||
spec.trigger = BackgroundTaskTrigger::RunCommitted;
|
||||
let mut builder = FeatureBackgroundTaskRegistryBuilder::default();
|
||||
builder
|
||||
.register(FeatureId::builtin("surface-lifecycle-test"), spec, task)
|
||||
.unwrap();
|
||||
let registry = builder.build();
|
||||
registry
|
||||
.start_run_committed(HookInvocationContext {
|
||||
worker_id: "worker-1".into(),
|
||||
session_id: "session-1".into(),
|
||||
session_revision: 1,
|
||||
run_id: Some("run-1".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if !registry.diagnostics().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("surface lifecycle task should finish");
|
||||
registry.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_input_budget_counts_prompt_json_question_and_tool_schema() {
|
||||
let generation = generation(
|
||||
4_000,
|
||||
vec![material("memory-1", 9_000), material("memory-2", 9_000)],
|
||||
);
|
||||
let (input, selected) = bounded_editor_input("system policy", &generation).unwrap();
|
||||
|
||||
assert_eq!(selected.len(), 1, "deterministically bounds one category");
|
||||
assert_eq!(selected[0].memory_id, "memory-1");
|
||||
assert!(input.contains(EDITOR_QUESTION));
|
||||
assert!(
|
||||
estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap()
|
||||
<= generation.input_token_budget
|
||||
);
|
||||
let materials_only_tokens = (serde_json::to_vec(&selected)
|
||||
.unwrap()
|
||||
.len()
|
||||
.saturating_add(3))
|
||||
/ 4;
|
||||
assert!(
|
||||
estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap()
|
||||
> materials_only_tokens,
|
||||
"full accounting must include framing and the SubmitMemorySurface schema"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_editor_budget_preserves_feasible_nonempty_categories() {
|
||||
let materials = vec![
|
||||
material_with_body(
|
||||
"preference-large",
|
||||
CandidateKind::Preference,
|
||||
"\\\"".repeat(8_000),
|
||||
),
|
||||
material_with_body(
|
||||
"constraint-small",
|
||||
CandidateKind::Constraint,
|
||||
"constraint".into(),
|
||||
),
|
||||
material_with_body("decision-small", CandidateKind::Decision, "decision".into()),
|
||||
material_with_body(
|
||||
"assumption-small",
|
||||
CandidateKind::WorkingAssumption,
|
||||
"assumption".into(),
|
||||
),
|
||||
material_with_body(
|
||||
"question-small",
|
||||
CandidateKind::OpenQuestion,
|
||||
"question".into(),
|
||||
),
|
||||
material_with_body("lesson-small", CandidateKind::Lesson, "lesson".into()),
|
||||
];
|
||||
let mut generation = generation(9_999, materials);
|
||||
for _ in 0..3 {
|
||||
let large = editor_request_tokens_for_materials(
|
||||
"system policy",
|
||||
&generation,
|
||||
&[&generation.materials[0]],
|
||||
)
|
||||
.unwrap();
|
||||
let small = editor_request_tokens_for_materials(
|
||||
"system policy",
|
||||
&generation,
|
||||
&generation.materials[1..].iter().collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
generation.input_token_budget = large.max(small);
|
||||
}
|
||||
let all = editor_request_tokens_for_materials(
|
||||
"system policy",
|
||||
&generation,
|
||||
&generation.materials.iter().collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(all > generation.input_token_budget);
|
||||
|
||||
let (input, selected) = bounded_editor_input("system policy", &generation).unwrap();
|
||||
assert!(
|
||||
!selected
|
||||
.iter()
|
||||
.any(|item| item.kind == CandidateKind::Preference)
|
||||
);
|
||||
for kind in [
|
||||
CandidateKind::Constraint,
|
||||
CandidateKind::Decision,
|
||||
CandidateKind::WorkingAssumption,
|
||||
CandidateKind::OpenQuestion,
|
||||
CandidateKind::Lesson,
|
||||
] {
|
||||
assert!(selected.iter().any(|item| item.kind == kind));
|
||||
}
|
||||
assert!(
|
||||
estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap()
|
||||
<= generation.input_token_budget
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_editor_request_is_rejected_before_provider_when_over_budget() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let client = InputBudgetLlmClient {
|
||||
inner: Box::new(SurfaceEditorClient {
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
input_token_budget: 32,
|
||||
};
|
||||
let request = editor_request("system policy", &"x".repeat(1_000));
|
||||
|
||||
let error = match client.stream(request).await {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("over-budget request reached the provider"),
|
||||
};
|
||||
assert!(error.to_string().contains(INPUT_BUDGET_ERROR_MARKER));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn correction_turns_remain_subject_to_editor_input_budget() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let client = InputBudgetLlmClient {
|
||||
inner: Box::new(SurfaceEditorClient {
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
input_token_budget: 2_000,
|
||||
};
|
||||
let _stream = client
|
||||
.stream(editor_request("system policy", "small initial request"))
|
||||
.await
|
||||
.unwrap();
|
||||
let request =
|
||||
editor_request("system policy", "small initial request").assistant("x".repeat(12_000));
|
||||
let error = match client.stream(request).await {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("over-budget request reached the provider"),
|
||||
};
|
||||
|
||||
assert!(error.to_string().contains(INPUT_BUDGET_ERROR_MARKER));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_rebuilds_once_after_a_store_generation_conflict() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(1));
|
||||
let editor_calls = Arc::new(AtomicUsize::new(0));
|
||||
run_task(test_task(workspace.clone(), editor_calls.clone())).await;
|
||||
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
|
||||
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
|
||||
assert_eq!(editor_calls.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_bounds_conflict_retries_and_records_terminal_failure() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(MAX_GENERATION_ATTEMPTS));
|
||||
let editor_calls = Arc::new(AtomicUsize::new(0));
|
||||
run_task(test_task(workspace.clone(), editor_calls.clone())).await;
|
||||
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(editor_calls.load(Ordering::SeqCst), 4);
|
||||
assert_eq!(
|
||||
workspace.failure_reasons.lock().unwrap().as_slice(),
|
||||
["publish_failed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_records_input_budget_exhaustion_without_provider_or_publish() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
|
||||
let editor_calls = Arc::new(AtomicUsize::new(0));
|
||||
run_task(test_task(workspace.clone(), editor_calls.clone())).await;
|
||||
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(editor_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
workspace.failure_reasons.lock().unwrap().as_slice(),
|
||||
["input_budget_exhausted"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_input_budget_never_relabels_trimmed_nonempty_materials_as_empty() {
|
||||
let generation = generation(1, vec![material("memory-1", 32)]);
|
||||
let error = bounded_editor_input("system policy", &generation).unwrap_err();
|
||||
assert!(error.to_string().contains("input budget"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
|
||||
const SUBMIT_TOOL: &str = "SubmitMemorySurface";
|
||||
const SUBMIT_DESCRIPTION: &str = "Submit a bounded Markdown Memory surface whose every point cites exact refs from the supplied confirmed-Memory materials. Reference validation proves existence and scope, not semantic correctness.";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MemorySurfaceOutputState {
|
||||
materials: Arc<Vec<server_api::SubjektivSurfaceMaterial>>,
|
||||
body_token_budget: usize,
|
||||
submitted: Arc<Mutex<Option<Vec<server_api::SubjektivSurfacePoint>>>>,
|
||||
}
|
||||
|
||||
impl MemorySurfaceOutputState {
|
||||
pub(crate) fn new(
|
||||
materials: Vec<server_api::SubjektivSurfaceMaterial>,
|
||||
body_token_budget: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
materials: Arc::new(materials),
|
||||
body_token_budget,
|
||||
submitted: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn submitted(&self) -> Option<Vec<server_api::SubjektivSurfacePoint>> {
|
||||
self.submitted
|
||||
.lock()
|
||||
.expect("Memory surface output state poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn validate(&self, points: &[server_api::SubjektivSurfacePoint]) -> Result<(), ToolError> {
|
||||
if self.materials.is_empty() {
|
||||
return if points.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ToolError::InvalidArgument(
|
||||
"empty materials require an empty points array".into(),
|
||||
))
|
||||
};
|
||||
}
|
||||
if points.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"non-empty materials require at least one grounded point".into(),
|
||||
));
|
||||
}
|
||||
if points.len() > 24 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"points count {} exceeds the configured limit 24",
|
||||
points.len()
|
||||
)));
|
||||
}
|
||||
let allowed = self
|
||||
.materials
|
||||
.iter()
|
||||
.map(|material| (material.memory_id.as_str(), material.revision))
|
||||
.collect::<HashSet<_>>();
|
||||
let mut bodies = Vec::with_capacity(points.len());
|
||||
for point in points {
|
||||
let body = point.body_md.trim();
|
||||
if body.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"surface point body_md must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if point.memory_refs.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"every surface point requires at least one memory_ref".into(),
|
||||
));
|
||||
}
|
||||
let mut point_refs = HashSet::new();
|
||||
for reference in &point.memory_refs {
|
||||
if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"duplicate surface point ref {}@{}",
|
||||
reference.memory_id, reference.revision
|
||||
)));
|
||||
}
|
||||
if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"surface ref {}@{} is not in the generation materials",
|
||||
reference.memory_id, reference.revision
|
||||
)));
|
||||
}
|
||||
}
|
||||
bodies.push(body);
|
||||
}
|
||||
let bytes = bodies
|
||||
.iter()
|
||||
.map(|body| body.len())
|
||||
.sum::<usize>()
|
||||
.saturating_add(points.len().saturating_sub(1) * 2);
|
||||
let tokens = bytes.saturating_add(3) / 4;
|
||||
if tokens > self.body_token_budget {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"surface body token estimate {tokens} exceeds budget {}",
|
||||
self.body_token_budget
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MemorySurfaceOutputFeature {
|
||||
state: MemorySurfaceOutputState,
|
||||
}
|
||||
|
||||
impl MemorySurfaceOutputFeature {
|
||||
pub(crate) fn new(state: MemorySurfaceOutputState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for MemorySurfaceOutputFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("memory-surface-output", "Memory Surface Output")
|
||||
.with_description(
|
||||
"Restricted structured output for one clean-context Memory surface editor.",
|
||||
)
|
||||
.with_tool(ToolDeclaration::new(SUBMIT_TOOL, SUBMIT_DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.tools().register(ToolContribution::new(
|
||||
SUBMIT_TOOL,
|
||||
submit_definition(self.state.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_input_schema() -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(SubmitMemorySurfaceParams))
|
||||
.unwrap_or_else(|_| serde_json::json!({}))
|
||||
}
|
||||
|
||||
pub(crate) fn submit_llm_tool_definition() -> agen::llm_client::ToolDefinition {
|
||||
agen::llm_client::ToolDefinition::new(SUBMIT_TOOL)
|
||||
.description(SUBMIT_DESCRIPTION)
|
||||
.input_schema(submit_input_schema())
|
||||
}
|
||||
|
||||
fn submit_definition(state: MemorySurfaceOutputState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let definition = submit_llm_tool_definition();
|
||||
let meta = ToolMeta::new(definition.name)
|
||||
.description(
|
||||
definition
|
||||
.description
|
||||
.expect("SubmitMemorySurface description is fixed"),
|
||||
)
|
||||
.input_schema(definition.input_schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubmitMemorySurfaceTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmitMemorySurfaceParams {
|
||||
points: Vec<server_api::SubjektivSurfacePoint>,
|
||||
}
|
||||
|
||||
struct SubmitMemorySurfaceTool {
|
||||
state: MemorySurfaceOutputState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SubmitMemorySurfaceTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_context: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: SubmitMemorySurfaceParams =
|
||||
serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid SubmitMemorySurface input: {error}"))
|
||||
})?;
|
||||
self.state.validate(¶ms.points)?;
|
||||
let mut submitted = self
|
||||
.state
|
||||
.submitted
|
||||
.lock()
|
||||
.expect("Memory surface output state poisoned");
|
||||
if submitted.is_some() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"SubmitMemorySurface already accepted output for this generation".into(),
|
||||
));
|
||||
}
|
||||
let count = params.points.len();
|
||||
*submitted = Some(params.points);
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Accepted {count} grounded Memory surface point(s)."),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use memory::extract::CandidateKind;
|
||||
|
||||
fn material(id: &str) -> server_api::SubjektivSurfaceMaterial {
|
||||
server_api::SubjektivSurfaceMaterial {
|
||||
memory_id: id.into(),
|
||||
revision: 1,
|
||||
kind: CandidateKind::Constraint,
|
||||
body_md: "Keep the boundary.".into(),
|
||||
why_useful: "Avoid regression.".into(),
|
||||
staleness: Some("Until the boundary changes.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn point(id: &str) -> server_api::SubjektivSurfacePoint {
|
||||
server_api::SubjektivSurfacePoint {
|
||||
body_md: "- Keep the boundary unless it changes.".into(),
|
||||
memory_refs: vec![server_api::SubjektivMemoryRevisionRef {
|
||||
memory_id: id.into(),
|
||||
revision: 1,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_material_external_refs_and_budget_overflow() {
|
||||
let state = MemorySurfaceOutputState::new(vec![material("allowed")], 32);
|
||||
assert!(state.validate(&[]).is_err());
|
||||
assert!(state.validate(&[point("other")]).is_err());
|
||||
let mut oversized = point("allowed");
|
||||
oversized.body_md = "x".repeat(132);
|
||||
assert!(state.validate(&[oversized]).is_err());
|
||||
assert!(state.validate(&[point("allowed")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_materials_accept_only_normal_empty_output() {
|
||||
let state = MemorySurfaceOutputState::new(Vec::new(), 32);
|
||||
assert!(state.validate(&[]).is_ok());
|
||||
assert!(state.validate(&[point("invented")]).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,855 @@
|
||||
//! Host-scoped tools for exploring subject-attributed committed Sessions.
|
||||
//!
|
||||
//! Subject, Runtime, Worker, archive, and filesystem authority stays bound by
|
||||
//! the Workspace host. Model input contains only the selectors and filters from
|
||||
//! the public `server_api` request types.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use server_api::{
|
||||
SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivRecordSessionRequest,
|
||||
SubjektivRecordSessionResponse, SubjektivSessionBackendOperation,
|
||||
SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult,
|
||||
SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest,
|
||||
SubjektivSessionReadRequest, SubjektivSessionSearchRequest,
|
||||
};
|
||||
|
||||
use crate::feature::session::CommittedSessionCaptureHandle;
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy,
|
||||
HookPreRequestAction, PreLlmRequest, PreRequestContext, RunCommitted, RunCommittedContext,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
|
||||
|
||||
const LIST_TOOL: &str = "SubjektivSessionList";
|
||||
const SEARCH_TOOL: &str = "SubjektivSessionSearch";
|
||||
const READ_TOOL: &str = "SubjektivSessionRead";
|
||||
const COMMIT_HOOK: &str = "record-subjektiv-session-after-commit";
|
||||
const RETRY_HOOK: &str = "retry-subjektiv-session-attribution";
|
||||
|
||||
const LIST_DESCRIPTION: &str = "List committed retained or archived Sessions attributed by the Host to the connected subject. Filter by exact session_id or storage and continue with the returned opaque cursor; subject, Runtime, Worker, archive, and filesystem authority are never model inputs.";
|
||||
const SEARCH_DESCRIPTION: &str = "Search committed public entries across Host-authorized Sessions attributed to the connected subject, or list one Session's public entries when session_id is supplied and query is omitted. Search is case-insensitive literal matching; system prompts, hidden reasoning, traces, and attachment bodies are never exposed.";
|
||||
const READ_DESCRIPTION: &str = "Read one exact committed public Session entry selected by session_id, segment_id, and entry_ref. Compact mode is the default; full mode remains bounded, and partial content must be continued with the returned opaque cursor.";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SubjektivSessionFeature {
|
||||
state: SubjektivSessionState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SubjektivSessionState {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
capture: CommittedSessionCaptureHandle,
|
||||
}
|
||||
|
||||
impl SubjektivSessionFeature {
|
||||
pub(crate) fn from_resolved_config(
|
||||
config: &manifest::ResolvedSubjektivFeatureConfig,
|
||||
capture: CommittedSessionCaptureHandle,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !config.execution_enabled() {
|
||||
return Ok(None);
|
||||
}
|
||||
config
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !client.is_available() || client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv Session tools require Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
state: SubjektivSessionState { client, capture },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for SubjektivSessionFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("subjektiv-session-tools", "subjektiv Session Tools")
|
||||
.with_description(
|
||||
"Read-only discovery, search, and reading of Host-authorized subject Sessions.",
|
||||
)
|
||||
.with_tool(ToolDeclaration::new(LIST_TOOL, LIST_DESCRIPTION))
|
||||
.with_tool(ToolDeclaration::new(SEARCH_TOOL, SEARCH_DESCRIPTION))
|
||||
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
|
||||
.with_hook(HookDeclaration::new(
|
||||
COMMIT_HOOK,
|
||||
FeatureHookPoint::RunCommitted,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
RETRY_HOOK,
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
for (name, description, operation) in [
|
||||
(LIST_TOOL, LIST_DESCRIPTION, SessionOperation::List),
|
||||
(SEARCH_TOOL, SEARCH_DESCRIPTION, SessionOperation::Search),
|
||||
(READ_TOOL, READ_DESCRIPTION, SessionOperation::Read),
|
||||
] {
|
||||
context.tools().register(ToolContribution::new(
|
||||
name,
|
||||
tool_definition(name, description, self.state.clone(), operation),
|
||||
))?;
|
||||
}
|
||||
context.hooks().add_run_committed(
|
||||
COMMIT_HOOK,
|
||||
HookExecutionPolicy::new(HookFailurePolicy::AttentionRequired, 30_000),
|
||||
RecordCommittedSessionHook {
|
||||
state: self.state.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_request(
|
||||
RETRY_HOOK,
|
||||
RetryCommittedSessionHook {
|
||||
state: self.state.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SessionOperation {
|
||||
List,
|
||||
Search,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn tool_definition(
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
state: SubjektivSessionState,
|
||||
operation: SessionOperation,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = match operation {
|
||||
SessionOperation::List => schema_for::<SubjektivSessionListRequest>(),
|
||||
SessionOperation::Search => schema_for::<SubjektivSessionSearchRequest>(),
|
||||
SessionOperation::Read => schema_for::<SubjektivSessionReadRequest>(),
|
||||
};
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubjektivSessionTool {
|
||||
state: state.clone(),
|
||||
operation,
|
||||
});
|
||||
(
|
||||
ToolMeta::new(name)
|
||||
.description(description)
|
||||
.input_schema(schema),
|
||||
tool,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct SubjektivSessionTool {
|
||||
state: SubjektivSessionState,
|
||||
operation: SessionOperation,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SubjektivSessionTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let operation = match self.operation {
|
||||
SessionOperation::List => {
|
||||
SubjektivSessionBackendOperation::List(parse(input_json, LIST_TOOL)?)
|
||||
}
|
||||
SessionOperation::Search => {
|
||||
SubjektivSessionBackendOperation::Search(parse(input_json, SEARCH_TOOL)?)
|
||||
}
|
||||
SessionOperation::Read => {
|
||||
SubjektivSessionBackendOperation::Read(parse(input_json, READ_TOOL)?)
|
||||
}
|
||||
};
|
||||
let response = self.state.execute(operation)?;
|
||||
model_visible_response(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivSessionState {
|
||||
fn execute(
|
||||
&self,
|
||||
operation: SubjektivSessionBackendOperation,
|
||||
) -> Result<SubjektivSessionBackendResponse, ToolError> {
|
||||
let response = self
|
||||
.client
|
||||
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
|
||||
SubjektivSessionBackendRequest { operation },
|
||||
))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
|
||||
if let Ok(response) =
|
||||
serde_json::from_str::<SubjektivSessionBackendResponse>(&response.body)
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
let detail = serde_json::from_str::<server_api::RepositoryApiError>(&response.body)
|
||||
.map(|error| error.message)
|
||||
.unwrap_or(response.body);
|
||||
Err(ToolError::ExecutionFailed(format!(
|
||||
"subjektiv Session backend returned HTTP {}: {detail}",
|
||||
response.status
|
||||
)))
|
||||
}
|
||||
|
||||
fn record_current_committed_session(&self) -> Result<(), HookError> {
|
||||
let capture = self
|
||||
.capture
|
||||
.capture()
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||
if !capture.has_committed_run {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"committed Session capture is not available yet",
|
||||
));
|
||||
}
|
||||
self.record_session(&capture.session_id)
|
||||
}
|
||||
|
||||
fn retry_current_committed_session(&self) -> Result<(), HookError> {
|
||||
let capture = self
|
||||
.capture
|
||||
.capture()
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||
if !capture.has_committed_run {
|
||||
return Ok(());
|
||||
}
|
||||
self.record_session(&capture.session_id)
|
||||
}
|
||||
|
||||
fn record_session(&self, session_id: &str) -> Result<(), HookError> {
|
||||
let response = self
|
||||
.client
|
||||
.execute_server_operation(WorkspaceServerOperation::SubjektivRecordSession(
|
||||
SubjektivRecordSessionRequest {
|
||||
session_id: session_id.to_string(),
|
||||
},
|
||||
))
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
format!(
|
||||
"record subjektiv Session attribution returned HTTP {}",
|
||||
response.status
|
||||
),
|
||||
));
|
||||
}
|
||||
let output: SubjektivRecordSessionResponse =
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
format!("decode subjektiv Session attribution response: {error}"),
|
||||
)
|
||||
})?;
|
||||
if output.session_id != session_id {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"subjektiv Session attribution response changed session identity",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordCommittedSessionHook {
|
||||
state: SubjektivSessionState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<RunCommitted> for RecordCommittedSessionHook {
|
||||
async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> {
|
||||
self.state.record_current_committed_session()
|
||||
}
|
||||
}
|
||||
|
||||
struct RetryCommittedSessionHook {
|
||||
state: SubjektivSessionState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for RetryCommittedSessionHook {
|
||||
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||
self.state.retry_current_committed_session()?;
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(input: &str, tool: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
}
|
||||
|
||||
fn schema_for<T: JsonSchema>() -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({}))
|
||||
}
|
||||
|
||||
fn model_visible_response(
|
||||
response: SubjektivSessionBackendResponse,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let summary = response_summary(&response);
|
||||
let content = serde_json::to_string(&response)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES {
|
||||
return Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let bounded = SubjektivSessionBackendResponse::Error {
|
||||
error: SubjektivSessionErrorResponse {
|
||||
code: SubjektivSessionDiagnosticCode::ResourceLimit,
|
||||
message: "subjektiv Session response exceeded the 56 KiB model-visible JSON budget"
|
||||
.to_string(),
|
||||
},
|
||||
};
|
||||
let content = serde_json::to_string(&bounded)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
debug_assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
|
||||
Ok(ToolOutput {
|
||||
summary: "Subjektiv Session operation reached its output limit.".to_string(),
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn response_summary(response: &SubjektivSessionBackendResponse) -> String {
|
||||
match response {
|
||||
SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::List(value),
|
||||
} => format!("Listed {} subject Session(s).", value.items.len()),
|
||||
SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::Search(value),
|
||||
} => format!("Found {} subject Session entries.", value.items.len()),
|
||||
SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::Read(value),
|
||||
} => format!("Read subject Session entry {}.", value.entry_ref),
|
||||
SubjektivSessionBackendResponse::Error { error } => {
|
||||
format!("Subjektiv Session operation returned {:?}.", error.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::feature::session::{CommittedRunExit, CommittedSessionCapture};
|
||||
use crate::hook::{HookHistoryRange, HookInvocationContext, PreRequestInfo, RunCommittedExit};
|
||||
use crate::worker::{
|
||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionToolClient {
|
||||
response: Mutex<SubjektivSessionBackendResponse>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl SessionToolClient {
|
||||
fn new(response: SubjektivSessionBackendResponse) -> Self {
|
||||
Self {
|
||||
response: Mutex::new(response),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for SessionToolClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-test")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"subjektiv-session-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&*self.response.lock().unwrap()).unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AttributionClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
recorded: Mutex<HashSet<String>>,
|
||||
insertions: AtomicUsize,
|
||||
failures: AtomicUsize,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for AttributionClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-test")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"subjektiv-attribution-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
if self
|
||||
.failures
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
||||
remaining.checked_sub(1)
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 503,
|
||||
body: "temporarily unavailable".into(),
|
||||
});
|
||||
}
|
||||
let input: SubjektivRecordSessionRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
if self
|
||||
.recorded
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(input.session_id.clone())
|
||||
{
|
||||
self.insertions.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&SubjektivRecordSessionResponse {
|
||||
subject_id: "subject-1".into(),
|
||||
session_id: input.session_id,
|
||||
})
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn capture(has_committed_run: bool) -> CommittedSessionCaptureHandle {
|
||||
CommittedSessionCaptureHandle::new(move || {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: usize::from(has_committed_run) as u64,
|
||||
entry_count: usize::from(has_committed_run),
|
||||
has_committed_run,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: Vec::new(),
|
||||
usage_history: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn list_response() -> SubjektivSessionBackendResponse {
|
||||
SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
has_more: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn state(client: Arc<dyn WorkspaceClient>, has_committed_run: bool) -> SubjektivSessionState {
|
||||
SubjektivSessionState {
|
||||
client,
|
||||
capture: capture(has_committed_run),
|
||||
}
|
||||
}
|
||||
|
||||
fn feature(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionFeature {
|
||||
SubjektivSessionFeature {
|
||||
state: state(client, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_committed_context() -> RunCommittedContext {
|
||||
RunCommittedContext {
|
||||
invocation: HookInvocationContext {
|
||||
workspace_id: Some("workspace-test".into()),
|
||||
worker_id: "worker-1".into(),
|
||||
session_id: "session-1".into(),
|
||||
session_revision: 1,
|
||||
run_id: Some("run-1".into()),
|
||||
turn_index: Some(0),
|
||||
call_id: None,
|
||||
},
|
||||
exit: RunCommittedExit::Finished,
|
||||
committed_history: HookHistoryRange::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_request_context() -> PreRequestContext {
|
||||
PreRequestContext::new(
|
||||
PreRequestInfo {
|
||||
item_count: 0,
|
||||
estimated_tokens: None,
|
||||
turn_index: 0,
|
||||
tool_calls_this_turn: 0,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_and_definitions_register_exact_session_tools_and_typed_schemas() {
|
||||
let feature = feature(Arc::new(SessionToolClient::new(list_response())));
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| (tool.name.as_str(), tool.description.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(LIST_TOOL, LIST_DESCRIPTION),
|
||||
(SEARCH_TOOL, SEARCH_DESCRIPTION),
|
||||
(READ_TOOL, READ_DESCRIPTION),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.hooks
|
||||
.iter()
|
||||
.map(|hook| (hook.name.as_str(), hook.point.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(COMMIT_HOOK, FeatureHookPoint::RunCommitted),
|
||||
(RETRY_HOOK, FeatureHookPoint::PreLlmRequest),
|
||||
]
|
||||
);
|
||||
|
||||
for (name, description, operation, expected_schema) in [
|
||||
(
|
||||
LIST_TOOL,
|
||||
LIST_DESCRIPTION,
|
||||
SessionOperation::List,
|
||||
schema_for::<SubjektivSessionListRequest>(),
|
||||
),
|
||||
(
|
||||
SEARCH_TOOL,
|
||||
SEARCH_DESCRIPTION,
|
||||
SessionOperation::Search,
|
||||
schema_for::<SubjektivSessionSearchRequest>(),
|
||||
),
|
||||
(
|
||||
READ_TOOL,
|
||||
READ_DESCRIPTION,
|
||||
SessionOperation::Read,
|
||||
schema_for::<SubjektivSessionReadRequest>(),
|
||||
),
|
||||
] {
|
||||
let (meta, _) = tool_definition(name, description, feature.state.clone(), operation)();
|
||||
assert_eq!(meta.name, name);
|
||||
assert_eq!(meta.description, description);
|
||||
assert_eq!(meta.input_schema, expected_schema);
|
||||
let properties = meta.input_schema["properties"].as_object().unwrap();
|
||||
for forbidden in [
|
||||
"subject_id",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"archive_id",
|
||||
"archive_path",
|
||||
"path",
|
||||
] {
|
||||
assert!(!properties.contains_key(forbidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_subjektiv_feature_registers_nothing() {
|
||||
let config = manifest::ResolvedSubjektivFeatureConfig::default();
|
||||
let result = SubjektivSessionFeature::from_resolved_config(
|
||||
&config,
|
||||
capture(false),
|
||||
Arc::new(SessionToolClient::new(list_response())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_reject_model_supplied_host_scope_and_storage_paths() {
|
||||
let client = Arc::new(SessionToolClient::new(list_response()));
|
||||
for (operation, input) in [
|
||||
(
|
||||
SessionOperation::List,
|
||||
r#"{"subject_id":"subject-1","archive_path":"/tmp/archive"}"#,
|
||||
),
|
||||
(
|
||||
SessionOperation::Search,
|
||||
r#"{"query":"needle","runtime_id":"runtime-1","worker_id":"worker-1"}"#,
|
||||
),
|
||||
(
|
||||
SessionOperation::Read,
|
||||
r#"{"session_id":"session-1","segment_id":"segment-1","entry_ref":"E1","path":"session.jsonl"}"#,
|
||||
),
|
||||
] {
|
||||
let error = SubjektivSessionTool {
|
||||
state: state(client.clone(), true),
|
||||
operation,
|
||||
}
|
||||
.execute(input, ToolExecutionContext::new("call", "batch", 0))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_routes_one_host_bound_operation_to_session_history() {
|
||||
let client = Arc::new(SessionToolClient::new(list_response()));
|
||||
let tool = SubjektivSessionTool {
|
||||
state: state(client.clone(), true),
|
||||
operation: SessionOperation::List,
|
||||
};
|
||||
let output = tool
|
||||
.execute(
|
||||
r#"{"storage":"retained","limit":20}"#,
|
||||
ToolExecutionContext::new("call-1", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/api/w/workspace-test/subjektiv/session-history"
|
||||
);
|
||||
let body: SubjektivSessionBackendRequest =
|
||||
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
body.operation,
|
||||
SubjektivSessionBackendOperation::List(SubjektivSessionListRequest {
|
||||
session_id: None,
|
||||
storage: server_api::SubjektivSessionStorageFilter::Retained,
|
||||
limit: Some(20),
|
||||
cursor: None,
|
||||
})
|
||||
));
|
||||
let visible: SubjektivSessionBackendResponse =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
visible,
|
||||
SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::List(_)
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_errors_are_visible_and_oversized_json_becomes_bounded_resource_limit() {
|
||||
let typed_error = SubjektivSessionBackendResponse::Error {
|
||||
error: SubjektivSessionErrorResponse {
|
||||
code: SubjektivSessionDiagnosticCode::StaleCursor,
|
||||
message: "restart the search".into(),
|
||||
},
|
||||
};
|
||||
let error_tool = SubjektivSessionTool {
|
||||
state: state(Arc::new(SessionToolClient::new(typed_error.clone())), true),
|
||||
operation: SessionOperation::Search,
|
||||
};
|
||||
let output = error_tool
|
||||
.execute(
|
||||
r#"{"query":"needle"}"#,
|
||||
ToolExecutionContext::new("call-error", "batch", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let visible: SubjektivSessionBackendResponse =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
visible,
|
||||
SubjektivSessionBackendResponse::Error {
|
||||
error: SubjektivSessionErrorResponse {
|
||||
code: SubjektivSessionDiagnosticCode::StaleCursor,
|
||||
..
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
let oversized = SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse {
|
||||
items: vec![server_api::SubjektivSessionListItem {
|
||||
session_id: "session-1".into(),
|
||||
attributed_at: "2026-01-01T00:00:00Z".into(),
|
||||
storage: server_api::SubjektivSessionStorage::Retained,
|
||||
availability: server_api::SubjektivSessionAvailability::Unavailable,
|
||||
reason: Some("\\\u{0000}".repeat(SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES)),
|
||||
}],
|
||||
next_cursor: Some("cursor".into()),
|
||||
has_more: true,
|
||||
}),
|
||||
};
|
||||
let output = model_visible_response(oversized).unwrap();
|
||||
let content = output.content.unwrap();
|
||||
assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
|
||||
let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap();
|
||||
assert!(matches!(
|
||||
visible,
|
||||
SubjektivSessionBackendResponse::Error {
|
||||
error: SubjektivSessionErrorResponse {
|
||||
code: SubjektivSessionDiagnosticCode::ResourceLimit,
|
||||
..
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
let escaped_content = "\0".repeat(8 * 1024);
|
||||
let paged_read = SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::Read(server_api::SubjektivSessionReadResponse {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
entry_ref: "Eentry-1".into(),
|
||||
kind: server_api::SubjektivSessionEntryKind::User,
|
||||
origin: protocol::SessionEntryProvenance::HumanInput,
|
||||
lineage: server_api::SubjektivSessionLineage {
|
||||
kind: server_api::SubjektivSessionLineageKind::Root,
|
||||
parent_segment_id: None,
|
||||
at_turn_index: None,
|
||||
},
|
||||
mode: server_api::SubjektivSessionReadMode::Full,
|
||||
content: escaped_content.clone(),
|
||||
truncated: true,
|
||||
next_cursor: Some("next-page".into()),
|
||||
has_more: true,
|
||||
}),
|
||||
};
|
||||
let output = model_visible_response(paged_read).unwrap();
|
||||
let content = output.content.unwrap();
|
||||
assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
|
||||
let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap();
|
||||
assert!(matches!(
|
||||
visible,
|
||||
SubjektivSessionBackendResponse::Ok {
|
||||
result: SubjektivSessionBackendResult::Read(
|
||||
server_api::SubjektivSessionReadResponse {
|
||||
content,
|
||||
next_cursor: Some(cursor),
|
||||
has_more: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
} if content == escaped_content && cursor == "next-page"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_free_commit_records_session_and_pre_request_waits_for_committed_capture() {
|
||||
let client = Arc::new(AttributionClient::default());
|
||||
let commit_hook = RecordCommittedSessionHook {
|
||||
state: state(client.clone(), true),
|
||||
};
|
||||
commit_hook.call(&run_committed_context()).await.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].path, "/api/w/workspace-test/subjektiv/sessions");
|
||||
assert!(!requests[0].path.contains("staging"));
|
||||
drop(requests);
|
||||
|
||||
let before_commit = Arc::new(AttributionClient::default());
|
||||
let premature_commit_hook = RecordCommittedSessionHook {
|
||||
state: state(before_commit.clone(), false),
|
||||
};
|
||||
assert!(
|
||||
premature_commit_hook
|
||||
.call(&run_committed_context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let retry_hook = RetryCommittedSessionHook {
|
||||
state: state(before_commit.clone(), false),
|
||||
};
|
||||
assert_eq!(
|
||||
retry_hook.call(&pre_request_context()).await.unwrap(),
|
||||
HookPreRequestAction::Continue
|
||||
);
|
||||
assert!(before_commit.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_attribution_failure_is_a_hook_failure_then_retries_exactly() {
|
||||
let client = Arc::new(AttributionClient::default());
|
||||
client.failures.store(1, Ordering::SeqCst);
|
||||
let state = state(client.clone(), true);
|
||||
let commit_hook = RecordCommittedSessionHook {
|
||||
state: state.clone(),
|
||||
};
|
||||
let retry_hook = RetryCommittedSessionHook { state };
|
||||
|
||||
let error = commit_hook
|
||||
.call(&run_committed_context())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.category, HookErrorCategory::Dependency);
|
||||
assert_eq!(
|
||||
retry_hook.call(&pre_request_context()).await.unwrap(),
|
||||
HookPreRequestAction::Continue
|
||||
);
|
||||
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].body, requests[1].body);
|
||||
assert_eq!(client.insertions.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_commit_and_pre_request_use_exact_request_and_backend_dedupes() {
|
||||
let client = Arc::new(AttributionClient::default());
|
||||
let state = state(client.clone(), true);
|
||||
let commit_hook = RecordCommittedSessionHook {
|
||||
state: state.clone(),
|
||||
};
|
||||
let retry_hook = RetryCommittedSessionHook { state };
|
||||
|
||||
commit_hook.call(&run_committed_context()).await.unwrap();
|
||||
commit_hook.call(&run_committed_context()).await.unwrap();
|
||||
retry_hook.call(&pre_request_context()).await.unwrap();
|
||||
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.body == requests[0].body)
|
||||
);
|
||||
assert_eq!(client.recorded.lock().unwrap().len(), 1);
|
||||
assert_eq!(client.insertions.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture {
|
||||
/// Monotonic committed-log revision for the captured Segment.
|
||||
pub(crate) session_revision: u64,
|
||||
pub(crate) entry_count: usize,
|
||||
/// Whether the durable capture contains a committed run boundary. This is
|
||||
/// false for a newly materialized Session before its first Run commits.
|
||||
pub(crate) has_committed_run: bool,
|
||||
pub(crate) run_exit: CommittedRunExit,
|
||||
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||
pub(crate) usage_history: Vec<UsageRecord>,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! need durable domain audit must keep using their domain authority (for example Memory audit).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use agen::timeline::event::UsageEvent;
|
||||
use agen::{Engine, EngineError, llm_client::LlmClient};
|
||||
@@ -17,7 +17,9 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events};
|
||||
use crate::controller::{
|
||||
build_greeting, wire_event_bridges_on_engine, wire_workdir_command_events,
|
||||
};
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
||||
use crate::ipc::alerter::Alerter;
|
||||
@@ -409,6 +411,7 @@ enum InternalWorkerSessionCommand {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalWorkerSessionSnapshot {
|
||||
pub session: protocol::SessionSnapshot,
|
||||
pub greeting: Option<protocol::Greeting>,
|
||||
pub status: WorkerStatus,
|
||||
pub error: Option<String>,
|
||||
pub in_flight: InFlightSnapshot,
|
||||
@@ -428,6 +431,7 @@ pub(crate) struct InternalWorkerSessionHandle {
|
||||
visibility: InternalWorkerVisibility,
|
||||
last_error: Arc<Mutex<Option<String>>>,
|
||||
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
|
||||
greeting: Option<Arc<RwLock<protocol::Greeting>>>,
|
||||
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
||||
sink: SegmentLogSink,
|
||||
#[cfg(test)]
|
||||
@@ -481,6 +485,12 @@ impl InternalWorkerSessionHandle {
|
||||
};
|
||||
InternalWorkerSessionSnapshot {
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||
greeting: self.greeting.as_ref().map(|greeting| {
|
||||
greeting
|
||||
.read()
|
||||
.expect("internal Worker greeting lock poisoned")
|
||||
.clone()
|
||||
}),
|
||||
status: match self.status() {
|
||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
@@ -824,6 +834,23 @@ fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Se
|
||||
});
|
||||
}
|
||||
|
||||
fn publish_internal_context_usage(
|
||||
greeting: &RwLock<protocol::Greeting>,
|
||||
event_tx: &broadcast::Sender<Event>,
|
||||
tokens: Option<u64>,
|
||||
source: protocol::ContextTokenSource,
|
||||
) {
|
||||
let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
|
||||
{
|
||||
let mut greeting = greeting
|
||||
.write()
|
||||
.expect("internal Worker greeting lock poisoned");
|
||||
greeting.context_tokens = tokens.unwrap_or_default();
|
||||
greeting.context_usage = usage;
|
||||
}
|
||||
let _ = event_tx.send(Event::ContextUsage { usage });
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_internal_worker_session(
|
||||
mut worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
|
||||
store: EphemeralSessionStore,
|
||||
@@ -851,8 +878,20 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
worker.attach_alerter(alerter.clone());
|
||||
worker.attach_working_event_tx(event_tx.clone());
|
||||
worker.attach_in_flight_events(in_flight.clone());
|
||||
let greeting = Arc::new(RwLock::new(build_greeting(&worker)));
|
||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
||||
let usage_greeting = greeting.clone();
|
||||
let usage_events = event_tx.clone();
|
||||
worker.engine_mut().on_usage(move |event| {
|
||||
publish_internal_context_usage(
|
||||
&usage_greeting,
|
||||
&usage_events,
|
||||
event.input_tokens,
|
||||
protocol::ContextTokenSource::Measured,
|
||||
);
|
||||
});
|
||||
|
||||
let actor_greeting = greeting.clone();
|
||||
let session_id = worker.session_id();
|
||||
let segment_id = worker.segment_id();
|
||||
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8);
|
||||
@@ -874,6 +913,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
visibility,
|
||||
last_error: last_error.clone(),
|
||||
last_outcome: last_outcome.clone(),
|
||||
greeting: Some(greeting),
|
||||
child_registry,
|
||||
sink,
|
||||
#[cfg(test)]
|
||||
@@ -886,50 +926,56 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
match command {
|
||||
InternalWorkerSessionCommand::Run(input) => {
|
||||
actor_in_flight.clear();
|
||||
let cancel_sender = worker.engine_mut().cancel_sender();
|
||||
let mut run = std::pin::pin!(worker.run_text(&input));
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut run => {
|
||||
let (turn_status, error, outcome) =
|
||||
classify_internal_turn_result(result);
|
||||
actor_in_flight.clear();
|
||||
*last_outcome.lock().unwrap() = Some(outcome);
|
||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||
if let Some(message) = error {
|
||||
*last_error.lock().unwrap() = Some(message.clone());
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message,
|
||||
});
|
||||
let (turn_status, error, outcome) = {
|
||||
let cancel_sender = worker.engine_mut().cancel_sender();
|
||||
let mut run = std::pin::pin!(worker.run_text(&input));
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut run => {
|
||||
break classify_internal_turn_result(result);
|
||||
}
|
||||
send_internal_worker_state(&event_tx, turn_status);
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
state_changed.notify_waiters();
|
||||
break;
|
||||
}
|
||||
command = command_rx.recv() => {
|
||||
match command {
|
||||
Some(InternalWorkerSessionCommand::Stop(done)) => {
|
||||
let _ = cancel_sender.send(()).await;
|
||||
let _ = (&mut run).await;
|
||||
stop_done = Some(done);
|
||||
break 'actor;
|
||||
}
|
||||
Some(InternalWorkerSessionCommand::Run(_)) => {
|
||||
// `send` reserves Running atomically, so a second Run cannot be enqueued.
|
||||
}
|
||||
None => {
|
||||
let _ = cancel_sender.send(()).await;
|
||||
let _ = (&mut run).await;
|
||||
break 'actor;
|
||||
command = command_rx.recv() => {
|
||||
match command {
|
||||
Some(InternalWorkerSessionCommand::Stop(done)) => {
|
||||
let _ = cancel_sender.send(()).await;
|
||||
let _ = (&mut run).await;
|
||||
stop_done = Some(done);
|
||||
break 'actor;
|
||||
}
|
||||
Some(InternalWorkerSessionCommand::Run(_)) => {
|
||||
// `send` reserves Running atomically, so a second Run cannot be enqueued.
|
||||
}
|
||||
None => {
|
||||
let _ = cancel_sender.send(()).await;
|
||||
let _ = (&mut run).await;
|
||||
break 'actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
publish_internal_context_usage(
|
||||
&actor_greeting,
|
||||
&event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
actor_in_flight.clear();
|
||||
*last_outcome.lock().unwrap() = Some(outcome);
|
||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||
if let Some(message) = error {
|
||||
*last_error.lock().unwrap() = Some(message.clone());
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message,
|
||||
});
|
||||
}
|
||||
send_internal_worker_state(&event_tx, turn_status);
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
state_changed.notify_waiters();
|
||||
}
|
||||
InternalWorkerSessionCommand::Stop(done) => {
|
||||
stop_done = Some(done);
|
||||
@@ -1174,6 +1220,14 @@ impl session_store::WorkerMetadataStore for EphemeralSessionStore {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_internal_worker_session(
|
||||
visibility: InternalWorkerVisibility,
|
||||
) -> (InternalWorkerSessionHandle, broadcast::Sender<Event>) {
|
||||
test_internal_worker_session_with_greeting(visibility, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_internal_worker_session_with_greeting(
|
||||
visibility: InternalWorkerVisibility,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) -> (InternalWorkerSessionHandle, broadcast::Sender<Event>) {
|
||||
let store = EphemeralSessionStore::default();
|
||||
let session_id = session_store::new_session_id();
|
||||
@@ -1206,6 +1260,7 @@ pub(crate) fn test_internal_worker_session(
|
||||
visibility,
|
||||
last_error: Arc::new(Mutex::new(None)),
|
||||
last_outcome: Arc::new(Mutex::new(None)),
|
||||
greeting: greeting.map(|greeting| Arc::new(RwLock::new(greeting))),
|
||||
child_registry: None,
|
||||
sink,
|
||||
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -1485,11 +1540,37 @@ permission = "write"
|
||||
assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1);
|
||||
let entries_after_first = handle.entries().len();
|
||||
assert!(entries_after_first >= 4);
|
||||
assert!(matches!(
|
||||
handle
|
||||
.protocol_snapshot()
|
||||
.greeting
|
||||
.and_then(|greeting| greeting.context_usage),
|
||||
Some(protocol::ContextUsage {
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
..
|
||||
})
|
||||
));
|
||||
let mut events = handle.subscribe_events();
|
||||
handle.send("follow-up").await.expect("send follow-up turn");
|
||||
assert_eq!(
|
||||
handle.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Idle
|
||||
);
|
||||
let mut saw_estimated_context = false;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if matches!(
|
||||
event,
|
||||
Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
..
|
||||
})
|
||||
}
|
||||
) {
|
||||
saw_estimated_context = true;
|
||||
}
|
||||
}
|
||||
assert!(saw_estimated_context);
|
||||
assert!(handle.protocol_snapshot().in_flight.blocks.is_empty());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
assert!(handle.entries().len() > entries_after_first);
|
||||
|
||||
@@ -16,9 +16,23 @@ pub struct WorkerProtocolSessionStreams {
|
||||
pub events: broadcast::Receiver<Event>,
|
||||
}
|
||||
|
||||
fn subscribe_before_snapshot<Subscription, Snapshot>(
|
||||
subscribe: impl FnOnce() -> Subscription,
|
||||
snapshot: impl FnOnce() -> Snapshot,
|
||||
) -> (Snapshot, Subscription) {
|
||||
// Subscription must exist before any snapshot field is read. A concurrent
|
||||
// state update can then appear in both lanes, but it can never be absent
|
||||
// from both the snapshot and the queued live stream.
|
||||
let subscription = subscribe();
|
||||
let snapshot = snapshot();
|
||||
(snapshot, subscription)
|
||||
}
|
||||
|
||||
pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams {
|
||||
let (snapshot_event, log_entries) = handle.snapshot_event_with_entry_subscription();
|
||||
let (alert_snapshot, events) = handle.alerter.subscribe_with_snapshot();
|
||||
let ((snapshot_event, log_entries), (alert_snapshot, events)) = subscribe_before_snapshot(
|
||||
|| handle.alerter.subscribe_with_snapshot(),
|
||||
|| handle.snapshot_event_with_entry_subscription(),
|
||||
);
|
||||
WorkerProtocolSessionStreams {
|
||||
snapshot_event,
|
||||
alert_snapshot,
|
||||
@@ -117,11 +131,158 @@ pub async fn dispatch_worker_protocol_method(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use session_store::Store;
|
||||
|
||||
use crate::segment_log_sink::SegmentLogSink;
|
||||
use crate::worker::{LogWriterHandle, SegmentState};
|
||||
|
||||
fn committed_entry(event: Event) -> protocol::SessionSnapshotEntry {
|
||||
match event {
|
||||
Event::SessionEntryCommitted { entry } => entry,
|
||||
other => panic!("expected SessionEntryCommitted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_publication_between_subscription_and_snapshot_cannot_be_missed() {
|
||||
let (events, _) = broadcast::channel(4);
|
||||
let context_tokens = std::cell::Cell::new(20_u64);
|
||||
|
||||
let (snapshot_tokens, mut receiver) = subscribe_before_snapshot(
|
||||
|| events.subscribe(),
|
||||
|| {
|
||||
context_tokens.set(25);
|
||||
events
|
||||
.send(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 25,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
context_tokens.get()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(snapshot_tokens, 25);
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage { tokens: 25, .. })
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_run_transitions_keep_identity_across_live_and_snapshot_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = session_store::FsStore::new(temp.path()).unwrap();
|
||||
let session_id = session_store::new_session_id();
|
||||
let segment_id = session_store::new_segment_id();
|
||||
let start = LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: agen::llm_client::RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
store
|
||||
.create_segment(session_id, segment_id, std::slice::from_ref(&start))
|
||||
.unwrap();
|
||||
let sink = SegmentLogSink::with_initial(vec![start]);
|
||||
let writer = LogWriterHandle {
|
||||
store: store.clone(),
|
||||
state: SegmentState::new(session_id, segment_id, 1),
|
||||
sink: sink.clone(),
|
||||
in_flight: None,
|
||||
};
|
||||
let (_, mut receiver) = sink.subscribe_with_snapshot();
|
||||
let transitions = [
|
||||
LogEntry::RunYielded {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
reason: protocol::RunYieldReason::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
LogEntry::RunResumed {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
source: protocol::RunResumeSource::Compaction,
|
||||
active_run_turn_count: 2,
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "same compaction failure".into(),
|
||||
failure: Some(protocol::RunFailureKind::Compaction),
|
||||
},
|
||||
LogEntry::RunErrored {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: "same compaction failure".into(),
|
||||
failure: Some(protocol::RunFailureKind::Compaction),
|
||||
},
|
||||
LogEntry::RunCancelled {
|
||||
ts: 10,
|
||||
entry_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
let mut live_ids = Vec::new();
|
||||
for transition in transitions {
|
||||
writer.append_entry(transition).unwrap();
|
||||
let committed = receiver.try_recv().expect("committed transition broadcast");
|
||||
live_ids.push(
|
||||
committed_entry(live_log_entry_event(committed).expect("live projection")).entry_id,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
live_ids
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len(),
|
||||
5
|
||||
);
|
||||
|
||||
let (mirror, _) = sink.subscribe_with_snapshot();
|
||||
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&mirror);
|
||||
let snapshot_ids: Vec<_> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.data,
|
||||
protocol::SessionSnapshotEntryData::RunYielded { .. }
|
||||
| protocol::SessionSnapshotEntryData::RunResumed { .. }
|
||||
| protocol::SessionSnapshotEntryData::RunError { .. }
|
||||
| protocol::SessionSnapshotEntryData::RunCancelled
|
||||
)
|
||||
})
|
||||
.map(|entry| entry.entry_id.clone())
|
||||
.collect();
|
||||
assert_eq!(snapshot_ids, live_ids);
|
||||
|
||||
let persisted = store.read_all(session_id, segment_id).unwrap();
|
||||
let persisted_snapshot =
|
||||
session_store::public_snapshot::project_current_session_snapshot(&persisted);
|
||||
let persisted_ids: Vec<_> = persisted_snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| snapshot_ids.contains(&entry.entry_id))
|
||||
.map(|entry| entry.entry_id.clone())
|
||||
.collect();
|
||||
assert_eq!(persisted_ids, live_ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_run_transition_maps_to_session_entry_committed() {
|
||||
let event = live_log_entry_event(LogEntry::RunYielded {
|
||||
ts: 42,
|
||||
entry_id: Some(session_store::LoggedSessionHistoryEntryId::new()),
|
||||
reason: protocol::RunYieldReason::Compaction,
|
||||
active_run_turn_count: 3,
|
||||
})
|
||||
|
||||
@@ -94,6 +94,8 @@ pub enum WorkerPrompt {
|
||||
CompactSystem,
|
||||
MemoryExtractSystem,
|
||||
MemoryConsolidationSystem,
|
||||
SubjektivMemoryConsolidationSystem,
|
||||
SubjektivMemorySurfaceSystem,
|
||||
FlowVerifierSystem,
|
||||
BackendJobSystem,
|
||||
NotifyWrapper,
|
||||
@@ -102,6 +104,7 @@ pub enum WorkerPrompt {
|
||||
WorkingBoundariesSection,
|
||||
AgentsMdSection,
|
||||
ResidentMemorySummarySection,
|
||||
ResidentMemoryRestoreSection,
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
@@ -112,6 +115,10 @@ impl WorkerPrompt {
|
||||
Self::CompactSystem => "internal.compact_system",
|
||||
Self::MemoryExtractSystem => "internal.memory_extract_system",
|
||||
Self::MemoryConsolidationSystem => "internal.memory_consolidation_system",
|
||||
Self::SubjektivMemoryConsolidationSystem => {
|
||||
"internal.subjektiv_memory_consolidation_system"
|
||||
}
|
||||
Self::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system",
|
||||
Self::FlowVerifierSystem => "internal.flow_verifier_system",
|
||||
Self::BackendJobSystem => "internal.backend_job_system",
|
||||
Self::NotifyWrapper => "internal.notify_wrapper",
|
||||
@@ -120,6 +127,7 @@ impl WorkerPrompt {
|
||||
Self::WorkingBoundariesSection => "internal.working_boundaries_section",
|
||||
Self::AgentsMdSection => "internal.agents_md_section",
|
||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||
Self::WorkerOrchestrationGuidanceSection => {
|
||||
"internal.worker_orchestration_guidance_section"
|
||||
}
|
||||
@@ -131,6 +139,8 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::CompactSystem,
|
||||
WorkerPrompt::MemoryExtractSystem,
|
||||
WorkerPrompt::MemoryConsolidationSystem,
|
||||
WorkerPrompt::SubjektivMemoryConsolidationSystem,
|
||||
WorkerPrompt::SubjektivMemorySurfaceSystem,
|
||||
WorkerPrompt::FlowVerifierSystem,
|
||||
WorkerPrompt::BackendJobSystem,
|
||||
WorkerPrompt::NotifyWrapper,
|
||||
@@ -139,6 +149,7 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::WorkingBoundariesSection,
|
||||
WorkerPrompt::AgentsMdSection,
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
@@ -449,6 +460,21 @@ impl PromptCatalog {
|
||||
single("language", language),
|
||||
)
|
||||
}
|
||||
pub fn subjektiv_memory_consolidation_system(
|
||||
&self,
|
||||
language: &str,
|
||||
) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::SubjektivMemoryConsolidationSystem,
|
||||
single("language", language),
|
||||
)
|
||||
}
|
||||
pub fn subjektiv_memory_surface_system(&self, language: &str) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::SubjektivMemorySurfaceSystem,
|
||||
single("language", language),
|
||||
)
|
||||
}
|
||||
pub fn flow_verifier_system(&self) -> Result<String, CatalogError> {
|
||||
self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED)
|
||||
}
|
||||
@@ -479,6 +505,19 @@ impl PromptCatalog {
|
||||
single("summary", summary),
|
||||
)
|
||||
}
|
||||
pub fn resident_memory_restore_section(
|
||||
&self,
|
||||
surface_ready: bool,
|
||||
summary: Option<&str>,
|
||||
) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||
minijinja::context! {
|
||||
surface_ready => surface_ready,
|
||||
summary => summary.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
@@ -913,6 +952,23 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("LANGUAGE_MARKER")
|
||||
);
|
||||
let surface = catalog
|
||||
.subjektiv_memory_surface_system("SURFACE_LANGUAGE")
|
||||
.unwrap();
|
||||
assert!(surface.contains("SURFACE_LANGUAGE"));
|
||||
for required in [
|
||||
"conditions",
|
||||
"negation",
|
||||
"contradictions",
|
||||
"staleness",
|
||||
"SubmitMemorySurface",
|
||||
"not semantic verification",
|
||||
] {
|
||||
assert!(
|
||||
surface.contains(required),
|
||||
"missing surface rule {required}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
catalog
|
||||
.notify_wrapper("NOTIFICATION_MARKER")
|
||||
|
||||
@@ -212,6 +212,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ mod tests {
|
||||
// Durable logical-Run transitions share the committed-entry lane.
|
||||
sink.publish(LogEntry::RunResumed {
|
||||
ts: now_millis(),
|
||||
entry_id: None,
|
||||
source: protocol::RunResumeSource::Compaction,
|
||||
active_run_turn_count: 1,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
|
||||
//! range/search, read, and generic evidence projections without granting mutation authority.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||
@@ -207,6 +208,14 @@ pub(crate) struct ReadResult {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CommittedToolCall {
|
||||
pub call_id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
pub evidence: SessionEntryEvidence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionEntryEvidence {
|
||||
pub segment_id: String,
|
||||
@@ -600,6 +609,76 @@ impl SessionCapture {
|
||||
ReadResult { entries, truncated }
|
||||
}
|
||||
|
||||
/// Resolves the committed tool-call input for a provider call id. This is
|
||||
/// used by explicit-memory receipts only after the containing run is durable,
|
||||
/// so a model-authored claim can cite its own committed tool invocation
|
||||
/// without inventing a SessionEntryRef.
|
||||
pub(crate) fn evidence_for_tool_call(&self, call_id: &str) -> Option<SessionEntryEvidence> {
|
||||
let entry_index = self.entries.iter().position(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolCall {
|
||||
call_id: stored_call_id,
|
||||
..
|
||||
} if stored_call_id == call_id
|
||||
)
|
||||
})?;
|
||||
let reference = self.index.iter().find(|entry| {
|
||||
entry.entry_range[0] == entry_index as u64
|
||||
&& entry.kind == ReferenceKind::Tool
|
||||
&& entry.tool_part == Some(ToolPart::Input)
|
||||
})?;
|
||||
self.evidence_for(reference.id.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn committed_pending_tool_calls(&self, names: &[&str]) -> Vec<CommittedToolCall> {
|
||||
let pending_call_ids = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let Item::ToolResult {
|
||||
call_id,
|
||||
content: Some(content),
|
||||
is_error: false,
|
||||
..
|
||||
} = &entry.item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
(serde_json::from_str::<serde_json::Value>(content)
|
||||
.ok()
|
||||
.and_then(|value| value.get("status").cloned())
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("pending_commit"))
|
||||
.then_some(call_id.as_str())
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} = &entry.item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !names.contains(&name.as_str()) || !pending_call_ids.contains(call_id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some(CommittedToolCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: arguments.clone(),
|
||||
evidence: self.evidence_for_tool_call(call_id)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
|
||||
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
|
||||
let excerpt = self
|
||||
@@ -1028,4 +1107,20 @@ mod tests {
|
||||
assert_eq!(source.entry_range, [0, 0]);
|
||||
assert_eq!(source.entry_ref.as_str(), "E00000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_tool_call_can_be_resolved_by_provider_call_id() {
|
||||
let view = SessionCapture::new(
|
||||
"segment-1",
|
||||
vec![Item::tool_call(
|
||||
"call-remember",
|
||||
"SubjektivMemoryRemember",
|
||||
r#"{"claim":"remember this"}"#,
|
||||
)],
|
||||
);
|
||||
let evidence = view.evidence_for_tool_call("call-remember").unwrap();
|
||||
assert_eq!(evidence.entry_ref.as_str(), "E00000000");
|
||||
assert_eq!(evidence.tool_part, Some(ToolPart::Input));
|
||||
assert!(view.evidence_for_tool_call("missing").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct WorkerSharedState {
|
||||
pub worker_name: String,
|
||||
pub segment_id: SegmentId,
|
||||
pub manifest_toml: String,
|
||||
pub greeting: protocol::Greeting,
|
||||
greeting: RwLock<protocol::Greeting>,
|
||||
state: RwLock<WorkerStateSnapshot>,
|
||||
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
|
||||
/// Worker-from-the-inside view of the filesystem. Set once in
|
||||
@@ -62,7 +62,7 @@ impl WorkerSharedState {
|
||||
worker_name,
|
||||
segment_id,
|
||||
manifest_toml,
|
||||
greeting,
|
||||
greeting: RwLock::new(greeting),
|
||||
state: RwLock::new(WorkerStateSnapshot::initial()),
|
||||
accepted_commands: RwLock::new(VecDeque::new()),
|
||||
fs_view: OnceLock::new(),
|
||||
@@ -90,6 +90,22 @@ impl WorkerSharedState {
|
||||
self.flow_transition_enabled.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn greeting(&self) -> protocol::Greeting {
|
||||
self.greeting
|
||||
.read()
|
||||
.expect("worker greeting lock poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn update_context_usage(&self, tokens: Option<u64>, source: protocol::ContextTokenSource) {
|
||||
let mut greeting = self
|
||||
.greeting
|
||||
.write()
|
||||
.expect("worker greeting lock poisoned");
|
||||
greeting.context_tokens = tokens.unwrap_or_default();
|
||||
greeting.context_usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
|
||||
}
|
||||
|
||||
pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot {
|
||||
let mut snapshot = self
|
||||
.state
|
||||
@@ -245,9 +261,32 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_context_usage_is_reflected_in_reconnect_greeting() {
|
||||
let state = test_state();
|
||||
state.update_context_usage(Some(42_000), protocol::ContextTokenSource::Measured);
|
||||
|
||||
let greeting = state.greeting();
|
||||
assert_eq!(greeting.context_tokens, 42_000);
|
||||
assert_eq!(
|
||||
greeting.context_usage,
|
||||
Some(protocol::ContextUsage {
|
||||
tokens: 42_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
})
|
||||
);
|
||||
|
||||
state.update_context_usage(None, protocol::ContextTokenSource::Measured);
|
||||
let greeting = state.greeting();
|
||||
assert_eq!(greeting.context_tokens, 0);
|
||||
assert_eq!(greeting.context_usage, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_snapshot_is_idle() {
|
||||
let state = test_state();
|
||||
|
||||
@@ -97,6 +97,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
|
||||
@@ -29,7 +29,9 @@ use tokio::sync::{Notify, broadcast};
|
||||
use tracing::warn;
|
||||
use workdir::WorkdirScopeLeaseSet;
|
||||
|
||||
use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility};
|
||||
use crate::internal_worker::{
|
||||
InternalWorkerSessionHandle, InternalWorkerSessionSnapshot, InternalWorkerVisibility,
|
||||
};
|
||||
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
|
||||
use crate::runtime::worker_allocation;
|
||||
|
||||
@@ -648,6 +650,14 @@ impl SpawnedWorkerRegistry {
|
||||
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||
let protocol_terminal = record.protocol_terminal.clone();
|
||||
let mut child_rx = record.session.subscribe_events();
|
||||
publish_initial_internal_snapshot(
|
||||
&parent_tx,
|
||||
&worker,
|
||||
&protocol_revision,
|
||||
&protocol_emit_lock,
|
||||
&protocol_terminal,
|
||||
&record.session,
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match child_rx.recv().await {
|
||||
@@ -710,6 +720,14 @@ impl SpawnedWorkerRegistry {
|
||||
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||
let protocol_terminal = record.protocol_terminal.clone();
|
||||
let mut child_rx = record.session.subscribe_events();
|
||||
publish_initial_internal_snapshot(
|
||||
&parent_tx,
|
||||
&worker,
|
||||
&protocol_revision,
|
||||
&protocol_emit_lock,
|
||||
&protocol_terminal,
|
||||
&record.session,
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match child_rx.recv().await {
|
||||
@@ -985,6 +1003,41 @@ impl SpawnedWorkerRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_initial_internal_snapshot(
|
||||
parent_tx: &broadcast::Sender<Event>,
|
||||
worker: &InternalWorkerRef,
|
||||
protocol_revision: &AtomicU64,
|
||||
protocol_emit_lock: &Mutex<()>,
|
||||
protocol_terminal: &AtomicBool,
|
||||
session: &InternalWorkerSessionHandle,
|
||||
) {
|
||||
let Some(event) = internal_worker_live_snapshot(session.protocol_snapshot()) else {
|
||||
return;
|
||||
};
|
||||
let _emit_guard = protocol_emit_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if protocol_terminal.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
let _ = parent_tx.send(Event::InternalWorker {
|
||||
worker: worker.clone(),
|
||||
revision,
|
||||
event: Box::new(event),
|
||||
});
|
||||
}
|
||||
|
||||
fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Option<Event> {
|
||||
Some(Event::Snapshot {
|
||||
session: snapshot.session,
|
||||
greeting: snapshot.greeting?,
|
||||
state: snapshot.status.into(),
|
||||
in_flight: snapshot.in_flight,
|
||||
internal_workers: snapshot.internal_workers,
|
||||
})
|
||||
}
|
||||
|
||||
fn internal_worker_snapshot(
|
||||
worker: InternalWorkerRef,
|
||||
revision: u64,
|
||||
@@ -995,6 +1048,7 @@ fn internal_worker_snapshot(
|
||||
worker,
|
||||
revision,
|
||||
session: snapshot.session,
|
||||
greeting: snapshot.greeting,
|
||||
status: snapshot.status,
|
||||
error: snapshot.error,
|
||||
in_flight: snapshot.in_flight,
|
||||
@@ -1121,7 +1175,10 @@ mod tests {
|
||||
use session_store::LogEntry;
|
||||
|
||||
use super::*;
|
||||
use crate::internal_worker::{InternalWorkerSessionStatus, test_internal_worker_session};
|
||||
use crate::internal_worker::{
|
||||
InternalWorkerSessionStatus, test_internal_worker_session,
|
||||
test_internal_worker_session_with_greeting,
|
||||
};
|
||||
|
||||
fn registry() -> Arc<SpawnedWorkerRegistry> {
|
||||
let scope = Scope::from_config(&ScopeConfig {
|
||||
@@ -1141,7 +1198,15 @@ mod tests {
|
||||
name: &str,
|
||||
visibility: InternalWorkerVisibility,
|
||||
) -> (InternalSpawnedWorkerRecord, broadcast::Sender<Event>) {
|
||||
let (session, sender) = test_internal_worker_session(visibility);
|
||||
record_with_greeting(name, visibility, None).await
|
||||
}
|
||||
|
||||
async fn record_with_greeting(
|
||||
name: &str,
|
||||
visibility: InternalWorkerVisibility,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) -> (InternalSpawnedWorkerRecord, broadcast::Sender<Event>) {
|
||||
let (session, sender) = test_internal_worker_session_with_greeting(visibility, greeting);
|
||||
let root = std::path::PathBuf::from("/tmp");
|
||||
let scope = Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
@@ -1193,6 +1258,95 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_internal_worker_starts_with_own_live_snapshot_metadata() {
|
||||
let registry = registry();
|
||||
let (parent_tx, mut parent_rx) = broadcast::channel(16);
|
||||
registry.attach_parent_protocol(parent_tx, "parent-session".into());
|
||||
let greeting = protocol::Greeting {
|
||||
worker_name: "research".into(),
|
||||
cwd: "/child".into(),
|
||||
provider: "provider".into(),
|
||||
model: "child-model".into(),
|
||||
reasoning: Some(protocol::ReasoningConfig::Effort {
|
||||
effort: "high".into(),
|
||||
}),
|
||||
scope_summary: "child scope".into(),
|
||||
tools: Vec::new(),
|
||||
context_window: 64_000,
|
||||
context_tokens: 12_000,
|
||||
context_usage: Some(protocol::ContextUsage {
|
||||
tokens: 12_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
}),
|
||||
};
|
||||
let (record, child_tx) = record_with_greeting(
|
||||
"research",
|
||||
InternalWorkerVisibility::ParentClient,
|
||||
Some(greeting),
|
||||
)
|
||||
.await;
|
||||
registry
|
||||
.internal_records
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(record.clone());
|
||||
|
||||
registry.start_protocol_forwarding(record);
|
||||
|
||||
let initial = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
initial,
|
||||
Event::InternalWorker {
|
||||
revision: 1,
|
||||
event,
|
||||
..
|
||||
} if matches!(
|
||||
*event,
|
||||
Event::Snapshot {
|
||||
greeting: protocol::Greeting {
|
||||
ref model,
|
||||
context_window: 64_000,
|
||||
..
|
||||
},
|
||||
..
|
||||
} if model == "child-model"
|
||||
)
|
||||
));
|
||||
|
||||
child_tx
|
||||
.send(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 14_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let update = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
update,
|
||||
Event::InternalWorker {
|
||||
revision: 2,
|
||||
event,
|
||||
..
|
||||
} if matches!(
|
||||
*event,
|
||||
Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 14_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
})
|
||||
}
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_internal_output_is_wrapped_after_registry_insertion() {
|
||||
let registry = registry();
|
||||
|
||||
@@ -925,25 +925,30 @@ fn bind_child_memory_settings(
|
||||
parent: &manifest::WorkerManifest,
|
||||
child: &mut manifest::WorkerManifest,
|
||||
) -> Result<(), String> {
|
||||
if !child.feature.memory.profile.enabled {
|
||||
return child
|
||||
if child.feature.memory.profile.enabled {
|
||||
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
||||
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
||||
.to_string()
|
||||
})?;
|
||||
child
|
||||
.feature
|
||||
.memory
|
||||
.validate_execution()
|
||||
.map_err(str::to_string);
|
||||
.bind_workspace_settings(workspace_settings)
|
||||
.map_err(str::to_string)?;
|
||||
}
|
||||
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
||||
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
||||
.to_string()
|
||||
})?;
|
||||
child
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(workspace_settings)
|
||||
.validate_execution()
|
||||
.map_err(str::to_string)?;
|
||||
|
||||
// An Internal SubWorker has no Backend keyed-singleton lease of its own.
|
||||
// Preserve reusable subjektiv profile policy but never copy the parent's
|
||||
// trusted activation snapshot into the child.
|
||||
child.feature.subjektiv.workspace_settings = None;
|
||||
child
|
||||
.feature
|
||||
.memory
|
||||
.subjektiv
|
||||
.validate_execution()
|
||||
.map_err(str::to_string)
|
||||
}
|
||||
@@ -1292,7 +1297,7 @@ enabled = false
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
||||
async fn builtin_reviewer_spawn_from_subject_parent_is_inert_and_notifies_parent() {
|
||||
let runtime = TempDir::new().unwrap();
|
||||
let workspace_root = runtime.path().join("project");
|
||||
let bash_output_dir = runtime.path().join("bash-output");
|
||||
@@ -1310,6 +1315,17 @@ enabled = false
|
||||
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
manifest.feature.subjektiv.profile.enabled = true;
|
||||
manifest.feature.subjektiv.profile.extraction.enabled = true;
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
|
||||
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
||||
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
|
||||
@@ -1359,7 +1375,7 @@ enabled = false
|
||||
}));
|
||||
let input = serde_json::json!({
|
||||
"name": "reviewer-child",
|
||||
"profile": "project:reviewer",
|
||||
"profile": "builtin:reviewer",
|
||||
"instruction": "role.reviewer",
|
||||
"task": "review immutable commit",
|
||||
"scope": [{
|
||||
@@ -1409,6 +1425,14 @@ enabled = false
|
||||
record.installed_tools
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
record
|
||||
.installed_tools
|
||||
.iter()
|
||||
.all(|name| !name.starts_with("Subjektiv")),
|
||||
"Reviewer child must not install independently scoped subjektiv tools: {:?}",
|
||||
record.installed_tools
|
||||
);
|
||||
assert!(
|
||||
!record
|
||||
.installed_tools
|
||||
@@ -1881,14 +1905,22 @@ enabled = false
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut parent = parent_manifest(temp.path(), None);
|
||||
parent.feature.memory.profile.enabled = true;
|
||||
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 4,
|
||||
language: "日本語".to_string(),
|
||||
};
|
||||
parent
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 4,
|
||||
language: "日本語".to_string(),
|
||||
})
|
||||
.bind_workspace_settings(settings.clone())
|
||||
.unwrap();
|
||||
parent.feature.subjektiv.profile.enabled = true;
|
||||
parent.feature.subjektiv.profile.extraction.enabled = true;
|
||||
parent
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(settings)
|
||||
.unwrap();
|
||||
let mut child = parent.clone();
|
||||
child.feature.memory.workspace_settings = None;
|
||||
@@ -1898,6 +1930,15 @@ enabled = false
|
||||
child.feature.memory.workspace_settings(),
|
||||
parent.feature.memory.workspace_settings()
|
||||
);
|
||||
assert!(child.feature.subjektiv.profile.enabled);
|
||||
assert!(!child.feature.subjektiv.execution_enabled());
|
||||
|
||||
let mut ordinary_parent = parent.clone();
|
||||
ordinary_parent.feature.subjektiv.workspace_settings = None;
|
||||
let mut ordinary_child = child.clone();
|
||||
ordinary_child.feature.memory.workspace_settings = None;
|
||||
bind_child_memory_settings(&ordinary_parent, &mut ordinary_child).unwrap();
|
||||
assert!(!ordinary_child.feature.subjektiv.execution_enabled());
|
||||
|
||||
child.feature.memory.profile.enabled = false;
|
||||
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
|
||||
|
||||
+321
-10
@@ -411,7 +411,9 @@ use crate::ipc::alerter::Alerter;
|
||||
use crate::ipc::interceptor::WorkerInterceptor;
|
||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||
use crate::prompt::agents_md::read_agents_md;
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection};
|
||||
use crate::prompt::catalog::{
|
||||
CatalogError, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
|
||||
};
|
||||
use crate::prompt::source::PromptCatalogSource;
|
||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::runtime::dir;
|
||||
@@ -624,6 +626,10 @@ pub enum WorkspaceServerOperation {
|
||||
},
|
||||
WorkerObservationSessions,
|
||||
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
|
||||
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
|
||||
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
|
||||
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
|
||||
SubjektivSession(server_api::SubjektivSessionBackendRequest),
|
||||
}
|
||||
|
||||
fn workspace_server_json_request<T: serde::Serialize>(
|
||||
@@ -702,6 +708,18 @@ fn workspace_server_operation_request(
|
||||
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
||||
workspace_server_json_request(format!("{base}/worker-observation/session"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/staging"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivRecordSession(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivMemory(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivSession(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,7 +1432,8 @@ where
|
||||
/// Append `entry` to the log: disk write → counter bump → in-memory
|
||||
/// mirror push → broadcast. The Store owns physical write ordering and
|
||||
/// partial-write recovery; publication happens only after it returns Ok.
|
||||
pub fn append_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
pub fn append_entry(&self, mut entry: LogEntry) -> Result<(), StoreError> {
|
||||
entry.ensure_session_entry_id();
|
||||
let _append_guard = self
|
||||
.state
|
||||
.append_lock
|
||||
@@ -2322,9 +2341,15 @@ impl WorkerSession {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum SystemPromptContribution {
|
||||
Ready(String),
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
||||
async fn load(&self) -> Option<String>;
|
||||
async fn load(&self) -> SystemPromptContribution;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -2488,6 +2513,8 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
inject_resident_summary: bool,
|
||||
/// Deferred resident prompt source installed by an enabled Feature.
|
||||
feature_resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
/// One-shot append-only resident refresh required at a restore boundary.
|
||||
restore_resident_summary_refresh_pending: bool,
|
||||
/// Complete system prompt replacement installed by an enabled Feature.
|
||||
feature_system_prompt_override: Option<String>,
|
||||
/// Typed user submissions in submit order. K-th entry corresponds to
|
||||
@@ -2741,6 +2768,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
prompts,
|
||||
inject_resident_summary: true,
|
||||
feature_resident_summary_source: None,
|
||||
restore_resident_summary_refresh_pending: false,
|
||||
feature_system_prompt_override: None,
|
||||
user_segments: Vec::new(),
|
||||
sink: SegmentLogSink::new(),
|
||||
@@ -3043,7 +3071,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// Append `entry` to the session log AND publish it through the
|
||||
/// broadcast sink. The Store is the commit boundary: a failed write is
|
||||
/// never counted or published.
|
||||
pub(crate) fn commit_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
pub(crate) fn commit_entry(&self, mut entry: LogEntry) -> Result<(), StoreError> {
|
||||
entry.ensure_session_entry_id();
|
||||
let loc = self.segment_state.location();
|
||||
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
||||
self.segment_state.increment_entries();
|
||||
@@ -3463,6 +3492,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
let entries = store
|
||||
.read_all(location.session_id, location.segment_id)
|
||||
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
|
||||
let has_committed_run = entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. }
|
||||
)
|
||||
});
|
||||
let run_exit = entries
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -3489,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
segment_id: location.segment_id.to_string(),
|
||||
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
|
||||
entry_count: entries.len(),
|
||||
has_committed_run,
|
||||
run_exit,
|
||||
history,
|
||||
usage_history: restored.usage_history,
|
||||
@@ -3807,6 +3843,66 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_restore_resident_summary_refreshed(&mut self) -> Result<(), WorkerError>
|
||||
where
|
||||
St: Clone + 'static,
|
||||
{
|
||||
if !self.restore_resident_summary_refresh_pending {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(source) = self.feature_resident_summary_source.clone() else {
|
||||
self.restore_resident_summary_refresh_pending = false;
|
||||
return Ok(());
|
||||
};
|
||||
let contribution = if self.inject_resident_summary {
|
||||
source.load().await
|
||||
} else {
|
||||
SystemPromptContribution::Unavailable
|
||||
};
|
||||
let (surface_ready, summary) = match &contribution {
|
||||
SystemPromptContribution::Ready(summary) => (true, Some(summary.as_str())),
|
||||
SystemPromptContribution::Unavailable => (false, None),
|
||||
};
|
||||
let prompt_catalog = self.prompts.load_full();
|
||||
let body = prompt_catalog.resident_memory_restore_section(surface_ready, summary)?;
|
||||
let prompt_provenance =
|
||||
Some(self.prompt_render_provenance(WorkerPrompt::ResidentMemoryRestoreSection.key()));
|
||||
let item = SystemItem::ResidentSummaryRefresh {
|
||||
body: body.clone(),
|
||||
prompt_provenance,
|
||||
};
|
||||
let metadata = new_history_metadata(
|
||||
WorkerHistoryProvenance::BackendInstruction { operation_id: None },
|
||||
None,
|
||||
);
|
||||
self.commit_entry(LogEntry::AnnotatedSystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
entry: session_store::LoggedSystemHistoryEntry {
|
||||
item,
|
||||
metadata: metadata.clone(),
|
||||
},
|
||||
extensions: Vec::new(),
|
||||
})?;
|
||||
let history_entry = HistoryEntry::new(Item::system_message(body), metadata);
|
||||
let mut annotate = history_annotator(
|
||||
self.log_writer_handle(),
|
||||
vec![history_entry.clone()],
|
||||
self.pending_committed_history.clone(),
|
||||
);
|
||||
let (engine, session) = (
|
||||
self.engine.as_mut().expect("worker present"),
|
||||
&mut self.session,
|
||||
);
|
||||
engine.append_history_with(
|
||||
session.history_mut(),
|
||||
std::iter::once(history_entry.item),
|
||||
&mut annotate,
|
||||
)?;
|
||||
session.note_mutation();
|
||||
self.restore_resident_summary_refresh_pending = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render the manifest-supplied instruction template exactly once,
|
||||
/// just before the first LLM turn, append the fixed trailing
|
||||
/// section (scope summary + optional AGENTS.md), and hand the
|
||||
@@ -3814,9 +3910,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// Subsequent invocations are no-ops: the template field is
|
||||
/// consumed with `Option::take()`, so the materialised value
|
||||
/// persists across all later turns and compaction.
|
||||
async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> {
|
||||
async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError>
|
||||
where
|
||||
St: Clone + 'static,
|
||||
{
|
||||
let Some(template) = self.system_prompt_template.take() else {
|
||||
return Ok(());
|
||||
return self.ensure_restore_resident_summary_refreshed().await;
|
||||
};
|
||||
if let Some(rendered) = self.feature_system_prompt_override.take() {
|
||||
self.engine
|
||||
@@ -3849,7 +3948,14 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
}
|
||||
let resident_summary = if self.inject_resident_summary {
|
||||
match &self.feature_resident_summary_source {
|
||||
Some(source) => source.load().await,
|
||||
Some(source) => match source.load().await {
|
||||
SystemPromptContribution::Ready(summary) if !summary.is_empty() => {
|
||||
Some(summary)
|
||||
}
|
||||
SystemPromptContribution::Ready(_) | SystemPromptContribution::Unavailable => {
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
@@ -3905,6 +4011,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
.is_some_and(|state| state.pre_run_eligible(self.total_tokens().tokens))
|
||||
}
|
||||
|
||||
pub(crate) fn needs_initial_session_head_materialization(&self) -> bool {
|
||||
self.system_prompt_template.is_some()
|
||||
}
|
||||
|
||||
/// Materialize the initial durable session head without running the model.
|
||||
///
|
||||
/// Runtime-managed Workers call this before creation is accepted so even a
|
||||
@@ -4695,6 +4805,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
.expect("pause/yield resume must retain the logical-run turn budget");
|
||||
self.commit_entry(LogEntry::RunResumed {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
source,
|
||||
active_run_turn_count,
|
||||
})?;
|
||||
@@ -5012,6 +5123,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
// marked as already delivered to clients through the session-log sink.
|
||||
self.commit_entry(LogEntry::RunErrored {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message,
|
||||
failure: Some(RunFailureKind::Compaction),
|
||||
@@ -5423,6 +5535,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
{
|
||||
self.commit_entry(LogEntry::RunErrored {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
interrupted: false,
|
||||
message: message.to_string(),
|
||||
failure: Some(RunFailureKind::Compaction),
|
||||
@@ -5446,6 +5559,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
EngineRunExit::Yielded => {
|
||||
self.commit_entry(LogEntry::RunYielded {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
reason: RunYieldReason::Compaction,
|
||||
active_run_turn_count: active_run_turn_count
|
||||
.expect("yielded logical run must retain its turn budget"),
|
||||
@@ -5462,11 +5576,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => {
|
||||
self.commit_entry(LogEntry::RunCancelled {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
})?;
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => {
|
||||
self.commit_entry(LogEntry::RunErrored {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
interrupted,
|
||||
message: run_interruption_reason_message(reason),
|
||||
failure: Some(RunFailureKind::Engine),
|
||||
@@ -6537,6 +6653,7 @@ where
|
||||
prompts: common.prompts,
|
||||
inject_resident_summary: true,
|
||||
feature_resident_summary_source: None,
|
||||
restore_resident_summary_refresh_pending: false,
|
||||
feature_system_prompt_override: None,
|
||||
user_segments: Vec::new(),
|
||||
sink: SegmentLogSink::new(),
|
||||
@@ -6626,6 +6743,7 @@ where
|
||||
prompts: common.prompts,
|
||||
inject_resident_summary: true,
|
||||
feature_resident_summary_source: None,
|
||||
restore_resident_summary_refresh_pending: false,
|
||||
feature_system_prompt_override: None,
|
||||
user_segments: Vec::new(),
|
||||
sink: SegmentLogSink::new(),
|
||||
@@ -6750,6 +6868,7 @@ where
|
||||
prompts: common.prompts,
|
||||
inject_resident_summary: true,
|
||||
feature_resident_summary_source: None,
|
||||
restore_resident_summary_refresh_pending: false,
|
||||
feature_system_prompt_override: None,
|
||||
user_segments: Vec::new(),
|
||||
sink: SegmentLogSink::new(),
|
||||
@@ -7131,6 +7250,7 @@ where
|
||||
prompts: common.prompts,
|
||||
inject_resident_summary: true,
|
||||
feature_resident_summary_source: None,
|
||||
restore_resident_summary_refresh_pending: true,
|
||||
feature_system_prompt_override: None,
|
||||
user_segments: state.user_segments,
|
||||
// Seed the mirror with the entries we just replayed so a
|
||||
@@ -8690,13 +8810,13 @@ mod build_summary_prompt_tests {
|
||||
use super::*;
|
||||
|
||||
struct TestSystemPromptContributionSource {
|
||||
value: Option<String>,
|
||||
value: SystemPromptContribution,
|
||||
load_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SystemPromptContributionSource for TestSystemPromptContributionSource {
|
||||
async fn load(&self) -> Option<String> {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
self.load_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.value.clone()
|
||||
}
|
||||
@@ -8708,7 +8828,10 @@ mod build_summary_prompt_tests {
|
||||
let load_count = Arc::new(AtomicUsize::new(0));
|
||||
(
|
||||
Arc::new(TestSystemPromptContributionSource {
|
||||
value,
|
||||
value: value.map_or(
|
||||
SystemPromptContribution::Unavailable,
|
||||
SystemPromptContribution::Ready,
|
||||
),
|
||||
load_count: Arc::clone(&load_count),
|
||||
}),
|
||||
load_count,
|
||||
@@ -11063,6 +11186,191 @@ mod build_summary_prompt_tests {
|
||||
assert!(worker.history().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_worker_appends_latest_resident_surface_without_rewriting_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cwd = dir.path().join("workspace");
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
let runtime = dir.path().join("runtime");
|
||||
std::fs::create_dir_all(&runtime).unwrap();
|
||||
let _runtime =
|
||||
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(&runtime);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::FsStore::new(dir.path().join("sessions")).unwrap(),
|
||||
session_store::FsWorkerStore::new(dir.path().join("workers")).unwrap(),
|
||||
);
|
||||
let manifest = WorkerManifest::from_toml(&format!(
|
||||
r#"
|
||||
[worker]
|
||||
name = "restore-resident-{}"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
instruction = "default"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{}"
|
||||
permission = "write"
|
||||
"#,
|
||||
uuid::Uuid::now_v7(),
|
||||
cwd.display()
|
||||
))
|
||||
.unwrap();
|
||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||
let scope = Scope::writable(&cwd).unwrap();
|
||||
let mut original = Worker::new(
|
||||
manifest.clone(),
|
||||
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
|
||||
store.clone(),
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
authority.clone(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
original.set_system_prompt_template(
|
||||
SystemPromptTemplate::parse(
|
||||
"default",
|
||||
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let (old_source, _) =
|
||||
test_system_prompt_contribution_source(Some("old subject surface".into()));
|
||||
original.install_system_prompt_contribution(Some(old_source), None);
|
||||
original.materialize_durable_session_head().await.unwrap();
|
||||
let session_id = original.session_id();
|
||||
let segment_id = original.segment_id();
|
||||
assert!(
|
||||
original
|
||||
.engine()
|
||||
.get_system_prompt()
|
||||
.unwrap()
|
||||
.contains("old subject surface")
|
||||
);
|
||||
drop(original);
|
||||
|
||||
let mut restored =
|
||||
Worker::<Box<dyn LlmClient>, _>::restore_from_manifest_with_context_and_model_client(
|
||||
session_id,
|
||||
segment_id,
|
||||
manifest.clone(),
|
||||
store.clone(),
|
||||
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
authority.clone(),
|
||||
Some(Box::new(NoopClient)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let history_before = restored.history().to_vec();
|
||||
let (current_source, current_loads) =
|
||||
test_system_prompt_contribution_source(Some("latest subject surface".into()));
|
||||
restored.install_system_prompt_contribution(Some(current_source), None);
|
||||
restored.materialize_durable_session_head().await.unwrap();
|
||||
|
||||
assert_eq!(current_loads.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(&restored.history()[..history_before.len()], history_before);
|
||||
let refresh = restored
|
||||
.history()
|
||||
.last()
|
||||
.unwrap()
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(refresh.contains("latest subject surface"));
|
||||
assert!(refresh.contains("supersedes every resident memory summary"));
|
||||
assert!(!refresh.contains("old subject surface"));
|
||||
let refreshed_len = restored.history().len();
|
||||
restored.materialize_durable_session_head().await.unwrap();
|
||||
assert_eq!(restored.history().len(), refreshed_len);
|
||||
drop(restored);
|
||||
|
||||
let mut empty_restore =
|
||||
Worker::<Box<dyn LlmClient>, _>::restore_from_manifest_with_context_and_model_client(
|
||||
session_id,
|
||||
segment_id,
|
||||
manifest.clone(),
|
||||
store.clone(),
|
||||
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
authority.clone(),
|
||||
Some(Box::new(NoopClient)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (empty_source, empty_loads) =
|
||||
test_system_prompt_contribution_source(Some(String::new()));
|
||||
empty_restore.install_system_prompt_contribution(Some(empty_source), None);
|
||||
empty_restore
|
||||
.materialize_durable_session_head()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(empty_loads.load(Ordering::SeqCst), 1);
|
||||
let empty_boundary = empty_restore
|
||||
.history()
|
||||
.last()
|
||||
.unwrap()
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(
|
||||
empty_boundary.contains("current ready resident memory surface is intentionally empty")
|
||||
);
|
||||
assert!(!empty_boundary.contains("No current ready resident memory surface"));
|
||||
drop(empty_restore);
|
||||
|
||||
let mut stale_restore =
|
||||
Worker::<Box<dyn LlmClient>, _>::restore_from_manifest_with_context_and_model_client(
|
||||
session_id,
|
||||
segment_id,
|
||||
manifest,
|
||||
store.clone(),
|
||||
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
authority,
|
||||
Some(Box::new(NoopClient)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stale_history_before = stale_restore.history().to_vec();
|
||||
let (stale_source, stale_loads) = test_system_prompt_contribution_source(None);
|
||||
stale_restore.install_system_prompt_contribution(Some(stale_source), None);
|
||||
stale_restore
|
||||
.materialize_durable_session_head()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stale_loads.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
&stale_restore.history()[..stale_history_before.len()],
|
||||
stale_history_before
|
||||
);
|
||||
let tombstone = stale_restore
|
||||
.history()
|
||||
.last()
|
||||
.unwrap()
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(tombstone.contains("No current ready resident memory surface"));
|
||||
assert!(!tombstone.contains("latest subject surface"));
|
||||
let entries = store.read_all(session_id, segment_id).unwrap();
|
||||
assert!(matches!(
|
||||
entries.last(),
|
||||
Some(LogEntry::AnnotatedSystemItem {
|
||||
entry: session_store::LoggedSystemHistoryEntry {
|
||||
item: SystemItem::ResidentSummaryRefresh { .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_consolidation_prompt_uses_bound_workspace_language() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -11430,6 +11738,7 @@ mod build_summary_prompt_tests {
|
||||
worker
|
||||
.commit_entry(LogEntry::RunErrored {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
interrupted: true,
|
||||
message: "provider failed".to_string(),
|
||||
failure: Some(RunFailureKind::Engine),
|
||||
@@ -11460,6 +11769,7 @@ mod build_summary_prompt_tests {
|
||||
worker
|
||||
.commit_entry(LogEntry::RunCancelled {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -11470,6 +11780,7 @@ mod build_summary_prompt_tests {
|
||||
worker
|
||||
.commit_entry(LogEntry::RunResumed {
|
||||
ts: segment_log::now_millis(),
|
||||
entry_id: None,
|
||||
source: RunResumeSource::Compaction,
|
||||
active_run_turn_count: 1,
|
||||
})
|
||||
|
||||
@@ -1037,25 +1037,15 @@ permission = "write"
|
||||
#[tokio::test]
|
||||
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut resolved = ProfileResolver::new()
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(workspace.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
if resolved.manifest.feature.memory.enabled() {
|
||||
resolved
|
||||
.manifest
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert!(resolved.manifest.feature.subjektiv.enabled());
|
||||
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
|
||||
let workspace_context =
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
||||
let client = MockClient::new(simple_text_events());
|
||||
@@ -1081,6 +1071,47 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||
|
||||
assert!(installed.iter().any(|name| name == "WorkerRemove"));
|
||||
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
|
||||
assert!(
|
||||
installed.iter().all(|name| !name.starts_with("Subjektiv")),
|
||||
"ordinary Orchestrator must not install subject-scoped tools: {installed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_coder_commits_without_unattached_subjektiv_lifecycle() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(workspace.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||
ProfileResolveOptions::with_worker_name("coder-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(resolved.manifest.feature.subjektiv.enabled());
|
||||
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
|
||||
client,
|
||||
resolved.manifest,
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)),
|
||||
)
|
||||
.await;
|
||||
let handle = spawn_controller(worker).await;
|
||||
|
||||
handle
|
||||
.send(Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"Hello",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
let installed = request_tool_names(&wait_for_captured_request(&client_for_assert).await);
|
||||
assert!(
|
||||
installed.iter().all(|name| !name.starts_with("Subjektiv")),
|
||||
"ordinary Coder must not install subject-scoped tools: {installed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -51,6 +51,7 @@ webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
agen.workspace = true
|
||||
session-store.workspace = true
|
||||
tempfile.workspace = true
|
||||
tools.workspace = true
|
||||
yoi = { path = "../yoi" }
|
||||
|
||||
@@ -820,11 +820,9 @@ impl SqliteWorkspaceAuthority {
|
||||
.map(|worker| (assignment, worker))
|
||||
})
|
||||
.map(|(assignment, worker)| {
|
||||
let worker_resource_key = self.store.resource_key(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&worker.worker_id,
|
||||
)?;
|
||||
let worker_resource_key = self
|
||||
.store
|
||||
.worker_resource_key(&self.workspace_id, &worker)?;
|
||||
Ok::<_, Error>(TicketAssignmentSummary {
|
||||
assignment_id: assignment.assignment_id.clone(),
|
||||
runtime_id: worker.runtime_id,
|
||||
|
||||
@@ -534,6 +534,9 @@ pub struct WorkerSpawnRequest {
|
||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
/// Backend-attested subjektiv singleton attachment. Browser/model input cannot set it.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_subjektiv_attached: bool,
|
||||
/// Backend-owned feature enablement; client input cannot set it.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_worker_observation_enabled: bool,
|
||||
@@ -1217,6 +1220,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
fn session_public_search(
|
||||
&self,
|
||||
_request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "Session public search is not supported by this Runtime".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn session_public_read(
|
||||
&self,
|
||||
_request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "Session public read is not supported by this Runtime".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn observation_source(
|
||||
&self,
|
||||
_worker_id: &str,
|
||||
@@ -2073,6 +2096,36 @@ impl RuntimeRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn session_public_search(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
self.runtime(runtime_id)?
|
||||
.session_public_search(request)
|
||||
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "session_public_search_failed".to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn session_public_read(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
self.runtime(runtime_id)?
|
||||
.session_public_read(request)
|
||||
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "session_public_read_failed".to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn observation_source(
|
||||
&self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -2964,6 +3017,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn session_public_search(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||
if request.workspace_id != self.workspace_id {
|
||||
return Err(
|
||||
"Session public search Workspace does not match embedded Runtime".to_string(),
|
||||
);
|
||||
}
|
||||
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
|
||||
self.runtime
|
||||
.session_public_search_scoped(&scope, &request)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn session_public_read(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||
if request.workspace_id != self.workspace_id {
|
||||
return Err(
|
||||
"Session public read Workspace does not match embedded Runtime".to_string(),
|
||||
);
|
||||
}
|
||||
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
|
||||
self.runtime
|
||||
.session_public_read_scoped(&scope, &request)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn observation_source(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
@@ -3669,6 +3752,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
||||
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
|
||||
return "workers:delete";
|
||||
}
|
||||
if path.starts_with("/v1/session-public/") && method == "POST" {
|
||||
return "workers:read";
|
||||
}
|
||||
if path.starts_with("/v1/workers/") && method == "GET" {
|
||||
return "workers:read";
|
||||
}
|
||||
@@ -5007,6 +5093,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
.map_err(|diagnostic| diagnostic.message)
|
||||
}
|
||||
|
||||
fn session_public_search(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||
self.run_runtime_api(
|
||||
self.request_timeout,
|
||||
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||
move |client| async move { client.session_public_search(request).await },
|
||||
)
|
||||
.map_err(|diagnostic| diagnostic.message)
|
||||
}
|
||||
|
||||
fn session_public_read(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||
self.run_runtime_api(
|
||||
self.request_timeout,
|
||||
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||
move |client| async move { client.session_public_read(request).await },
|
||||
)
|
||||
.map_err(|diagnostic| diagnostic.message)
|
||||
}
|
||||
|
||||
fn observation_source(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
@@ -5246,6 +5356,7 @@ fn runtime_create_worker_request(
|
||||
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
||||
workspace_api: Some(workspace_api),
|
||||
memory_settings: request.resolved_memory_settings.clone(),
|
||||
subjektiv_attached: request.resolved_subjektiv_attached,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5370,6 +5481,7 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
|
||||
|
||||
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||
pub(crate) const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
||||
pub(crate) const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:";
|
||||
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
|
||||
pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-orchestrator";
|
||||
|
||||
@@ -5377,7 +5489,7 @@ pub(crate) fn is_reserved_internal_worker_singleton_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
||||
)
|
||||
) || key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX)
|
||||
}
|
||||
|
||||
struct WorkerDisplayMetadata {
|
||||
@@ -7030,6 +7142,7 @@ mod tests {
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7318,6 +7431,7 @@ mod tests {
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -7420,6 +7534,7 @@ mod tests {
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -7460,6 +7575,7 @@ mod tests {
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -753,11 +753,11 @@ CREATE TABLE "worker_control_grants" (
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, grant_id),
|
||||
UNIQUE (workspace_id, controller_worker_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, controller_worker_id)
|
||||
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, subject_worker_id)
|
||||
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE
|
||||
UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id),
|
||||
FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id)
|
||||
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id)
|
||||
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE worker_create_reservations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
@@ -832,7 +832,7 @@ CREATE TABLE "worker_registry" (
|
||||
diagnostics_ref TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, worker_id),
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE backend_jobs (
|
||||
@@ -914,31 +914,15 @@ CREATE TABLE worker_registry_observations (
|
||||
worker_id TEXT NOT NULL,
|
||||
availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')),
|
||||
worker_json TEXT,
|
||||
connection_generation INTEGER NOT NULL,
|
||||
subject_revision INTEGER NOT NULL,
|
||||
snapshot_revision INTEGER NOT NULL,
|
||||
projection_revision INTEGER NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id, worker_id)
|
||||
REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_cursors (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
connection_generation INTEGER NOT NULL,
|
||||
snapshot_revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id)
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_revisions (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL
|
||||
FOREIGN KEY (workspace_id, runtime_id, worker_id)
|
||||
REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_removals (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
projection_revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id)
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_diagnostics (
|
||||
@@ -973,6 +957,17 @@ CREATE TABLE worker_session_archives (
|
||||
committed_at TEXT NOT NULL, expires_at TEXT,
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
|
||||
CREATE TABLE worker_session_archive_observe_grants (
|
||||
workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL,
|
||||
controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL,
|
||||
subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL,
|
||||
source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, revoked_at TEXT,
|
||||
PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE);
|
||||
CREATE INDEX worker_session_archive_observe_grants_controller
|
||||
ON worker_session_archive_observe_grants(
|
||||
workspace_id,controller_runtime_id,controller_worker_id,archive_id);
|
||||
CREATE TABLE worker_tombstones (
|
||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
|
||||
@@ -998,9 +993,9 @@ CREATE TABLE "worker_workdir_links" (
|
||||
alias TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
unlinked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, worker_id, workdir_id, alias),
|
||||
FOREIGN KEY (workspace_id, worker_id)
|
||||
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias),
|
||||
FOREIGN KEY (workspace_id, runtime_id, worker_id)
|
||||
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, workdir_id)
|
||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1217,11 +1212,11 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||
ON worker_workdir_attachment_reservations(workspace_id, reservation_id);
|
||||
CREATE INDEX worker_control_grants_controller
|
||||
ON worker_control_grants(
|
||||
workspace_id, controller_worker_id, revoked_at
|
||||
workspace_id, controller_runtime_id, controller_worker_id, revoked_at
|
||||
);
|
||||
CREATE INDEX worker_control_grants_subject
|
||||
ON worker_control_grants(
|
||||
workspace_id, subject_worker_id, revoked_at
|
||||
workspace_id, subject_runtime_id, subject_worker_id, revoked_at
|
||||
);
|
||||
CREATE INDEX worker_create_reservations_worker
|
||||
ON worker_create_reservations(workspace_id, worker_id);
|
||||
@@ -1232,7 +1227,7 @@ CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique
|
||||
ON worker_workdir_links(workspace_id, workdir_id)
|
||||
WHERE unlinked_at IS NULL;
|
||||
CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique
|
||||
ON worker_workdir_links(workspace_id, worker_id, alias)
|
||||
ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias)
|
||||
WHERE unlinked_at IS NULL;
|
||||
CREATE INDEX worker_workdir_links_workdir
|
||||
ON worker_workdir_links(workspace_id, workdir_id);
|
||||
|
||||
@@ -112,6 +112,8 @@ pub enum Error {
|
||||
},
|
||||
#[error("unknown local repository `{0}`")]
|
||||
UnknownRepository(String),
|
||||
#[error("subjektiv subject `{0}` was not found in this Workspace")]
|
||||
SubjektivSubjectNotFound(String),
|
||||
#[error(
|
||||
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
|
||||
)]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use memory::backend::{
|
||||
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
|
||||
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
|
||||
MemoryStagingCloseAction, MemoryStagingCloseOperation, MemoryStagingListOperation,
|
||||
MemoryStagingReadOperation, MemoryStagingWriteOutput, MemoryToolOutput,
|
||||
MemoryResidentSummaryAvailability, MemoryResidentSummaryOutput, MemoryStagingCloseAction,
|
||||
MemoryStagingCloseOperation, MemoryStagingListOperation, MemoryStagingReadOperation,
|
||||
MemoryStagingWriteOutput, MemoryToolOutput,
|
||||
};
|
||||
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
|
||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||
@@ -32,7 +33,7 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
|
||||
.map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
||||
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
|
||||
MemoryBackendOperationResult::ResidentSummary(execute_resident_summary(authority)?),
|
||||
),
|
||||
MemoryBackendOperation::AppendAudit(_operation) => Ok(
|
||||
MemoryBackendOperationResult::Acknowledged(MemoryBackendAckOutput {
|
||||
@@ -88,16 +89,18 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_resident_summary<A: MemoryAuthority>(authority: &A) -> Result<MemoryToolOutput> {
|
||||
fn execute_resident_summary<A: MemoryAuthority>(
|
||||
authority: &A,
|
||||
) -> Result<MemoryResidentSummaryOutput> {
|
||||
let document = authority.ensure_memory_document()?;
|
||||
if document.body_md.trim().is_empty() {
|
||||
Ok(MemoryToolOutput {
|
||||
summary: "resident memory summary unavailable".to_string(),
|
||||
Ok(MemoryResidentSummaryOutput {
|
||||
availability: MemoryResidentSummaryAvailability::Unavailable,
|
||||
content: None,
|
||||
})
|
||||
} else {
|
||||
Ok(MemoryToolOutput {
|
||||
summary: "resident memory document collected".to_string(),
|
||||
Ok(MemoryResidentSummaryOutput {
|
||||
availability: MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some(document.body_md),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerSessionArchiveRecord {
|
||||
pub archive_id: String,
|
||||
pub workspace_id: String,
|
||||
pub worker: RuntimeWorkerRef,
|
||||
pub session_id: String,
|
||||
pub checksum_sha256: String,
|
||||
pub content_bytes: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub operation_id: String,
|
||||
pub committed_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerOrphanDiagnostic {
|
||||
pub diagnostic_id: String,
|
||||
@@ -454,6 +469,51 @@ impl SqliteWorkspaceStore {
|
||||
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
||||
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
|
||||
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
|
||||
let observe_grants = {
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT grant_id, controller_runtime_id, controller_worker_id,
|
||||
permissions_json, created_at
|
||||
FROM worker_control_grants
|
||||
WHERE workspace_id = ?1 AND subject_runtime_id = ?2
|
||||
AND subject_worker_id = ?3 AND revoked_at IS NULL",
|
||||
)?;
|
||||
statement
|
||||
.query_map(
|
||||
params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
},
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants {
|
||||
let permissions: Vec<String> = serde_json::from_str(&permissions)
|
||||
.map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?;
|
||||
if permissions.iter().any(|permission| permission == "observe") {
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO worker_session_archive_observe_grants
|
||||
(workspace_id,archive_id,controller_runtime_id,controller_worker_id,
|
||||
subject_runtime_id,subject_worker_id,source_grant_id,granted_at)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace_id,
|
||||
a.archive_id,
|
||||
controller_runtime_id,
|
||||
controller_worker_id,
|
||||
plan.worker.runtime_id,
|
||||
plan.worker.worker_id,
|
||||
grant_id,
|
||||
created_at
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
|
||||
match plan.diagnostics_disposition {
|
||||
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
|
||||
@@ -592,6 +652,78 @@ impl SqliteWorkspaceStore {
|
||||
) -> crate::Result<Option<WorkerTombstone>> {
|
||||
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
|
||||
}
|
||||
|
||||
pub fn worker_session_archive(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
session_id: &str,
|
||||
) -> crate::Result<Option<WorkerSessionArchiveRecord>> {
|
||||
self.with_conn(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
|
||||
policy_revision, operation_id, committed_at, expires_at
|
||||
FROM worker_session_archives
|
||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||
AND session_id = ?4",
|
||||
params![
|
||||
workspace_id,
|
||||
worker.runtime_id,
|
||||
worker.worker_id,
|
||||
session_id
|
||||
],
|
||||
|row| {
|
||||
Ok(WorkerSessionArchiveRecord {
|
||||
archive_id: row.get(0)?,
|
||||
workspace_id: workspace_id.to_string(),
|
||||
worker: worker.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
checksum_sha256: row.get(1)?,
|
||||
content_bytes: row.get::<_, i64>(2)? as u64,
|
||||
policy_id: row.get(3)?,
|
||||
policy_revision: row.get::<_, i64>(4)? as u64,
|
||||
operation_id: row.get(5)?,
|
||||
committed_at: row.get(6)?,
|
||||
expires_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(StoreError::from)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn worker_session_archive_observable_by(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
archive_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
subject: &RuntimeWorkerRef,
|
||||
) -> crate::Result<bool> {
|
||||
self.with_conn(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM worker_session_archive_observe_grants
|
||||
WHERE workspace_id = ?1 AND archive_id = ?2
|
||||
AND controller_runtime_id = ?3 AND controller_worker_id = ?4
|
||||
AND subject_runtime_id = ?5 AND subject_worker_id = ?6
|
||||
AND revoked_at IS NULL
|
||||
)",
|
||||
params![
|
||||
workspace_id,
|
||||
archive_id,
|
||||
controller.runtime_id,
|
||||
controller.worker_id,
|
||||
subject.runtime_id,
|
||||
subject.worker_id
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn orphan_diagnostic(
|
||||
@@ -1170,6 +1302,32 @@ mod tests {
|
||||
#[test]
|
||||
fn purge_tombstone_commit_is_idempotent() {
|
||||
let s = setup();
|
||||
let archived_grant_id = "archive-observe-grant";
|
||||
let archived_controller =
|
||||
RuntimeWorkerRef::new("r", WorkerId::from_legacy_u64(2).to_string());
|
||||
s.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO worker_registry(\
|
||||
workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\
|
||||
) VALUES('w',?1,'r','controller','builtin:companion','normal','created','controller-rev1')",
|
||||
[&archived_controller.worker_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.create_worker_control_grant(&crate::store::WorkerControlGrantRecord {
|
||||
workspace_id: "w".into(),
|
||||
grant_id: archived_grant_id.into(),
|
||||
controller: archived_controller.clone(),
|
||||
subject: req().worker,
|
||||
relation: "subject_session".into(),
|
||||
origin: "test".into(),
|
||||
permissions: vec!["observe".into()],
|
||||
operation_id: archived_grant_id.into(),
|
||||
created_at: "2026-10-02T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
|
||||
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
|
||||
@@ -1220,6 +1378,31 @@ mod tests {
|
||||
.state,
|
||||
WorkerRemovalPlanState::Succeeded
|
||||
);
|
||||
assert!(
|
||||
s.worker_session_archive_observable_by(
|
||||
"w",
|
||||
p.archive_id.as_deref().unwrap(),
|
||||
&archived_controller,
|
||||
&p.worker,
|
||||
)
|
||||
.unwrap(),
|
||||
"the explicit observe grant survives Worker removal for the committed archive"
|
||||
);
|
||||
assert!(
|
||||
s.revoke_worker_control_grant("w", archived_grant_id, "2026-10-02T00:01:00Z")
|
||||
.unwrap(),
|
||||
"the existing grant revocation authority must also revoke its archive projection"
|
||||
);
|
||||
assert!(
|
||||
!s.worker_session_archive_observable_by(
|
||||
"w",
|
||||
p.archive_id.as_deref().unwrap(),
|
||||
&archived_controller,
|
||||
&p.worker,
|
||||
)
|
||||
.unwrap(),
|
||||
"every later cursor page and direct Read must observe archive grant revocation"
|
||||
);
|
||||
assert!(s.worker_tombstone("w", &p.worker).unwrap().is_some());
|
||||
let reservation_state: String = s.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
|
||||
@@ -33,26 +33,19 @@ pub enum RuntimeSubscriptionBrokerError {
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BrokerSubscriptionEvent {
|
||||
Snapshot {
|
||||
connection_generation: u64,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
},
|
||||
Event {
|
||||
connection_generation: u64,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
Disconnected {
|
||||
connection_generation: u64,
|
||||
message: String,
|
||||
},
|
||||
Rejected {
|
||||
connection_generation: u64,
|
||||
code: SubscriptionRejectionCode,
|
||||
message: String,
|
||||
},
|
||||
Closed {
|
||||
connection_generation: u64,
|
||||
code: SubscriptionTerminationCode,
|
||||
message: String,
|
||||
},
|
||||
@@ -60,7 +53,6 @@ pub enum BrokerSubscriptionEvent {
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RuntimeSubscriptionBrokerStatus {
|
||||
pub connection_generation: u64,
|
||||
pub connected: bool,
|
||||
pub desired_selectors: usize,
|
||||
pub upstream_subscriptions: usize,
|
||||
@@ -82,7 +74,30 @@ impl BrokerSubscription {
|
||||
&self.selector
|
||||
}
|
||||
pub async fn recv(&mut self) -> Option<BrokerSubscriptionEvent> {
|
||||
self.receiver.recv().await
|
||||
loop {
|
||||
if let Some(event) = self.receiver.recv().await {
|
||||
return Some(event);
|
||||
}
|
||||
if self.restart().is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restart(&mut self) -> Result<(), RuntimeSubscriptionBrokerError> {
|
||||
self.commands
|
||||
.send(Command::Unsubscribe(self.downstream_id))
|
||||
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
|
||||
let (events, receiver) = mpsc::channel(DOWNSTREAM_QUEUE_CAPACITY);
|
||||
self.commands
|
||||
.send(Command::Subscribe {
|
||||
downstream_id: self.downstream_id,
|
||||
selector: self.selector.clone(),
|
||||
events,
|
||||
})
|
||||
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
|
||||
self.receiver = receiver;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +135,7 @@ impl RuntimeSubscriptionBroker {
|
||||
pub fn register_remote_runtime(&self, config: RemoteRuntimeConfig) -> u64 {
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (commands, receiver) = mpsc::unbounded_channel();
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
..Default::default()
|
||||
}));
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus::default()));
|
||||
let registration = Registration {
|
||||
generation,
|
||||
commands: commands.clone(),
|
||||
@@ -156,7 +168,6 @@ impl RuntimeSubscriptionBroker {
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (commands, receiver) = mpsc::unbounded_channel();
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
connected: true,
|
||||
..Default::default()
|
||||
}));
|
||||
@@ -257,7 +268,6 @@ impl RuntimeSubscriptionBroker {
|
||||
.clone();
|
||||
if !initial_status.connected {
|
||||
let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation: initial_status.connection_generation,
|
||||
message: "Runtime subscription connection is not currently available".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -286,8 +296,7 @@ struct SelectorState {
|
||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
upstream_id: Option<SubscriptionId>,
|
||||
pending: bool,
|
||||
snapshot: Option<(u64, SubscriptionSnapshot)>,
|
||||
revisions: HashMap<String, u64>,
|
||||
snapshot: Option<SubscriptionSnapshot>,
|
||||
}
|
||||
impl SelectorState {
|
||||
fn new() -> Self {
|
||||
@@ -296,7 +305,6 @@ impl SelectorState {
|
||||
upstream_id: None,
|
||||
pending: false,
|
||||
snapshot: None,
|
||||
revisions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,11 +342,9 @@ impl State {
|
||||
selector.upstream_id = None;
|
||||
selector.pending = false;
|
||||
selector.snapshot = None;
|
||||
selector.revisions.clear();
|
||||
broadcast(
|
||||
&mut selector.downstreams,
|
||||
BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation: self.generation,
|
||||
message: message.clone(),
|
||||
},
|
||||
);
|
||||
@@ -347,12 +353,77 @@ impl State {
|
||||
}
|
||||
|
||||
struct EmbeddedEntry {
|
||||
lifetime: u64,
|
||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
enum EmbeddedUpdate {
|
||||
Event {
|
||||
selector: EventSubscriptionSelector,
|
||||
lifetime: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
Closed {
|
||||
selector: EventSubscriptionSelector,
|
||||
lifetime: u64,
|
||||
code: SubscriptionTerminationCode,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn start_embedded_subscription(
|
||||
runtime: &worker_runtime::Runtime,
|
||||
runtime_id: &str,
|
||||
selector: EventSubscriptionSelector,
|
||||
lifetime: u64,
|
||||
updates: mpsc::UnboundedSender<EmbeddedUpdate>,
|
||||
) -> Result<(SubscriptionSnapshot, tokio::task::JoinHandle<()>), worker_runtime::error::RuntimeError>
|
||||
{
|
||||
let mut subscription = runtime.subscribe_event_selector(selector.clone())?;
|
||||
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), runtime_id);
|
||||
let task_runtime_id = runtime_id.to_string();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
match subscription.recv().await {
|
||||
Ok(update) => {
|
||||
let payload = project_payload_runtime(update.payload, &task_runtime_id);
|
||||
if updates
|
||||
.send(EmbeddedUpdate::Event {
|
||||
selector: selector.clone(),
|
||||
lifetime,
|
||||
payload,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(worker_runtime::RuntimeSubscriptionRecvError::Lagged) => {
|
||||
let _ = updates.send(EmbeddedUpdate::Closed {
|
||||
selector,
|
||||
lifetime,
|
||||
code: SubscriptionTerminationCode::Lagged,
|
||||
message: "embedded Runtime subscription lagged; resubscribing from a fresh snapshot".to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(worker_runtime::RuntimeSubscriptionRecvError::Closed) => {
|
||||
let _ = updates.send(EmbeddedUpdate::Closed {
|
||||
selector,
|
||||
lifetime,
|
||||
code: SubscriptionTerminationCode::ServerShutdown,
|
||||
message: "embedded Runtime subscription closed".to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((snapshot, task))
|
||||
}
|
||||
|
||||
async fn run_embedded_connection(
|
||||
runtime_id: String,
|
||||
runtime: worker_runtime::Runtime,
|
||||
@@ -364,6 +435,7 @@ async fn run_embedded_connection(
|
||||
let (updates, mut update_receiver) = mpsc::unbounded_channel();
|
||||
let mut entries = HashMap::<EventSubscriptionSelector, EmbeddedEntry>::new();
|
||||
let mut downstream_index = HashMap::<u64, EventSubscriptionSelector>::new();
|
||||
let mut next_lifetime = 1_u64;
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => match command {
|
||||
@@ -371,30 +443,38 @@ async fn run_embedded_connection(
|
||||
downstream_index.insert(downstream_id, selector.clone());
|
||||
if let Some(entry) = entries.get_mut(&selector) {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: generation,
|
||||
snapshot_revision: entry.snapshot_revision,
|
||||
snapshot: entry.snapshot.clone(),
|
||||
});
|
||||
entry.downstreams.insert(downstream_id, events);
|
||||
} else {
|
||||
match runtime.subscribe_event_selector(selector.clone()) {
|
||||
Ok(mut subscription) => {
|
||||
let snapshot_revision = subscription.snapshot_revision();
|
||||
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), &runtime_id);
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { connection_generation: generation, snapshot_revision, snapshot: snapshot.clone() });
|
||||
let sender = updates.clone();
|
||||
let task_selector = selector.clone();
|
||||
let task_runtime_id = runtime_id.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok(update) = subscription.recv().await {
|
||||
let payload = project_payload_runtime(update.payload, &task_runtime_id);
|
||||
if sender.send((task_selector.clone(), update.subject_revision, payload)).is_err() { break; }
|
||||
}
|
||||
let lifetime = next_lifetime;
|
||||
next_lifetime = next_lifetime.saturating_add(1);
|
||||
match start_embedded_subscription(
|
||||
&runtime,
|
||||
&runtime_id,
|
||||
selector.clone(),
|
||||
lifetime,
|
||||
updates.clone(),
|
||||
) {
|
||||
Ok((snapshot, task)) => {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
entries.insert(selector, EmbeddedEntry { downstreams: HashMap::from([(downstream_id, events)]), snapshot_revision, snapshot, task });
|
||||
entries.insert(
|
||||
selector,
|
||||
EmbeddedEntry {
|
||||
lifetime,
|
||||
downstreams: HashMap::from([(downstream_id, events)]),
|
||||
snapshot,
|
||||
task,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Rejected { connection_generation: generation, code: SubscriptionRejectionCode::UnsupportedSelector, message: error.to_string() });
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Rejected {
|
||||
code: SubscriptionRejectionCode::UnsupportedSelector,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,7 +487,7 @@ async fn run_embedded_connection(
|
||||
}
|
||||
Some(Command::Shutdown(replacement)) => {
|
||||
for entry in entries.values_mut() {
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { connection_generation: generation, code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime generation {generation} was fenced by {replacement}") });
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime registration {generation} was replaced by {replacement}") });
|
||||
entry.task.abort();
|
||||
}
|
||||
return;
|
||||
@@ -415,16 +495,68 @@ async fn run_embedded_connection(
|
||||
None => return,
|
||||
},
|
||||
update = update_receiver.recv() => {
|
||||
let Some((selector, subject_revision, payload)) = update else { return; };
|
||||
if let Some(entry) = entries.get_mut(&selector) {
|
||||
entry.snapshot_revision = entry.snapshot_revision.saturating_add(1);
|
||||
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { connection_generation: generation, subject_revision, payload });
|
||||
let Some(update) = update else { return; };
|
||||
match update {
|
||||
EmbeddedUpdate::Event { selector, lifetime, payload } => {
|
||||
if let Some(entry) = entries
|
||||
.get_mut(&selector)
|
||||
.filter(|entry| entry.lifetime == lifetime)
|
||||
{
|
||||
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { payload });
|
||||
}
|
||||
}
|
||||
EmbeddedUpdate::Closed { selector, lifetime, code, message } => {
|
||||
if !entries
|
||||
.get(&selector)
|
||||
.is_some_and(|entry| entry.lifetime == lifetime)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(mut entry) = entries.remove(&selector) else { continue; };
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed { code, message },
|
||||
);
|
||||
if entry.downstreams.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let new_lifetime = next_lifetime;
|
||||
next_lifetime = next_lifetime.saturating_add(1);
|
||||
match start_embedded_subscription(
|
||||
&runtime,
|
||||
&runtime_id,
|
||||
selector.clone(),
|
||||
new_lifetime,
|
||||
updates.clone(),
|
||||
) {
|
||||
Ok((snapshot, task)) => {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
},
|
||||
);
|
||||
entry.snapshot = snapshot;
|
||||
entry.task = task;
|
||||
entry.lifetime = new_lifetime;
|
||||
entries.insert(selector, entry);
|
||||
}
|
||||
Err(error) => {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Rejected {
|
||||
code: SubscriptionRejectionCode::Internal,
|
||||
message: error.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
connected: true,
|
||||
desired_selectors: entries.len(),
|
||||
upstream_subscriptions: entries.len(),
|
||||
@@ -597,10 +729,8 @@ async fn apply_online(
|
||||
.selectors
|
||||
.entry(selector.clone())
|
||||
.or_insert_with(SelectorState::new);
|
||||
if let Some((revision, snapshot)) = &entry.snapshot {
|
||||
if let Some(snapshot) = &entry.snapshot {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: state.generation,
|
||||
snapshot_revision: *revision,
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
}
|
||||
@@ -696,7 +826,6 @@ async fn handle_frame(
|
||||
request_id,
|
||||
subscription_id,
|
||||
selector,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
}) => {
|
||||
if state.pending.remove(&request_id) != Some(selector.clone()) {
|
||||
@@ -706,18 +835,13 @@ async fn handle_frame(
|
||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||
entry.pending = false;
|
||||
entry.upstream_id = Some(subscription_id.clone());
|
||||
entry.snapshot = Some((snapshot_revision, snapshot.clone()));
|
||||
entry.revisions = snapshot_revisions(&snapshot);
|
||||
entry.snapshot = Some(snapshot.clone());
|
||||
state
|
||||
.upstream_index
|
||||
.insert(subscription_id, selector.clone());
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: state.generation,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
},
|
||||
BrokerSubscriptionEvent::Snapshot { snapshot },
|
||||
);
|
||||
if entry.downstreams.is_empty() {
|
||||
maybe_unsubscribe(socket, state, selector).await?;
|
||||
@@ -734,18 +858,13 @@ async fn handle_frame(
|
||||
if let Some(mut entry) = state.selectors.remove(&selector) {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Rejected {
|
||||
connection_generation: state.generation,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
BrokerSubscriptionEvent::Rejected { code, message },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
subject_revision,
|
||||
payload,
|
||||
}) => {
|
||||
let selector = state
|
||||
@@ -756,24 +875,12 @@ async fn handle_frame(
|
||||
payload.validate_for_selector(&selector).map_err(|_| ())?;
|
||||
let payload = project_payload_runtime(payload, &state.runtime_id);
|
||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||
if let Some(subject) = event_subject(&payload) {
|
||||
let revision = entry.revisions.entry(subject).or_insert(0);
|
||||
if subject_revision <= *revision {
|
||||
return Ok(());
|
||||
}
|
||||
*revision = subject_revision;
|
||||
}
|
||||
if let Some((snapshot_revision, snapshot)) = entry.snapshot.as_mut() {
|
||||
*snapshot_revision = snapshot_revision.saturating_add(1);
|
||||
if let Some(snapshot) = entry.snapshot.as_mut() {
|
||||
apply_event_to_cached_snapshot(snapshot, &payload);
|
||||
}
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation: state.generation,
|
||||
subject_revision,
|
||||
payload,
|
||||
},
|
||||
BrokerSubscriptionEvent::Event { payload },
|
||||
);
|
||||
}
|
||||
SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed {
|
||||
@@ -785,14 +892,9 @@ async fn handle_frame(
|
||||
let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) {
|
||||
entry.upstream_id = None;
|
||||
entry.snapshot = None;
|
||||
entry.revisions.clear();
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation: state.generation,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
BrokerSubscriptionEvent::Closed { code, message },
|
||||
);
|
||||
!entry.downstreams.is_empty()
|
||||
} else {
|
||||
@@ -822,10 +924,9 @@ fn close_all(state: &mut State, replacement: u64) {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation: state.generation,
|
||||
code: SubscriptionTerminationCode::ServerShutdown,
|
||||
message: format!(
|
||||
"Runtime connection generation {} was fenced by generation {replacement}",
|
||||
"Runtime registration {} was replaced by {replacement}",
|
||||
state.generation
|
||||
),
|
||||
},
|
||||
@@ -846,51 +947,6 @@ fn broadcast(
|
||||
downstreams.retain(|id, _| !closed.contains(id));
|
||||
}
|
||||
|
||||
fn snapshot_revisions(snapshot: &SubscriptionSnapshot) -> HashMap<String, u64> {
|
||||
match snapshot {
|
||||
SubscriptionSnapshot::Workers { workers } => workers
|
||||
.iter()
|
||||
.map(|worker| {
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
worker.runtime_id.as_deref().unwrap_or_default(),
|
||||
worker.worker_id
|
||||
),
|
||||
worker.subject_revision,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
SubscriptionSnapshot::WorkerProtocol { worker_id, .. } => {
|
||||
HashMap::from([(worker_id.to_string(), 0)])
|
||||
}
|
||||
SubscriptionSnapshot::WorkspaceWorkdirs { .. } => HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn event_subject(payload: &SubscriptionEventPayload) -> Option<String> {
|
||||
Some(match payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => format!(
|
||||
"{}:{}",
|
||||
worker.runtime_id.as_deref().unwrap_or_default(),
|
||||
worker.worker_id
|
||||
),
|
||||
SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id,
|
||||
runtime_id,
|
||||
} => format!(
|
||||
"{}:{}",
|
||||
runtime_id.as_deref().unwrap_or_default(),
|
||||
worker_id
|
||||
),
|
||||
SubscriptionEventPayload::WorkerProtocol { worker_id, .. } => worker_id.to_string(),
|
||||
SubscriptionEventPayload::WorkdirUpserted { workdir } => {
|
||||
workdir.working_directory_id.to_string()
|
||||
}
|
||||
SubscriptionEventPayload::WorkdirRemoved {
|
||||
working_directory_id,
|
||||
} => working_directory_id.to_string(),
|
||||
})
|
||||
}
|
||||
async fn send_frame(socket: &mut RuntimeSocket, frame: SubscriptionFrame) -> Result<(), ()> {
|
||||
frame.validate().map_err(|_| ())?;
|
||||
socket
|
||||
@@ -979,7 +1035,6 @@ fn runtime_token(
|
||||
}
|
||||
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
|
||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: state.generation,
|
||||
connected,
|
||||
desired_selectors: state
|
||||
.selectors
|
||||
|
||||
@@ -74,6 +74,7 @@ fn create_request(name: &str) -> CreateWorkerRequest {
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,16 +265,9 @@ async fn replacing_runtime_registration_fences_the_old_generation() {
|
||||
assert!(second_generation > first_generation);
|
||||
assert!(matches!(
|
||||
next_event(&mut old).await,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation,
|
||||
..
|
||||
} if connection_generation == first_generation
|
||||
BrokerSubscriptionEvent::Closed { .. }
|
||||
));
|
||||
let status = wait_for_status(&broker, "runtime-test", |status| {
|
||||
status.connection_generation == second_generation && status.connected
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status.connection_generation, second_generation);
|
||||
wait_for_status(&broker, "runtime-test", |status| status.connected).await;
|
||||
server.abort();
|
||||
}
|
||||
|
||||
@@ -430,6 +424,75 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_downstream_overflow_recovers_through_a_fresh_snapshot() {
|
||||
let (runtime, _config, server) = fixture().await;
|
||||
let worker = runtime.list_workers().unwrap().remove(0);
|
||||
let broker = RuntimeSubscriptionBroker::new("local");
|
||||
broker.register_embedded_runtime("embedded-worker-runtime", runtime.clone());
|
||||
let mut subscription = broker
|
||||
.subscribe(
|
||||
"embedded-worker-runtime",
|
||||
EventSubscriptionSelector::RuntimeWorkers,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
next_snapshot(&mut subscription).await,
|
||||
BrokerSubscriptionEvent::Snapshot { .. }
|
||||
));
|
||||
|
||||
let final_command_id = (DOWNSTREAM_QUEUE_CAPACITY as u64) * 3;
|
||||
for command_id in 1..=final_command_id {
|
||||
let mut snapshot = worker.worker_state.clone().unwrap();
|
||||
snapshot.last_command_id = command_id;
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&worker.worker_ref,
|
||||
protocol::Event::WorkerState { snapshot },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut saw_recovery_snapshot = false;
|
||||
let mut converged = false;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !converged {
|
||||
match subscription
|
||||
.recv()
|
||||
.await
|
||||
.expect("broker subscription closed")
|
||||
{
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
} => {
|
||||
saw_recovery_snapshot = true;
|
||||
converged = workers.iter().any(|candidate| {
|
||||
candidate.worker_id.as_str() == worker.worker_id.to_string()
|
||||
&& candidate
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.last_command_id == final_command_id)
|
||||
});
|
||||
}
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker: candidate },
|
||||
} if saw_recovery_snapshot => {
|
||||
converged = candidate.worker_id.as_str() == worker.worker_id.to_string()
|
||||
&& candidate
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.last_command_id == final_command_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("overflow recovery did not converge");
|
||||
assert!(saw_recovery_snapshot);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
|
||||
let config = RemoteRuntimeConfig::new(
|
||||
|
||||
+6679
-146
File diff suppressed because it is too large
Load Diff
+1206
-371
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,10 @@ use tokio::task::JoinHandle;
|
||||
use crate::runtime_subscription::{
|
||||
BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError,
|
||||
};
|
||||
use crate::store::{ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit,
|
||||
WorkerRegistryProjectionRecord,
|
||||
};
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
|
||||
const PROJECTION_EVENT_CAPACITY: usize = 256;
|
||||
@@ -20,21 +23,42 @@ pub type WorkerProjectionChange = WorkerCatalogChange;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerProjectionEvent {
|
||||
pub revision: u64,
|
||||
pub changes: Vec<WorkerProjectionChange>,
|
||||
}
|
||||
|
||||
/// An authoritative durable snapshot followed by changes committed after that snapshot.
|
||||
pub struct WorkerProjectionSubscription {
|
||||
snapshot: Vec<WorkerRegistryProjectionRecord>,
|
||||
receiver: broadcast::Receiver<WorkerProjectionEvent>,
|
||||
}
|
||||
|
||||
impl WorkerProjectionSubscription {
|
||||
pub fn take_snapshot(&mut self) -> Vec<WorkerRegistryProjectionRecord> {
|
||||
std::mem::take(&mut self.snapshot)
|
||||
}
|
||||
|
||||
pub async fn recv(&mut self) -> Result<WorkerProjectionEvent, broadcast::error::RecvError> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn try_recv(&mut self) -> Result<WorkerProjectionEvent, broadcast::error::TryRecvError> {
|
||||
self.receiver.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the durable Runtime-to-Workspace Worker projection and its independent
|
||||
/// low-frequency publication stream.
|
||||
///
|
||||
/// One Runtime subscription is kept alive per registered Runtime even when no
|
||||
/// browser is connected. Browser subscriptions therefore never become the
|
||||
/// authority for populating `worker_registry` observations.
|
||||
/// The ordering gate covers both durable commits and subscription snapshot creation.
|
||||
/// Every subscription therefore starts with one authoritative snapshot and receives
|
||||
/// only commits that completed after it, in the same order in which they were stored.
|
||||
pub struct WorkerProjectionService {
|
||||
workspace_id: String,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
events: broadcast::Sender<WorkerProjectionEvent>,
|
||||
connection_generation_epoch: u64,
|
||||
ordering: Mutex<()>,
|
||||
attach_gate: tokio::sync::Mutex<()>,
|
||||
tasks: Mutex<HashMap<String, JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
@@ -45,8 +69,8 @@ impl WorkerProjectionService {
|
||||
workspace_id: workspace_id.into(),
|
||||
store,
|
||||
events,
|
||||
connection_generation_epoch: Utc::now().timestamp_nanos_opt().unwrap_or_default().max(0)
|
||||
as u64,
|
||||
ordering: Mutex::new(()),
|
||||
attach_gate: tokio::sync::Mutex::new(()),
|
||||
tasks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
@@ -62,8 +86,38 @@ impl WorkerProjectionService {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<WorkerProjectionEvent> {
|
||||
self.events.subscribe()
|
||||
pub fn subscribe_ordered(&self, limit: usize) -> crate::Result<WorkerProjectionSubscription> {
|
||||
let _ordering = self
|
||||
.ordering
|
||||
.lock()
|
||||
.expect("Worker projection ordering lock poisoned");
|
||||
let receiver = self.events.subscribe();
|
||||
let snapshot_limit = limit.saturating_add(1);
|
||||
let mut snapshot = self
|
||||
.store
|
||||
.worker_registry_projection_snapshot(self.workspace_id.as_str(), snapshot_limit)?;
|
||||
if snapshot.len() > limit {
|
||||
return Err(crate::Error::Store(format!(
|
||||
"authoritative Worker projection exceeds the configured snapshot limit of {limit} records"
|
||||
)));
|
||||
}
|
||||
snapshot.shrink_to_fit();
|
||||
Ok(WorkerProjectionSubscription { snapshot, receiver })
|
||||
}
|
||||
|
||||
/// Serialize one durable projection mutation with its publication. The closure
|
||||
/// must return the exact projection payload captured by the same transaction.
|
||||
pub(crate) fn publish_ordered<T, E>(
|
||||
&self,
|
||||
mutation: impl FnOnce() -> std::result::Result<(T, WorkerRegistryProjectionCommit), E>,
|
||||
) -> std::result::Result<T, E> {
|
||||
let _ordering = self
|
||||
.ordering
|
||||
.lock()
|
||||
.expect("Worker projection ordering lock poisoned");
|
||||
let (result, commit) = mutation()?;
|
||||
self.publish_commit(commit);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn attach_runtime(
|
||||
@@ -71,6 +125,20 @@ impl WorkerProjectionService {
|
||||
broker: &RuntimeSubscriptionBroker,
|
||||
runtime_id: &str,
|
||||
) -> Result<(), RuntimeSubscriptionBrokerError> {
|
||||
// Serialize replacement and wait for the old consumer to stop before the new
|
||||
// lifetime can apply its snapshot. Late notifications can therefore never
|
||||
// cross a Runtime ownership boundary.
|
||||
let _attach = self.attach_gate.lock().await;
|
||||
let previous = self
|
||||
.tasks
|
||||
.lock()
|
||||
.expect("Worker projection task lock poisoned")
|
||||
.remove(runtime_id);
|
||||
if let Some(previous) = previous {
|
||||
previous.abort();
|
||||
let _ = previous.await;
|
||||
}
|
||||
|
||||
let mut subscription =
|
||||
broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
@@ -80,12 +148,14 @@ impl WorkerProjectionService {
|
||||
break;
|
||||
};
|
||||
let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. });
|
||||
if let Err(error) = self.apply_broker_event(runtime_id, event).await {
|
||||
if let Err(error) = self.apply_broker_event(runtime_id, event) {
|
||||
tracing::warn!(
|
||||
runtime_id,
|
||||
error = %error,
|
||||
"failed to initialize durable Worker projection"
|
||||
"failed to initialize durable Worker projection; restarting from a fresh snapshot"
|
||||
);
|
||||
subscription.restart()?;
|
||||
continue;
|
||||
}
|
||||
if initialized {
|
||||
break;
|
||||
@@ -99,26 +169,22 @@ impl WorkerProjectionService {
|
||||
let Some(service) = service.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = service
|
||||
.apply_broker_event(task_runtime_id.as_str(), event)
|
||||
.await
|
||||
{
|
||||
if let Err(error) = service.apply_broker_event(task_runtime_id.as_str(), event) {
|
||||
tracing::warn!(
|
||||
runtime_id = %task_runtime_id,
|
||||
error = %error,
|
||||
"failed to update durable Worker projection"
|
||||
"failed to update durable Worker projection; restarting from a fresh snapshot"
|
||||
);
|
||||
if subscription.restart().is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(previous) = self
|
||||
.tasks
|
||||
self.tasks
|
||||
.lock()
|
||||
.expect("Worker projection task lock poisoned")
|
||||
.insert(runtime_id, task)
|
||||
{
|
||||
previous.abort();
|
||||
}
|
||||
.insert(runtime_id, task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -128,110 +194,96 @@ impl WorkerProjectionService {
|
||||
worker: &protocol::subscription::SubscriptionWorker,
|
||||
observed_at: &str,
|
||||
) -> crate::Result<()> {
|
||||
let commit = self.store.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
0,
|
||||
worker,
|
||||
observed_at,
|
||||
)?;
|
||||
self.publish_commit(commit)
|
||||
self.publish_ordered(|| {
|
||||
self.store
|
||||
.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
worker,
|
||||
observed_at,
|
||||
)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> {
|
||||
let commit = self
|
||||
.store
|
||||
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)?;
|
||||
self.publish_commit(commit)
|
||||
self.publish_ordered(|| {
|
||||
self.store
|
||||
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_broker_event(
|
||||
fn apply_broker_event(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
event: BrokerSubscriptionEvent,
|
||||
) -> crate::Result<()> {
|
||||
let observed_at = Utc::now().to_rfc3339();
|
||||
let projection_generation = |connection_generation: u64| {
|
||||
self.connection_generation_epoch
|
||||
.saturating_add(connection_generation)
|
||||
};
|
||||
let commit = match event {
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation,
|
||||
snapshot_revision,
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
} => self.store.reconcile_worker_registry_snapshot(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
snapshot_revision,
|
||||
workers.as_slice(),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Snapshot { .. } => return Ok(()),
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation,
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
..
|
||||
} => self.store.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
&worker,
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation,
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
|
||||
} => self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
Some(worker_id.as_str()),
|
||||
Some(subject_revision),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event { .. } => return Ok(()),
|
||||
BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation,
|
||||
..
|
||||
}
|
||||
| BrokerSubscriptionEvent::Rejected {
|
||||
connection_generation,
|
||||
..
|
||||
}
|
||||
| BrokerSubscriptionEvent::Closed {
|
||||
connection_generation,
|
||||
..
|
||||
} => self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
None,
|
||||
None,
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
};
|
||||
self.publish_commit(commit)
|
||||
self.publish_ordered(|| {
|
||||
let commit = match event {
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
} => self.store.reconcile_worker_registry_snapshot(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
workers.as_slice(),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Snapshot { .. } => {
|
||||
return Ok((
|
||||
(),
|
||||
WorkerRegistryProjectionCommit {
|
||||
changes: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
} => self.store.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
&worker,
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
|
||||
} => self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
Some(worker_id.as_str()),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event { .. } => {
|
||||
return Ok((
|
||||
(),
|
||||
WorkerRegistryProjectionCommit {
|
||||
changes: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
BrokerSubscriptionEvent::Disconnected { .. }
|
||||
| BrokerSubscriptionEvent::Rejected { .. }
|
||||
| BrokerSubscriptionEvent::Closed { .. } => {
|
||||
self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
None,
|
||||
observed_at.as_str(),
|
||||
)?
|
||||
}
|
||||
};
|
||||
Ok(((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
/// Broadcast an already committed catalog payload. Persistence always happens
|
||||
/// before this call; a process crash therefore closes subscribers, whose next
|
||||
/// subscription starts from the durable snapshot. Retrying a removal returns
|
||||
/// the same durable change and can heal an in-process missed broadcast.
|
||||
pub(crate) fn publish_commit(
|
||||
&self,
|
||||
commit: WorkerRegistryProjectionCommit,
|
||||
) -> crate::Result<()> {
|
||||
fn publish_commit(&self, commit: WorkerRegistryProjectionCommit) {
|
||||
if commit.changes.is_empty() {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let _ = self.events.send(WorkerProjectionEvent {
|
||||
revision: commit.revision,
|
||||
changes: commit.changes,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +292,159 @@ impl Drop for WorkerProjectionService {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Barrier, Mutex};
|
||||
|
||||
use crate::store::{
|
||||
AccountRecord, ControlPlaneStore, SqliteWorkspaceStore, WorkerCatalogChange,
|
||||
WorkerRegistryRecord, WorkspaceRecord,
|
||||
};
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
|
||||
use super::WorkerProjectionService;
|
||||
|
||||
const WORKSPACE_ID: &str = "projection-ordering-test";
|
||||
|
||||
async fn fixture() -> (
|
||||
Arc<SqliteWorkspaceStore>,
|
||||
Arc<WorkerProjectionService>,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap());
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: "owner".to_string(),
|
||||
kind: "user".to_string(),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: WORKSPACE_ID.to_string(),
|
||||
owner_account_id: "owner".to_string(),
|
||||
display_name: "Projection ordering".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let service = WorkerProjectionService::new(WORKSPACE_ID, store.clone());
|
||||
(store, service, temp)
|
||||
}
|
||||
|
||||
fn worker(runtime_id: &str, worker_id: &str) -> WorkerRegistryRecord {
|
||||
WorkerRegistryRecord {
|
||||
workspace_id: WORKSPACE_ID.to_string(),
|
||||
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||
display_name: format!("{runtime_id}-{worker_id}"),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
retention_state: "normal".to_string(),
|
||||
transcript_ref: None,
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_registration_is_atomic_with_concurrent_publication() {
|
||||
let (store, service, _temp) = fixture().await;
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let subscriber_service = service.clone();
|
||||
let subscriber_barrier = barrier.clone();
|
||||
let subscriber = std::thread::spawn(move || {
|
||||
subscriber_barrier.wait();
|
||||
subscriber_service.subscribe_ordered(10).unwrap()
|
||||
});
|
||||
let publisher_service = service.clone();
|
||||
let publisher_barrier = barrier.clone();
|
||||
let publisher_store = store.clone();
|
||||
let record = worker("runtime-a", "worker-a");
|
||||
let publisher = std::thread::spawn(move || {
|
||||
publisher_barrier.wait();
|
||||
publisher_service
|
||||
.publish_ordered(|| {
|
||||
publisher_store
|
||||
.upsert_worker_registry(&record)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut subscription = subscriber.join().unwrap();
|
||||
publisher.join().unwrap();
|
||||
let in_snapshot = subscription
|
||||
.take_snapshot()
|
||||
.iter()
|
||||
.any(|record| record.registry.worker.worker_id == "worker-a");
|
||||
let in_event = match subscription.try_recv() {
|
||||
Ok(event) => matches!(
|
||||
event.changes.as_slice(),
|
||||
[WorkerCatalogChange::Upsert(record)]
|
||||
if record.registry.worker.worker_id == "worker-a"
|
||||
),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => false,
|
||||
Err(error) => panic!("unexpected projection receive error: {error}"),
|
||||
};
|
||||
assert_ne!(
|
||||
in_snapshot, in_event,
|
||||
"the commit must cross the boundary exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrent_commits_are_published_in_durable_execution_order() {
|
||||
let (store, service, _temp) = fixture().await;
|
||||
let mut subscription = service.subscribe_ordered(10).unwrap();
|
||||
assert!(subscription.take_snapshot().is_empty());
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let durable_order = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut publishers = Vec::new();
|
||||
for worker_id in ["worker-a", "worker-b"] {
|
||||
let service = service.clone();
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let durable_order = durable_order.clone();
|
||||
let record = worker("runtime-a", worker_id);
|
||||
publishers.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
service
|
||||
.publish_ordered(|| {
|
||||
durable_order
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(record.worker.worker_id.clone());
|
||||
store
|
||||
.upsert_worker_registry(&record)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
for publisher in publishers {
|
||||
publisher.join().unwrap();
|
||||
}
|
||||
|
||||
let published_order = [
|
||||
subscription.try_recv().unwrap(),
|
||||
subscription.try_recv().unwrap(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|event| match event.changes.as_slice() {
|
||||
[WorkerCatalogChange::Upsert(record)] => record.registry.worker.worker_id.clone(),
|
||||
changes => panic!("unexpected projection changes: {changes:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(published_order, *durable_order.lock().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ mod tests {
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
repository_key: "platform".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
@@ -322,13 +322,11 @@ mod tests {
|
||||
assert_eq!(signing_identity.algorithm, "ed25519");
|
||||
assert!(signing_identity.public_key.is_some());
|
||||
assert!(signing_identity.public_key_fingerprint.is_some());
|
||||
assert_eq!(
|
||||
store
|
||||
.list_repositories(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let repositories = store
|
||||
.list_repositories(&created.workspace.workspace_id)
|
||||
.unwrap();
|
||||
assert_eq!(repositories.len(), 1);
|
||||
assert_eq!(repositories[0].repository_key, "platform");
|
||||
assert!(
|
||||
store
|
||||
.load_workspace_config(&created.workspace.workspace_id)
|
||||
@@ -337,6 +335,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_rejects_noncanonical_repository_keys_without_persisting_a_workspace() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(
|
||||
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||
),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
|
||||
for (operation_key, repository_key) in [
|
||||
("blank-repository-key", " "),
|
||||
("spaced-repository-key", " platform "),
|
||||
] {
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: operation_key.to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: repository_key.to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
|
||||
let error = service
|
||||
.create(request, owner_account_id.clone())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("invalid Repository key"), "{error}");
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -73,12 +73,11 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"worker_orphan_diagnostics",
|
||||
"worker_registry",
|
||||
"worker_registry_observations",
|
||||
"worker_registry_projection_cursors",
|
||||
"worker_registry_projection_diagnostics",
|
||||
"worker_registry_projection_removals",
|
||||
"worker_registry_projection_revisions",
|
||||
"worker_removal_operations",
|
||||
"worker_retention_audit_events",
|
||||
"worker_session_archive_observe_grants",
|
||||
"worker_session_archives",
|
||||
"worker_singleton_owners",
|
||||
"worker_tombstones",
|
||||
|
||||
@@ -271,7 +271,6 @@ async fn run_worker_protocol(
|
||||
worker_id: worker_id.clone(),
|
||||
runtime_id: Some(runtime_id),
|
||||
},
|
||||
snapshot_revision: 0,
|
||||
snapshot: SubscriptionSnapshot::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
events: vec![initial_event],
|
||||
@@ -284,13 +283,10 @@ async fn run_worker_protocol(
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut subject_revision = 0_u64;
|
||||
while let Some(event) = events.recv().await {
|
||||
subject_revision = subject_revision.saturating_add(1);
|
||||
let frame =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id.clone(),
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
event,
|
||||
@@ -333,12 +329,10 @@ async fn run_workspace_workers(
|
||||
subscription_id: SubscriptionId,
|
||||
outbound: mpsc::Sender<WsMessage>,
|
||||
) {
|
||||
// Subscribe before reading so commits racing with the snapshot are replayed.
|
||||
let mut events = api.worker_projection.subscribe();
|
||||
let Ok((snapshot_revision, records)) = api.store.worker_registry_projection_snapshot(
|
||||
&api.config.workspace_id,
|
||||
api.config.max_records.max(1),
|
||||
) else {
|
||||
let Ok(mut projection) = api
|
||||
.worker_projection
|
||||
.subscribe_ordered(api.config.max_records.max(1))
|
||||
else {
|
||||
let _ = send_rejected(
|
||||
&outbound,
|
||||
request_id,
|
||||
@@ -348,7 +342,8 @@ async fn run_workspace_workers(
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
let mut workers = records
|
||||
let mut workers = projection
|
||||
.take_snapshot()
|
||||
.into_iter()
|
||||
.filter_map(|record| project_registry_worker(&api, record))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -360,7 +355,6 @@ async fn run_workspace_workers(
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||
snapshot_revision,
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
},
|
||||
)),
|
||||
@@ -372,16 +366,14 @@ async fn run_workspace_workers(
|
||||
}
|
||||
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(event) if event.revision <= snapshot_revision => continue,
|
||||
match projection.recv().await {
|
||||
Ok(event) => {
|
||||
for change in event.changes {
|
||||
let payload = match change {
|
||||
crate::worker_projection::WorkerProjectionChange::Upsert(record) => {
|
||||
let Some(mut worker) = project_registry_worker(&api, record) else {
|
||||
let Some(worker) = project_registry_worker(&api, record) else {
|
||||
continue;
|
||||
};
|
||||
worker.subject_revision = event.revision;
|
||||
SubscriptionEventPayload::WorkerUpserted { worker }
|
||||
}
|
||||
crate::worker_projection::WorkerProjectionChange::Removed(worker) => {
|
||||
@@ -396,7 +388,7 @@ async fn run_workspace_workers(
|
||||
}
|
||||
}
|
||||
};
|
||||
if send_event(&outbound, &subscription_id, event.revision, payload)
|
||||
if send_event(&outbound, &subscription_id, payload)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -431,7 +423,6 @@ fn project_registry_worker(
|
||||
let mut worker = if let Some(observation) = record.observation {
|
||||
let mut worker = observation.worker;
|
||||
worker.availability = observation.availability;
|
||||
worker.subject_revision = observation.projection_revision;
|
||||
worker
|
||||
} else {
|
||||
SubscriptionWorker {
|
||||
@@ -442,7 +433,6 @@ fn project_registry_worker(
|
||||
runtime_id: Some(runtime_id.clone()),
|
||||
resource_key: record.resource_key.clone(),
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
|
||||
subject_revision: 0,
|
||||
worker_state: None,
|
||||
state: protocol::subscription::SubscriptionWorkerState::Stopped,
|
||||
has_running_internal_workers: false,
|
||||
@@ -559,14 +549,12 @@ fn sort_workers(workers: &mut [SubscriptionWorker]) {
|
||||
async fn send_event(
|
||||
outbound: &mpsc::Sender<WsMessage>,
|
||||
subscription_id: &SubscriptionId,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
) -> Result<(), ()> {
|
||||
send_frame(
|
||||
outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id.clone(),
|
||||
subject_revision,
|
||||
payload,
|
||||
})),
|
||||
)
|
||||
@@ -617,7 +605,6 @@ mod tests {
|
||||
runtime_id: Some("runtime-1".to_string()),
|
||||
resource_key: Some("W-1".to_string()),
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 1,
|
||||
worker_state: None,
|
||||
state: protocol::subscription::SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
|
||||
+31
-2
@@ -2986,7 +2986,7 @@ backend = "shared"
|
||||
"--display-name".to_string(),
|
||||
"Workspace A".to_string(),
|
||||
"--repository-key".to_string(),
|
||||
"main".to_string(),
|
||||
"platform".to_string(),
|
||||
"--repository".to_string(),
|
||||
repository.path().display().to_string(),
|
||||
"--default-ref".to_string(),
|
||||
@@ -3000,7 +3000,7 @@ backend = "shared"
|
||||
};
|
||||
assert_eq!(options.backend_url, "http://backend.example");
|
||||
assert_eq!(options.display_name, "Workspace A");
|
||||
assert_eq!(options.repository_key, "main");
|
||||
assert_eq!(options.repository_key, "platform");
|
||||
assert_eq!(options.default_ref.as_deref(), Some("develop"));
|
||||
assert_eq!(
|
||||
options.repository_root,
|
||||
@@ -3009,6 +3009,35 @@ backend = "shared"
|
||||
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_parsing_rejects_an_omitted_repository_key() {
|
||||
let repository = tempfile::tempdir().unwrap();
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(repository.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://unused.example",
|
||||
};
|
||||
let args = vec![
|
||||
"--backend".to_string(),
|
||||
"http://backend.example".to_string(),
|
||||
"init".to_string(),
|
||||
"--display-name".to_string(),
|
||||
"Workspace A".to_string(),
|
||||
"--repository".to_string(),
|
||||
repository.path().display().to_string(),
|
||||
];
|
||||
|
||||
let error = parse_args_slice_with_connection_resolver(&args, &resolver)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert_eq!(error, "yoi init requires --repository-key KEY");
|
||||
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user