chore: merge develop into hare/develop
This commit is contained in:
Generated
+1
@@ -6403,6 +6403,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"server-api",
|
"server-api",
|
||||||
|
"session-store",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"ssh-key",
|
"ssh-key",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ pub enum ToolError {
|
|||||||
/// Internal error
|
/// Internal error
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
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.
|
/// Cooperative cancellation completed with bounded terminal output.
|
||||||
#[error("Tool execution cancelled")]
|
#[error("Tool execution cancelled")]
|
||||||
Cancelled(ToolOutput),
|
Cancelled(ToolOutput),
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ use server_api::{
|
|||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||||
RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
|
||||||
WorkspaceRuntimeResource,
|
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
|
||||||
|
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
|
||||||
|
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
|
||||||
|
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||||
};
|
};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||||
@@ -410,6 +413,97 @@ impl BackendWorkspaceProductClient {
|
|||||||
crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit)
|
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(
|
pub fn launch_ticket_intake(
|
||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
@@ -917,6 +1011,45 @@ mod tests {
|
|||||||
handle.join().unwrap();
|
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]
|
#[test]
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
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"}"#;
|
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.",
|
description: "Internal Memory consolidation service.",
|
||||||
imports: BASE_IMPORT,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ use crate::{
|
|||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||||
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
||||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ResolvedSubjektivFeatureConfig,
|
||||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
ScopeConfig, SessionConfig, SkillsConfig, SubjektivFeatureProfileConfig, TicketFeatureConfig,
|
||||||
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||||
|
WorkerManifest, WorkerMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||||
@@ -76,6 +77,8 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: Option<MemoryFeatureConfigPartial>,
|
pub memory: Option<MemoryFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub subjektiv: Option<SubjektivFeatureConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
pub web: Option<FeatureFlagConfigPartial>,
|
pub web: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub image: Option<FeatureFlagConfigPartial>,
|
pub image: Option<FeatureFlagConfigPartial>,
|
||||||
@@ -104,6 +107,11 @@ impl FeatureConfigPartial {
|
|||||||
Self {
|
Self {
|
||||||
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
||||||
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::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),
|
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||||
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
|
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
|
||||||
sub_worker: merge_option(
|
sub_worker: merge_option(
|
||||||
@@ -227,6 +235,28 @@ pub struct MemoryConsolidationProfileConfigPartial {
|
|||||||
pub request_enabled: Option<bool>,
|
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 {
|
impl MemoryFeatureConfigPartial {
|
||||||
fn merge(self, other: Self) -> Self {
|
fn merge(self, other: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -331,6 +361,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.memory
|
.memory
|
||||||
.map(ResolvedMemoryFeatureConfig::from)
|
.map(ResolvedMemoryFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
subjektiv: value
|
||||||
|
.subjektiv
|
||||||
|
.map(ResolvedSubjektivFeatureConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
sub_worker: value
|
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 {
|
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
||||||
fn from(value: TicketFeatureConfigPartial) -> Self {
|
fn from(value: TicketFeatureConfigPartial) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -500,6 +570,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
Self {
|
Self {
|
||||||
task: Some(value.task.into()),
|
task: Some(value.task.into()),
|
||||||
memory: Some(value.memory.into()),
|
memory: Some(value.memory.into()),
|
||||||
|
subjektiv: Some(value.subjektiv.into()),
|
||||||
web: Some(value.web.into()),
|
web: Some(value.web.into()),
|
||||||
image: Some(value.image.into()),
|
image: Some(value.image.into()),
|
||||||
sub_worker: Some(value.sub_worker.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]
|
#[test]
|
||||||
fn merge_preserves_ref() {
|
fn merge_preserves_ref() {
|
||||||
let lower = WorkerManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
|
|||||||
+109
-1
@@ -108,6 +108,11 @@ pub struct FeatureConfig {
|
|||||||
pub task: FeatureFlagConfig,
|
pub task: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: ResolvedMemoryFeatureConfig,
|
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)]
|
#[serde(default)]
|
||||||
pub web: FeatureFlagConfig,
|
pub web: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -139,6 +144,7 @@ impl Default for FeatureConfig {
|
|||||||
Self {
|
Self {
|
||||||
task: FeatureFlagConfig::disabled(),
|
task: FeatureFlagConfig::disabled(),
|
||||||
memory: ResolvedMemoryFeatureConfig::default(),
|
memory: ResolvedMemoryFeatureConfig::default(),
|
||||||
|
subjektiv: ResolvedSubjektivFeatureConfig::default(),
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
image: FeatureFlagConfig::disabled(),
|
image: FeatureFlagConfig::disabled(),
|
||||||
sub_worker: 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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
pub struct TicketFeatureConfig {
|
pub struct TicketFeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -963,7 +1048,9 @@ impl Default for CompactionConfig {
|
|||||||
|
|
||||||
impl WorkerManifest {
|
impl WorkerManifest {
|
||||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
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.
|
/// 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());
|
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]
|
#[test]
|
||||||
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub enum WorkspaceAuthorityRequirement {
|
|||||||
Flow,
|
Flow,
|
||||||
ManageWorkdir,
|
ManageWorkdir,
|
||||||
Memory,
|
Memory,
|
||||||
|
Subjektiv,
|
||||||
MergeRequest,
|
MergeRequest,
|
||||||
Objective,
|
Objective,
|
||||||
Orchestration,
|
Orchestration,
|
||||||
@@ -157,6 +158,7 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
|
|||||||
Self::Flow => formatter.write_str("feature.flow"),
|
Self::Flow => formatter.write_str("feature.flow"),
|
||||||
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
||||||
Self::Memory => formatter.write_str("feature.memory"),
|
Self::Memory => formatter.write_str("feature.memory"),
|
||||||
|
Self::Subjektiv => formatter.write_str("feature.subjektiv"),
|
||||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||||
Self::Objective => formatter.write_str("feature.objective"),
|
Self::Objective => formatter.write_str("feature.objective"),
|
||||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
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 {
|
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||||
}
|
}
|
||||||
|
if feature.subjektiv.profile.enabled {
|
||||||
|
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
|
||||||
|
}
|
||||||
if feature.merge_request.show
|
if feature.merge_request.show
|
||||||
|| feature.merge_request.open
|
|| feature.merge_request.open
|
||||||
|| feature.merge_request.review
|
|| 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]
|
#[test]
|
||||||
fn builtin_profiles_pin_role_models_and_reasoning() {
|
fn builtin_profiles_pin_role_models_and_reasoning() {
|
||||||
use crate::model::{ReasoningControl, ReasoningEffort};
|
use crate::model::{ReasoningControl, ReasoningEffort};
|
||||||
@@ -1276,6 +1339,7 @@ mod tests {
|
|||||||
"coder",
|
"coder",
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"memory-consolidation",
|
"memory-consolidation",
|
||||||
|
"subjektiv-memory-consolidation",
|
||||||
] {
|
] {
|
||||||
let role = resolve(name);
|
let role = resolve(name);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1308,7 +1372,7 @@ mod tests {
|
|||||||
role.manifest.engine.reasoning,
|
role.manifest.engine.reasoning,
|
||||||
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
||||||
);
|
);
|
||||||
let extraction = &role.manifest.feature.memory.profile.extraction;
|
let extraction = &role.manifest.feature.subjektiv.profile.extraction;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extraction
|
extraction
|
||||||
.model
|
.model
|
||||||
@@ -1322,7 +1386,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let consolidation = resolve("memory-consolidation");
|
for name in ["memory-consolidation", "subjektiv-memory-consolidation"] {
|
||||||
|
let consolidation = resolve(name);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
consolidation.manifest.model.ref_.as_deref(),
|
consolidation.manifest.model.ref_.as_deref(),
|
||||||
Some("codex-oauth/gpt-5.6-luna")
|
Some("codex-oauth/gpt-5.6-luna")
|
||||||
@@ -1332,6 +1397,54 @@ mod tests {
|
|||||||
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
|
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!(
|
||||||
|
manifest.web.as_ref().and_then(|web| web.enabled),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
|
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
|
||||||
@@ -1477,7 +1590,8 @@ mod tests {
|
|||||||
panic!("unexpected error: {error}");
|
panic!("unexpected error: {error}");
|
||||||
};
|
};
|
||||||
assert_eq!(target, ProfileExecutionTarget::Standalone);
|
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::MergeRequest));
|
||||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
|
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
|
||||||
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
|
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub enum MemoryBackendHttpResponse {
|
|||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum MemoryBackendOperationResult {
|
pub enum MemoryBackendOperationResult {
|
||||||
ToolOutput(MemoryToolOutput),
|
ToolOutput(MemoryToolOutput),
|
||||||
|
ResidentSummary(MemoryResidentSummaryOutput),
|
||||||
Acknowledged(MemoryBackendAckOutput),
|
Acknowledged(MemoryBackendAckOutput),
|
||||||
StagingWritten(MemoryStagingWriteOutput),
|
StagingWritten(MemoryStagingWriteOutput),
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,23 @@ pub struct MemoryToolOutput {
|
|||||||
pub content: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct MemoryQueryOperation {
|
pub struct MemoryQueryOperation {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
+117
-7
@@ -766,6 +766,10 @@ pub struct InternalWorkerSnapshot {
|
|||||||
pub worker: InternalWorkerRef,
|
pub worker: InternalWorkerRef,
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
pub session: SessionSnapshot,
|
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)]
|
#[serde(default)]
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -1241,6 +1245,16 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
cache_read_input_tokens: Option<u64>,
|
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 {
|
RunEnd {
|
||||||
result: RunResult,
|
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
|
/// This deliberately exposes only the effective provider control, not the
|
||||||
/// transmitted alongside `Event::Snapshot` so clients don't need
|
/// Worker manifest or provider credentials. The tagged shape prevents token
|
||||||
/// their own view of the manifest.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
@@ -1680,14 +1728,22 @@ pub struct Greeting {
|
|||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub model: 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 scope_summary: String,
|
||||||
pub tools: Vec<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)]
|
#[serde(default)]
|
||||||
pub context_window: u64,
|
pub context_window: u64,
|
||||||
/// Estimated current session context tokens at connect time.
|
/// Legacy numeric mirror retained for mixed-version clients.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub context_tokens: u64,
|
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()],
|
tools: vec!["Read".into()],
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 42_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(),
|
state: WorkerStatus::Paused.into(),
|
||||||
in_flight: InFlightSnapshot::default(),
|
in_flight: InFlightSnapshot::default(),
|
||||||
@@ -2372,16 +2433,63 @@ mod tests {
|
|||||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||||
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_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"]["kind"], "busy");
|
||||||
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
|
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]
|
#[test]
|
||||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
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 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();
|
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||||
match decoded {
|
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:?}"),
|
other => panic!("expected Snapshot, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2399,6 +2507,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Running.into(),
|
state: WorkerStatus::Running.into(),
|
||||||
in_flight: InFlightSnapshot {
|
in_flight: InFlightSnapshot {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use serde::{Deserialize, Deserializer, Serialize};
|
|||||||
|
|
||||||
use crate::Event as WorkerProtocolEvent;
|
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/runtime wire guardrails. These bounds are generated into the Workspace
|
||||||
/// Browser validator so aggregate limits remain part of the Rust-owned contract.
|
/// Browser validator so aggregate limits remain part of the Rust-owned contract.
|
||||||
pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024;
|
pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||||
@@ -426,7 +426,6 @@ pub enum SubscriptionResponse {
|
|||||||
request_id: SubscriptionRequestId,
|
request_id: SubscriptionRequestId,
|
||||||
subscription_id: SubscriptionId,
|
subscription_id: SubscriptionId,
|
||||||
selector: EventSubscriptionSelector,
|
selector: EventSubscriptionSelector,
|
||||||
snapshot_revision: u64,
|
|
||||||
snapshot: SubscriptionSnapshot,
|
snapshot: SubscriptionSnapshot,
|
||||||
},
|
},
|
||||||
Unsubscribed {
|
Unsubscribed {
|
||||||
@@ -512,7 +511,6 @@ pub enum SubscriptionTerminationCode {
|
|||||||
pub enum SubscriptionEvent {
|
pub enum SubscriptionEvent {
|
||||||
Event {
|
Event {
|
||||||
subscription_id: SubscriptionId,
|
subscription_id: SubscriptionId,
|
||||||
subject_revision: u64,
|
|
||||||
payload: SubscriptionEventPayload,
|
payload: SubscriptionEventPayload,
|
||||||
},
|
},
|
||||||
SubscriptionClosed {
|
SubscriptionClosed {
|
||||||
@@ -666,9 +664,7 @@ pub struct SubscriptionWorker {
|
|||||||
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub availability: SubscriptionWorkerAvailability,
|
pub availability: SubscriptionWorkerAvailability,
|
||||||
/// Producer-owned monotonic revision for this Worker subject.
|
/// Latest foreground state observed from the Worker. This remains
|
||||||
pub subject_revision: u64,
|
|
||||||
/// Latest revisioned foreground state observed from the Worker. This remains
|
|
||||||
/// absent until an authoritative Worker snapshot/event has been applied.
|
/// absent until an authoritative Worker snapshot/event has been applied.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub worker_state: Option<crate::WorkerStateSnapshot>,
|
pub worker_state: Option<crate::WorkerStateSnapshot>,
|
||||||
@@ -970,7 +966,6 @@ mod tests {
|
|||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
resource_key: None,
|
resource_key: None,
|
||||||
availability: SubscriptionWorkerAvailability::Observed,
|
availability: SubscriptionWorkerAvailability::Observed,
|
||||||
subject_revision: 0,
|
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
state: SubscriptionWorkerState::Idle,
|
state: SubscriptionWorkerState::Idle,
|
||||||
has_running_internal_workers: false,
|
has_running_internal_workers: false,
|
||||||
@@ -1093,7 +1088,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
json,
|
json,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"protocol_version": 1,
|
"protocol_version": 2,
|
||||||
"frame": "request",
|
"frame": "request",
|
||||||
"message": {
|
"message": {
|
||||||
"method": "subscribe_events",
|
"method": "subscribe_events",
|
||||||
@@ -1119,7 +1114,6 @@ mod tests {
|
|||||||
request_id: request_id(),
|
request_id: request_id(),
|
||||||
subscription_id: subscription_id(),
|
subscription_id: subscription_id(),
|
||||||
selector: EventSubscriptionSelector::RuntimeWorkers,
|
selector: EventSubscriptionSelector::RuntimeWorkers,
|
||||||
snapshot_revision: 7,
|
|
||||||
snapshot: SubscriptionSnapshot::Workers {
|
snapshot: SubscriptionSnapshot::Workers {
|
||||||
workers: vec![worker("worker-1")],
|
workers: vec![worker("worker-1")],
|
||||||
},
|
},
|
||||||
@@ -1158,10 +1152,10 @@ mod tests {
|
|||||||
subscription_id: subscription_id(),
|
subscription_id: subscription_id(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
frame.protocol_version = 2;
|
frame.protocol_version = 1;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
frame.validate(),
|
frame.validate(),
|
||||||
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 2 })
|
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 1 })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,7 +1174,6 @@ mod tests {
|
|||||||
|
|
||||||
let event = SubscriptionEvent::Event {
|
let event = SubscriptionEvent::Event {
|
||||||
subscription_id: subscription_id(),
|
subscription_id: subscription_id(),
|
||||||
subject_revision: 8,
|
|
||||||
payload: SubscriptionEventPayload::WorkerRemoved {
|
payload: SubscriptionEventPayload::WorkerRemoved {
|
||||||
worker_id: worker_id("worker-2"),
|
worker_id: worker_id("worker-2"),
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
@@ -1211,7 +1204,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(frame).unwrap(),
|
serde_json::to_value(frame).unwrap(),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"protocol_version": 1,
|
"protocol_version": 2,
|
||||||
"frame": "worker_protocol",
|
"frame": "worker_protocol",
|
||||||
"message": {
|
"message": {
|
||||||
"subscription_id": "subscription-1",
|
"subscription_id": "subscription-1",
|
||||||
@@ -1253,7 +1246,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(frame).unwrap(),
|
serde_json::to_value(frame).unwrap(),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"protocol_version": 1,
|
"protocol_version": 2,
|
||||||
"frame": "event",
|
"frame": "event",
|
||||||
"message": {
|
"message": {
|
||||||
"event": "subscription_closed",
|
"event": "subscription_closed",
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ use ts_rs::{Config, TS};
|
|||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||||
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
|
||||||
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
|
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
|
||||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||||
RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
|
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
|
||||||
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
|
||||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
|
||||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||||
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
|
||||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
||||||
|
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||||
WorkerStateSnapshot, WorkerStatus,
|
WorkerStateSnapshot, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
@@ -116,10 +117,13 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||||
push_decl::<SessionSnapshot>(&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::<InternalWorkerKind>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<Greeting>(&cfg, &mut output);
|
|
||||||
push_decl::<Alert>(&cfg, &mut output);
|
push_decl::<Alert>(&cfg, &mut output);
|
||||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||||
push_decl::<PasteArtifactMediaType>(&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()),
|
runtime_id: Some("fixture-runtime".to_string()),
|
||||||
resource_key: Some("fixture-worker-resource".to_string()),
|
resource_key: Some("fixture-worker-resource".to_string()),
|
||||||
availability: SubscriptionWorkerAvailability::default(),
|
availability: SubscriptionWorkerAvailability::default(),
|
||||||
subject_revision: 1,
|
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
state: SubscriptionWorkerState::Idle,
|
state: SubscriptionWorkerState::Idle,
|
||||||
has_running_internal_workers: false,
|
has_running_internal_workers: false,
|
||||||
@@ -250,7 +253,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
|||||||
request_id,
|
request_id,
|
||||||
subscription_id: subscription_id.clone(),
|
subscription_id: subscription_id.clone(),
|
||||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||||
snapshot_revision: 1,
|
|
||||||
snapshot: SubscriptionSnapshot::Workers {
|
snapshot: SubscriptionSnapshot::Workers {
|
||||||
workers: vec![worker],
|
workers: vec![worker],
|
||||||
},
|
},
|
||||||
@@ -258,21 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
|||||||
));
|
));
|
||||||
let pending =
|
let pending =
|
||||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||||
subscription_id,
|
subscription_id: subscription_id.clone(),
|
||||||
subject_revision: 2,
|
|
||||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||||
worker_id,
|
worker_id: worker_id.clone(),
|
||||||
event: Event::PendingSubmissionsChanged {
|
event: Event::PendingSubmissionsChanged {
|
||||||
pending: PendingSubmissionsSnapshot::default(),
|
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
|
fixture
|
||||||
.validate()
|
.validate()
|
||||||
.expect("Rust-serialized Browser compatibility fixture must validate");
|
.expect("Rust-serialized Browser compatibility fixture must validate");
|
||||||
}
|
}
|
||||||
vec![subscribed, pending]
|
vec![subscribed, pending, snapshot]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close_object_shapes(schema: &mut Value) {
|
fn close_object_shapes(schema: &mut Value) {
|
||||||
|
|||||||
@@ -444,6 +444,10 @@ pub struct CreateWorkerRequest {
|
|||||||
pub workspace_api: Option<WorkspaceApiRef>,
|
pub workspace_api: Option<WorkspaceApiRef>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
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)]
|
#[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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RetainedSessionIdentity {
|
pub struct RetainedSessionIdentity {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
@@ -766,6 +947,18 @@ pub trait RuntimeApi {
|
|||||||
#[query] request: WorkerSessionHistoryRequest,
|
#[query] request: WorkerSessionHistoryRequest,
|
||||||
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
|
) -> 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)]
|
#[post("/v1/workers", status = 200, error_status = 400)]
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&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(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
_request: CreateWorkerRequest,
|
_request: CreateWorkerRequest,
|
||||||
@@ -1248,7 +1461,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn contract_inventory_is_complete_and_unique() {
|
fn contract_inventory_is_complete_and_unique() {
|
||||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||||
assert_eq!(operations.len(), 17);
|
assert_eq!(operations.len(), 19);
|
||||||
let mut routes = operations
|
let mut routes = operations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
.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 complete = Self::complete_jsonl_prefix(content);
|
||||||
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||||
line: complete[..error.valid_up_to()]
|
line: complete[..error.valid_up_to()]
|
||||||
@@ -222,16 +226,20 @@ impl FsStore {
|
|||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for (i, line) in content.lines().enumerate() {
|
let mut record_start = 0_u64;
|
||||||
if line.trim().is_empty() {
|
for (index, record) in content.split_inclusive('\n').enumerate() {
|
||||||
continue;
|
let line = record.strip_suffix('\n').unwrap_or(record);
|
||||||
}
|
if !line.trim().is_empty() {
|
||||||
let entry: T = serde_json::from_str(line).map_err(|e| StoreError::Corrupt {
|
let mut entry: LogEntry =
|
||||||
line: i + 1,
|
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
|
||||||
message: e.to_string(),
|
line: index + 1,
|
||||||
|
message: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
record_start = record_start.saturating_add(record.len() as u64);
|
||||||
|
}
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +302,7 @@ impl Store for FsStore {
|
|||||||
return Err(StoreError::NotFound(segment_id));
|
return Err(StoreError::NotFound(segment_id));
|
||||||
}
|
}
|
||||||
let content = fs::read(&path)?;
|
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> {
|
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::LoggedItem;
|
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
|
/// This value is generated at the trusted Worker session boundary and copied
|
||||||
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
|
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub mod history;
|
|||||||
mod legacy_session_log;
|
mod legacy_session_log;
|
||||||
pub mod logged_item;
|
pub mod logged_item;
|
||||||
mod paste_artifact;
|
mod paste_artifact;
|
||||||
|
pub mod public_index;
|
||||||
pub mod public_snapshot;
|
pub mod public_snapshot;
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod segment_log;
|
pub mod segment_log;
|
||||||
@@ -95,6 +96,16 @@ pub use history::{
|
|||||||
};
|
};
|
||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
pub use paste_artifact::PasteArtifactLimits;
|
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::{
|
pub use public_snapshot::{
|
||||||
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||||
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
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)
|
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,
|
session_id: SessionId,
|
||||||
segment_id: Option<SegmentId>,
|
segment_id: Option<SegmentId>,
|
||||||
log: &[LogEntry],
|
log: &[LogEntry],
|
||||||
@@ -1041,15 +1041,16 @@ fn project_session_snapshot_for_segment(
|
|||||||
)),
|
)),
|
||||||
LogEntry::RunYielded {
|
LogEntry::RunYielded {
|
||||||
ts,
|
ts,
|
||||||
|
entry_id,
|
||||||
reason,
|
reason,
|
||||||
active_run_turn_count: count,
|
active_run_turn_count: count,
|
||||||
} => {
|
} => {
|
||||||
active_run_turn_count = Some(*count);
|
active_run_turn_count = Some(*count);
|
||||||
entries.push(legacy_entry(
|
entries.push(run_transition_entry(
|
||||||
|
entry_id.as_ref(),
|
||||||
&session_key,
|
&session_key,
|
||||||
segment_id.as_ref(),
|
segment_id.as_ref(),
|
||||||
log_index,
|
log_index,
|
||||||
0,
|
|
||||||
*ts,
|
*ts,
|
||||||
SessionSnapshotEntryData::RunYielded {
|
SessionSnapshotEntryData::RunYielded {
|
||||||
reason: *reason,
|
reason: *reason,
|
||||||
@@ -1059,15 +1060,16 @@ fn project_session_snapshot_for_segment(
|
|||||||
}
|
}
|
||||||
LogEntry::RunResumed {
|
LogEntry::RunResumed {
|
||||||
ts,
|
ts,
|
||||||
|
entry_id,
|
||||||
source,
|
source,
|
||||||
active_run_turn_count: count,
|
active_run_turn_count: count,
|
||||||
} => {
|
} => {
|
||||||
active_run_turn_count = Some(*count);
|
active_run_turn_count = Some(*count);
|
||||||
entries.push(legacy_entry(
|
entries.push(run_transition_entry(
|
||||||
|
entry_id.as_ref(),
|
||||||
&session_key,
|
&session_key,
|
||||||
segment_id.as_ref(),
|
segment_id.as_ref(),
|
||||||
log_index,
|
log_index,
|
||||||
0,
|
|
||||||
*ts,
|
*ts,
|
||||||
SessionSnapshotEntryData::RunResumed {
|
SessionSnapshotEntryData::RunResumed {
|
||||||
source: *source,
|
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;
|
active_run_turn_count = None;
|
||||||
entries.push(legacy_entry(
|
entries.push(run_transition_entry(
|
||||||
|
entry_id.as_ref(),
|
||||||
&session_key,
|
&session_key,
|
||||||
segment_id.as_ref(),
|
segment_id.as_ref(),
|
||||||
log_index,
|
log_index,
|
||||||
0,
|
|
||||||
*ts,
|
*ts,
|
||||||
SessionSnapshotEntryData::RunCancelled,
|
SessionSnapshotEntryData::RunCancelled,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
LogEntry::RunErrored {
|
LogEntry::RunErrored {
|
||||||
ts,
|
ts,
|
||||||
|
entry_id,
|
||||||
message,
|
message,
|
||||||
failure,
|
failure,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
active_run_turn_count = None;
|
active_run_turn_count = None;
|
||||||
entries.push(legacy_entry(
|
entries.push(run_transition_entry(
|
||||||
|
entry_id.as_ref(),
|
||||||
&session_key,
|
&session_key,
|
||||||
segment_id.as_ref(),
|
segment_id.as_ref(),
|
||||||
log_index,
|
log_index,
|
||||||
0,
|
|
||||||
*ts,
|
*ts,
|
||||||
SessionSnapshotEntryData::RunError {
|
SessionSnapshotEntryData::RunError {
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
@@ -1234,6 +1237,25 @@ fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.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(
|
fn legacy_entry(
|
||||||
session_key: &SessionId,
|
session_key: &SessionId,
|
||||||
segment_id: Option<&SegmentId>,
|
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]
|
#[test]
|
||||||
fn run_transitions_project_with_timestamps_and_typed_causes() {
|
fn run_transitions_project_with_timestamps_and_typed_causes() {
|
||||||
let session_id = crate::new_session_id();
|
let session_id = crate::new_session_id();
|
||||||
let log = vec![
|
let log = vec![
|
||||||
LogEntry::RunYielded {
|
LogEntry::RunYielded {
|
||||||
ts: 10,
|
ts: 10,
|
||||||
|
entry_id: None,
|
||||||
reason: protocol::RunYieldReason::Compaction,
|
reason: protocol::RunYieldReason::Compaction,
|
||||||
active_run_turn_count: 2,
|
active_run_turn_count: 2,
|
||||||
},
|
},
|
||||||
LogEntry::RunResumed {
|
LogEntry::RunResumed {
|
||||||
ts: 20,
|
ts: 20,
|
||||||
|
entry_id: None,
|
||||||
source: protocol::RunResumeSource::Compaction,
|
source: protocol::RunResumeSource::Compaction,
|
||||||
active_run_turn_count: 2,
|
active_run_turn_count: 2,
|
||||||
},
|
},
|
||||||
LogEntry::RunErrored {
|
LogEntry::RunErrored {
|
||||||
ts: 30,
|
ts: 30,
|
||||||
|
entry_id: None,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
message: "mid-run compaction failed: unavailable".into(),
|
message: "mid-run compaction failed: unavailable".into(),
|
||||||
failure: Some(protocol::RunFailureKind::Compaction),
|
failure: Some(protocol::RunFailureKind::Compaction),
|
||||||
@@ -2869,7 +3213,10 @@ mod tests {
|
|||||||
ts: 10,
|
ts: 10,
|
||||||
trigger: protocol::InvokeKind::UserSend,
|
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,
|
segment_id,
|
||||||
LogEntry::RunYielded {
|
LogEntry::RunYielded {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
reason,
|
reason,
|
||||||
active_run_turn_count,
|
active_run_turn_count,
|
||||||
},
|
},
|
||||||
@@ -367,6 +368,7 @@ pub fn save_run_resumed(
|
|||||||
segment_id,
|
segment_id,
|
||||||
LogEntry::RunResumed {
|
LogEntry::RunResumed {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
source,
|
source,
|
||||||
active_run_turn_count,
|
active_run_turn_count,
|
||||||
},
|
},
|
||||||
@@ -390,6 +392,7 @@ pub fn save_run_errored(
|
|||||||
segment_id,
|
segment_id,
|
||||||
LogEntry::RunErrored {
|
LogEntry::RunErrored {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
interrupted,
|
interrupted,
|
||||||
message,
|
message,
|
||||||
failure: Some(RunFailureKind::Engine),
|
failure: Some(RunFailureKind::Engine),
|
||||||
@@ -568,7 +571,8 @@ pub fn append_entry(
|
|||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
entry: LogEntry,
|
mut entry: LogEntry,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
|
entry.ensure_session_entry_id();
|
||||||
store.append(session_id, segment_id, &entry)
|
store.append(session_id, segment_id, &entry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ pub enum LogEntry {
|
|||||||
/// preceding Invoke and a later RunResumed or terminal record.
|
/// preceding Invoke and a later RunResumed or terminal record.
|
||||||
RunYielded {
|
RunYielded {
|
||||||
ts: u64,
|
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,
|
reason: RunYieldReason,
|
||||||
active_run_turn_count: usize,
|
active_run_turn_count: usize,
|
||||||
},
|
},
|
||||||
@@ -149,19 +155,27 @@ pub enum LogEntry {
|
|||||||
/// It deliberately does not assert that a subsequent model call occurred.
|
/// It deliberately does not assert that a subsequent model call occurred.
|
||||||
RunResumed {
|
RunResumed {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
|
||||||
source: RunResumeSource,
|
source: RunResumeSource,
|
||||||
active_run_turn_count: usize,
|
active_run_turn_count: usize,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// The logical Run ended because the user intentionally cancelled it.
|
/// The logical Run ended because the user intentionally cancelled it.
|
||||||
/// This is terminal, but is not an execution failure.
|
/// 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` で終了した。
|
/// `run()` / `resume()` が `EngineError` で終了した。
|
||||||
/// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。
|
/// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。
|
||||||
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
||||||
RunErrored {
|
RunErrored {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
|
||||||
interrupted: bool,
|
interrupted: bool,
|
||||||
message: String,
|
message: String,
|
||||||
/// Older records omit this field. New records distinguish failures in
|
/// Older records omit this field. New records distinguish failures in
|
||||||
@@ -236,6 +250,48 @@ pub struct SegmentOrigin {
|
|||||||
pub at_turn_index: usize,
|
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.
|
/// State collected from log entries.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RestoredState {
|
pub struct RestoredState {
|
||||||
@@ -878,6 +934,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
LogEntry::RunYielded {
|
LogEntry::RunYielded {
|
||||||
ts: 300,
|
ts: 300,
|
||||||
|
entry_id: None,
|
||||||
reason: RunYieldReason::Compaction,
|
reason: RunYieldReason::Compaction,
|
||||||
active_run_turn_count: 2,
|
active_run_turn_count: 2,
|
||||||
},
|
},
|
||||||
@@ -888,6 +945,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
LogEntry::RunResumed {
|
LogEntry::RunResumed {
|
||||||
ts: 500,
|
ts: 500,
|
||||||
|
entry_id: None,
|
||||||
source: RunResumeSource::Compaction,
|
source: RunResumeSource::Compaction,
|
||||||
active_run_turn_count: 2,
|
active_run_turn_count: 2,
|
||||||
},
|
},
|
||||||
@@ -909,7 +967,10 @@ mod tests {
|
|||||||
ts: 200,
|
ts: 200,
|
||||||
turn_count: 1,
|
turn_count: 1,
|
||||||
},
|
},
|
||||||
LogEntry::RunCancelled { ts: 300 },
|
LogEntry::RunCancelled {
|
||||||
|
ts: 300,
|
||||||
|
entry_id: None,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert!(!state.last_run_interrupted);
|
assert!(!state.last_run_interrupted);
|
||||||
|
|||||||
@@ -157,6 +157,16 @@ pub enum SystemItem {
|
|||||||
prompt_provenance: Option<PromptRenderProvenance>,
|
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
|
/// Synthetic note inserted after an interrupted turn before the next
|
||||||
/// user input. `body` is the exact LLM-context text explaining that the
|
/// user input. `body` is the exact LLM-context text explaining that the
|
||||||
/// previous turn was cut short.
|
/// previous turn was cut short.
|
||||||
@@ -181,6 +191,7 @@ impl SystemItem {
|
|||||||
format!("Ignored legacy procedure item: /{slug}")
|
format!("Ignored legacy procedure item: /{slug}")
|
||||||
}
|
}
|
||||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||||
|
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +213,7 @@ impl SystemItem {
|
|||||||
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
||||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
|
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||||
SystemItem::Interrupt { .. } => "interrupt",
|
SystemItem::Interrupt { .. } => "interrupt",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ pub(crate) struct RetainedLogRecord {
|
|||||||
|
|
||||||
pub(crate) struct RetainedSegmentReader {
|
pub(crate) struct RetainedSegmentReader {
|
||||||
file: File,
|
file: File,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
read_end: u64,
|
read_end: u64,
|
||||||
buffer_start: u64,
|
buffer_start: u64,
|
||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
@@ -103,7 +105,12 @@ impl RetainedSegmentReader {
|
|||||||
message: "empty retained Segment log record".to_string(),
|
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((
|
return Ok((
|
||||||
Some(RetainedLogRecord {
|
Some(RetainedLogRecord {
|
||||||
start_offset: record_start,
|
start_offset: record_start,
|
||||||
@@ -255,7 +262,10 @@ impl WorkerSessionStore {
|
|||||||
if byte_len > max_bytes {
|
if byte_len > max_bytes {
|
||||||
return Err(StoreError::ReadLimitExceeded);
|
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(
|
pub(crate) fn read_first_log_record_read_only_bounded(
|
||||||
@@ -264,6 +274,7 @@ impl WorkerSessionStore {
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
max_bytes: u64,
|
max_bytes: u64,
|
||||||
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
|
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
|
||||||
|
let record_start = 0;
|
||||||
self.validate_retained_session(session_id)?;
|
self.validate_retained_session(session_id)?;
|
||||||
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
|
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
|
||||||
if error.kind() == std::io::ErrorKind::NotFound {
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
@@ -289,7 +300,8 @@ impl WorkerSessionStore {
|
|||||||
message: "empty retained Segment log record".to_string(),
|
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((
|
Ok((
|
||||||
Some(RetainedLogRecord {
|
Some(RetainedLogRecord {
|
||||||
start_offset: 0,
|
start_offset: 0,
|
||||||
@@ -308,6 +320,7 @@ impl WorkerSessionStore {
|
|||||||
start_offset: u64,
|
start_offset: u64,
|
||||||
max_bytes: u64,
|
max_bytes: u64,
|
||||||
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
|
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
|
||||||
|
let record_start = start_offset;
|
||||||
self.validate_retained_session(session_id)?;
|
self.validate_retained_session(session_id)?;
|
||||||
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
|
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
|
||||||
if error.kind() == std::io::ErrorKind::NotFound {
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
@@ -334,7 +347,8 @@ impl WorkerSessionStore {
|
|||||||
message: "empty retained Segment log record".to_string(),
|
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((
|
Ok((
|
||||||
Some(RetainedLogRecord {
|
Some(RetainedLogRecord {
|
||||||
start_offset,
|
start_offset,
|
||||||
@@ -353,6 +367,7 @@ impl WorkerSessionStore {
|
|||||||
end_offset: u64,
|
end_offset: u64,
|
||||||
max_bytes: u64,
|
max_bytes: u64,
|
||||||
) -> Result<RetainedLogRecord, StoreError> {
|
) -> Result<RetainedLogRecord, StoreError> {
|
||||||
|
let record_start = start_offset;
|
||||||
self.validate_retained_session(session_id)?;
|
self.validate_retained_session(session_id)?;
|
||||||
let record_len =
|
let record_len =
|
||||||
end_offset
|
end_offset
|
||||||
@@ -387,7 +402,8 @@ impl WorkerSessionStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
record.pop();
|
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 {
|
Ok(RetainedLogRecord {
|
||||||
start_offset,
|
start_offset,
|
||||||
end_offset,
|
end_offset,
|
||||||
@@ -429,6 +445,8 @@ impl WorkerSessionStore {
|
|||||||
}
|
}
|
||||||
Ok(RetainedSegmentReader {
|
Ok(RetainedSegmentReader {
|
||||||
file,
|
file,
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
read_end: before_offset,
|
read_end: before_offset,
|
||||||
buffer_start: before_offset,
|
buffer_start: before_offset,
|
||||||
buffer: Vec::new(),
|
buffer: Vec::new(),
|
||||||
@@ -450,6 +468,39 @@ impl WorkerSessionStore {
|
|||||||
Ok(())
|
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> {
|
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
||||||
Ok(fs::metadata(self.log_path(segment_id))?.len())
|
Ok(fs::metadata(self.log_path(segment_id))?.len())
|
||||||
}
|
}
|
||||||
@@ -645,7 +696,7 @@ impl Store for WorkerSessionStore {
|
|||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(StoreError::NotFound(segment_id));
|
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> {
|
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||||
@@ -1154,6 +1205,38 @@ fn complete_jsonl_prefix(content: &[u8]) -> &[u8] {
|
|||||||
.unwrap_or(&[])
|
.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> {
|
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
|
||||||
let complete = complete_jsonl_prefix(content);
|
let complete = complete_jsonl_prefix(content);
|
||||||
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
|
||||||
|
|||||||
+185
-8
@@ -247,6 +247,9 @@ pub struct App {
|
|||||||
/// Latest session context tokens reported by the Worker. This is the raw
|
/// Latest session context tokens reported by the Worker. This is the raw
|
||||||
/// `input_tokens` value and is independent from per-run upload totals.
|
/// `input_tokens` value and is independent from per-run upload totals.
|
||||||
pub session_context_tokens: u64,
|
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 context_window: u64,
|
||||||
pub turn_index: usize,
|
pub turn_index: usize,
|
||||||
pub current_tool: Option<String>,
|
pub current_tool: Option<String>,
|
||||||
@@ -352,6 +355,7 @@ impl App {
|
|||||||
run_upload_tokens: 0,
|
run_upload_tokens: 0,
|
||||||
run_output_tokens: 0,
|
run_output_tokens: 0,
|
||||||
session_context_tokens: 0,
|
session_context_tokens: 0,
|
||||||
|
session_context_source: None,
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
turn_index: 0,
|
turn_index: 0,
|
||||||
current_tool: None,
|
current_tool: None,
|
||||||
@@ -1403,7 +1407,12 @@ impl App {
|
|||||||
output_tokens,
|
output_tokens,
|
||||||
cache_read_input_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
|
// Subtract the cache-hit portion so a tool loop that
|
||||||
// re-sends the same prefix on every request doesn't
|
// re-sends the same prefix on every request doesn't
|
||||||
// re-count it. cache_creation stays in (it is full
|
// re-count it. cache_creation stays in (it is full
|
||||||
@@ -1414,6 +1423,14 @@ impl App {
|
|||||||
self.run_upload_tokens += net_input;
|
self.run_upload_tokens += net_input;
|
||||||
self.run_output_tokens += output_tokens.unwrap_or(0);
|
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 } => {
|
Event::Error { code, message } => {
|
||||||
self.handle_error(code, message);
|
self.handle_error(code, message);
|
||||||
}
|
}
|
||||||
@@ -1475,7 +1492,6 @@ impl App {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.active_compaction = None;
|
self.active_compaction = None;
|
||||||
self.session_context_tokens = 0;
|
|
||||||
let new_segment_id = lifecycle
|
let new_segment_id = lifecycle
|
||||||
.new_segment_id
|
.new_segment_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -2337,8 +2353,12 @@ impl App {
|
|||||||
) -> InternalWorkerView {
|
) -> InternalWorkerView {
|
||||||
let mut app = App::new(snapshot.worker.name.clone());
|
let mut app = App::new(snapshot.worker.name.clone());
|
||||||
app.mode = mode;
|
app.mode = mode;
|
||||||
|
if let Some(greeting) = snapshot.greeting {
|
||||||
|
app.restore_snapshot(&snapshot.session, greeting, snapshot.in_flight);
|
||||||
|
} else {
|
||||||
app.restore_session(&snapshot.session, None);
|
app.restore_session(&snapshot.session, None);
|
||||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||||
|
}
|
||||||
app.set_worker_status(snapshot.status);
|
app.set_worker_status(snapshot.status);
|
||||||
if let Some(error) = snapshot.error {
|
if let Some(error) = snapshot.error {
|
||||||
let _ = app.handle_worker_event(Event::Error {
|
let _ = app.handle_worker_event(Event::Error {
|
||||||
@@ -2424,7 +2444,14 @@ impl App {
|
|||||||
) {
|
) {
|
||||||
self.greeting = Some(greeting.clone());
|
self.greeting = Some(greeting.clone());
|
||||||
self.context_window = greeting.context_window;
|
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.restore_session(session, Some(greeting));
|
||||||
self.apply_in_flight_snapshot(in_flight);
|
self.apply_in_flight_snapshot(in_flight);
|
||||||
}
|
}
|
||||||
@@ -2441,10 +2468,16 @@ impl App {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let greeting = greeting.map(|mut greeting| {
|
||||||
|
greeting.context_tokens = 0;
|
||||||
|
greeting.context_usage = None;
|
||||||
|
greeting
|
||||||
|
});
|
||||||
if let Some(greeting) = greeting.clone() {
|
if let Some(greeting) = greeting.clone() {
|
||||||
self.greeting = Some(greeting.clone());
|
self.greeting = Some(greeting.clone());
|
||||||
self.context_window = greeting.context_window;
|
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();
|
let missing_greeting = greeting.is_none();
|
||||||
self.restore_session(session, greeting);
|
self.restore_session(session, greeting);
|
||||||
@@ -2584,6 +2617,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::SkillActivation { body, .. }
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
|
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { 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!(!blocks_contain(&app, "old post-target output"));
|
||||||
assert_eq!(composer_text(&app), "selected rewind input");
|
assert_eq!(composer_text(&app), "selected rewind input");
|
||||||
assert!(blocks_contain(&app, "Rewound session"));
|
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]
|
#[test]
|
||||||
@@ -2945,6 +2982,8 @@ mod rewind_refresh_tests {
|
|||||||
tools: vec![],
|
tools: vec![],
|
||||||
context_window: 100,
|
context_window: 100,
|
||||||
context_tokens: 10,
|
context_tokens: 10,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3800,6 +3839,7 @@ mod completion_flow_tests {
|
|||||||
|
|
||||||
let run_errored = session_store::LogEntry::RunErrored {
|
let run_errored = session_store::LogEntry::RunErrored {
|
||||||
ts: 3,
|
ts: 3,
|
||||||
|
entry_id: None,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
message: "provider unavailable".into(),
|
message: "provider unavailable".into(),
|
||||||
failure: None,
|
failure: None,
|
||||||
@@ -4019,6 +4059,7 @@ mod completion_flow_tests {
|
|||||||
},
|
},
|
||||||
revision,
|
revision,
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
|
greeting: None,
|
||||||
session: protocol::SessionSnapshot {
|
session: protocol::SessionSnapshot {
|
||||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||||
entries: Vec::new(),
|
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]
|
#[test]
|
||||||
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
|
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
|
||||||
let mut app = App::new("parent".into());
|
let mut app = App::new("parent".into());
|
||||||
@@ -4307,6 +4418,7 @@ mod completion_flow_tests {
|
|||||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
},
|
},
|
||||||
|
greeting: None,
|
||||||
status: WorkerStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
error: None,
|
error: None,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -4503,15 +4615,21 @@ mod completion_flow_tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_initializes_context_usage() {
|
fn snapshot_prefers_typed_context_usage_over_legacy_mirror() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let mut greeting = test_greeting();
|
let mut greeting = test_greeting();
|
||||||
greeting.context_window = 123_000;
|
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 {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
session: protocol::SessionSnapshot {
|
session: protocol::SessionSnapshot {
|
||||||
@@ -4526,6 +4644,36 @@ mod completion_flow_tests {
|
|||||||
|
|
||||||
assert_eq!(app.context_window, 123_000);
|
assert_eq!(app.context_window, 123_000);
|
||||||
assert_eq!(app.session_context_tokens, 45_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]
|
#[test]
|
||||||
@@ -4539,10 +4687,39 @@ mod completion_flow_tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(app.session_context_tokens, 42_000);
|
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_upload_tokens, 2_000);
|
||||||
assert_eq!(app.run_output_tokens, 9);
|
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]
|
#[test]
|
||||||
fn memory_worker_event_updates_actionbar_state() {
|
fn memory_worker_event_updates_actionbar_state() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
@@ -4564,7 +4741,7 @@ mod completion_flow_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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());
|
let mut app = App::new("test".into());
|
||||||
app.session_context_tokens = 42_000;
|
app.session_context_tokens = 42_000;
|
||||||
|
|
||||||
@@ -4572,7 +4749,7 @@ mod completion_flow_tests {
|
|||||||
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||||
|
|
||||||
assert_eq!(app.session_context_tokens, 0);
|
assert_eq!(app.session_context_tokens, 42_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use ratatui::style::{Modifier, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::{self, IsTerminal, Write};
|
use std::io::{self, BufRead, IsTerminal, Write};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
type PickerResult<T> = Result<T, Box<dyn Error>>;
|
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>> {
|
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||||
println!("Create Workspace (leave display name empty to cancel)");
|
let stdin = io::stdin();
|
||||||
let display_name = prompt_line("Workspace display name: ")?;
|
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() {
|
if display_name.is_empty() {
|
||||||
return Ok(None);
|
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() {
|
if uri.is_empty() {
|
||||||
println!("Repository path/URI is required.");
|
writeln!(output, "Repository path/URI is required.")?;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let repository_key = prompt_line("Repository key [main]: ")?;
|
let repository_key = loop {
|
||||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
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!(
|
let operation_key = format!(
|
||||||
"tui-workspace-create-{}-{}",
|
"tui-workspace-create-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
@@ -204,27 +236,43 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
|||||||
display_name,
|
display_name,
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
uri,
|
uri,
|
||||||
repository_key: if repository_key.is_empty() {
|
repository_key,
|
||||||
"main".to_string()
|
|
||||||
} else {
|
|
||||||
repository_key
|
|
||||||
},
|
|
||||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_line(prompt: &str) -> PickerResult<String> {
|
fn prompt_line(
|
||||||
print!("{prompt}");
|
input: &mut impl BufRead,
|
||||||
io::stdout().flush()?;
|
output: &mut impl Write,
|
||||||
|
prompt: &str,
|
||||||
|
) -> PickerResult<Option<String>> {
|
||||||
|
write!(output, "{prompt}")?;
|
||||||
|
output.flush()?;
|
||||||
let mut value = String::new();
|
let mut value = String::new();
|
||||||
io::stdin().read_line(&mut value)?;
|
if input.read_line(&mut value)? == 0 {
|
||||||
Ok(value.trim().to_string())
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if value.ends_with('\n') {
|
||||||
|
value.pop();
|
||||||
|
if value.ends_with('\r') {
|
||||||
|
value.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
||||||
@@ -237,4 +285,58 @@ mod tests {
|
|||||||
WorkspacePickerAction::Cancel
|
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(),
|
cwd: String::new(),
|
||||||
provider: "retained session".to_string(),
|
provider: "retained session".to_string(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
|
reasoning: None,
|
||||||
scope_summary: "read-only retained session".to_string(),
|
scope_summary: "read-only retained session".to_string(),
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: protocol::WorkerStateSnapshot {
|
state: protocol::WorkerStateSnapshot {
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
@@ -654,10 +656,12 @@ async fn run_e2e_rewind_fixture(
|
|||||||
cwd: workspace_root.display().to_string(),
|
cwd: workspace_root.display().to_string(),
|
||||||
provider: "e2e-fixture".to_string(),
|
provider: "e2e-fixture".to_string(),
|
||||||
model: "canned".to_string(),
|
model: "canned".to_string(),
|
||||||
|
reasoning: None,
|
||||||
scope_summary: "isolated e2e rewind fixture".to_string(),
|
scope_summary: "isolated e2e rewind fixture".to_string(),
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2784,10 +2788,12 @@ mod tests {
|
|||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
provider: "test".into(),
|
provider: "test".into(),
|
||||||
model: "test".into(),
|
model: "test".into(),
|
||||||
|
reasoning: None,
|
||||||
scope_summary: "".into(),
|
scope_summary: "".into(),
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 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 {
|
fn context_usage_text(app: &App) -> String {
|
||||||
let pct = if app.context_window == 0 {
|
let Some(source) = app.session_context_source else {
|
||||||
0
|
return "Context unavailable".to_string();
|
||||||
} else {
|
|
||||||
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64
|
|
||||||
};
|
};
|
||||||
|
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!(
|
format!(
|
||||||
"{} / {} ({}%)",
|
"{}{} / {} ({}%)",
|
||||||
|
estimate,
|
||||||
fmt_tokens(app.session_context_tokens),
|
fmt_tokens(app.session_context_tokens),
|
||||||
fmt_tokens(app.context_window),
|
fmt_tokens(app.context_window),
|
||||||
pct
|
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]
|
#[test]
|
||||||
fn run_elapsed_uses_console_style_units() {
|
fn run_elapsed_uses_console_style_units() {
|
||||||
assert_eq!(fmt_run_elapsed(9), "9s");
|
assert_eq!(fmt_run_elapsed(9), "9s");
|
||||||
|
|||||||
@@ -298,6 +298,10 @@ pub struct CreateWorkerRequest {
|
|||||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
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.
|
/// Last persisted Worker lifecycle status.
|
||||||
|
|||||||
@@ -1334,7 +1334,6 @@ async fn runtime_protocol_ws_session(
|
|||||||
request_id,
|
request_id,
|
||||||
subscription_id: subscription_id.clone(),
|
subscription_id: subscription_id.clone(),
|
||||||
selector: selector.clone(),
|
selector: selector.clone(),
|
||||||
snapshot_revision: subscription.snapshot_revision(),
|
|
||||||
snapshot: subscription.snapshot().clone(),
|
snapshot: subscription.snapshot().clone(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -1354,7 +1353,6 @@ async fn runtime_protocol_ws_session(
|
|||||||
SubscriptionFramePayload::Event(
|
SubscriptionFramePayload::Event(
|
||||||
SubscriptionEvent::Event {
|
SubscriptionEvent::Event {
|
||||||
subscription_id: event_subscription_id.clone(),
|
subscription_id: event_subscription_id.clone(),
|
||||||
subject_revision: update.subject_revision,
|
|
||||||
payload: update.payload,
|
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 {
|
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
||||||
return Some("workers: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 {
|
if path.starts_with("/v1/workers/") && *method == Method::GET {
|
||||||
return Some("workers:read");
|
return Some("workers:read");
|
||||||
}
|
}
|
||||||
@@ -3101,6 +3102,7 @@ mod tests {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3994,6 +3996,8 @@ mod ws_tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: protocol::WorkerStateSnapshot::initial(),
|
state: protocol::WorkerStateSnapshot::initial(),
|
||||||
in_flight: protocol::InFlightSnapshot {
|
in_flight: protocol::InFlightSnapshot {
|
||||||
@@ -4078,6 +4082,7 @@ mod ws_tests {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
|||||||
.map_err(api_error)
|
.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(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
value: runtime_api::CreateWorkerRequest,
|
value: runtime_api::CreateWorkerRequest,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ pub mod resource;
|
|||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
mod session_public;
|
||||||
pub mod ssh_host_key_probe;
|
pub mod ssh_host_key_probe;
|
||||||
pub mod worker_backend;
|
pub mod worker_backend;
|
||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
@@ -36,5 +38,8 @@ pub mod workspace_request;
|
|||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||||
pub use management::RuntimeOptions;
|
pub use management::RuntimeOptions;
|
||||||
pub use runtime::{Runtime, RuntimeWorkspaceScope};
|
pub use runtime::{
|
||||||
|
Runtime, RuntimeEventSelectorSubscription, RuntimeSubscriptionRecvError,
|
||||||
|
RuntimeSubscriptionUpdate, RuntimeWorkspaceScope,
|
||||||
|
};
|
||||||
pub use session_store::UploadedFileUploadContext;
|
pub use session_store::UploadedFileUploadContext;
|
||||||
|
|||||||
@@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider {
|
|||||||
.join(archive_id))
|
.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> {
|
fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
|
||||||
validate_id("operation_id", operation_id)?;
|
validate_id("operation_id", operation_id)?;
|
||||||
Ok(self
|
Ok(self
|
||||||
|
|||||||
@@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256;
|
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_CODE: &str = "worker_delete_persistence_failed";
|
||||||
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
|
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
|
||||||
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
|
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
|
||||||
@@ -151,7 +186,6 @@ impl RuntimeWorkerRestoreResult {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RuntimeSubscriptionUpdate {
|
pub struct RuntimeSubscriptionUpdate {
|
||||||
pub subject_revision: u64,
|
|
||||||
pub payload: SubscriptionEventPayload,
|
pub payload: SubscriptionEventPayload,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +202,6 @@ pub enum RuntimeSubscriptionRecvError {
|
|||||||
pub struct RuntimeEventSelectorSubscription {
|
pub struct RuntimeEventSelectorSubscription {
|
||||||
subscription_id: u64,
|
subscription_id: u64,
|
||||||
selector: EventSubscriptionSelector,
|
selector: EventSubscriptionSelector,
|
||||||
snapshot_revision: u64,
|
|
||||||
snapshot: SubscriptionSnapshot,
|
snapshot: SubscriptionSnapshot,
|
||||||
receiver: mpsc::Receiver<RuntimeSubscriptionUpdate>,
|
receiver: mpsc::Receiver<RuntimeSubscriptionUpdate>,
|
||||||
lagged: Arc<AtomicBool>,
|
lagged: Arc<AtomicBool>,
|
||||||
@@ -180,10 +213,6 @@ impl RuntimeEventSelectorSubscription {
|
|||||||
&self.selector
|
&self.selector
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot_revision(&self) -> u64 {
|
|
||||||
self.snapshot_revision
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot(&self) -> &SubscriptionSnapshot {
|
pub fn snapshot(&self) -> &SubscriptionSnapshot {
|
||||||
&self.snapshot
|
&self.snapshot
|
||||||
}
|
}
|
||||||
@@ -1280,7 +1309,6 @@ impl Runtime {
|
|||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
}
|
}
|
||||||
let snapshot = state.subscription_snapshot(scope, &selector)?;
|
let snapshot = state.subscription_snapshot(scope, &selector)?;
|
||||||
let snapshot_revision = state.subscription_revision;
|
|
||||||
let subscription_id = state.next_event_subscription_id;
|
let subscription_id = state.next_event_subscription_id;
|
||||||
state.next_event_subscription_id = state.next_event_subscription_id.saturating_add(1);
|
state.next_event_subscription_id = state.next_event_subscription_id.saturating_add(1);
|
||||||
let (sender, receiver) = mpsc::channel(SUBSCRIPTION_QUEUE_CAPACITY);
|
let (sender, receiver) = mpsc::channel(SUBSCRIPTION_QUEUE_CAPACITY);
|
||||||
@@ -1298,7 +1326,6 @@ impl Runtime {
|
|||||||
Ok(RuntimeEventSelectorSubscription {
|
Ok(RuntimeEventSelectorSubscription {
|
||||||
subscription_id,
|
subscription_id,
|
||||||
selector,
|
selector,
|
||||||
snapshot_revision,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
receiver,
|
receiver,
|
||||||
lagged,
|
lagged,
|
||||||
@@ -2217,9 +2244,9 @@ impl Runtime {
|
|||||||
if let Some(payload) = input_protocol_event(&input) {
|
if let Some(payload) = input_protocol_event(&input) {
|
||||||
state.push_worker_observation_event(worker_ref.clone(), payload);
|
state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||||
}
|
}
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
|
|
||||||
Ok(WorkerInteractionAck {
|
Ok(WorkerInteractionAck {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
@@ -2500,9 +2527,9 @@ impl Runtime {
|
|||||||
worker.workdir_attachments = workdir_attachments;
|
worker.workdir_attachments = workdir_attachments;
|
||||||
worker.detail()
|
worker.detail()
|
||||||
};
|
};
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
Ok(detail)
|
Ok(detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2541,8 +2568,8 @@ impl Runtime {
|
|||||||
state
|
state
|
||||||
.observation_events
|
.observation_events
|
||||||
.retain(|event| event.worker_ref != *worker_ref);
|
.retain(|event| event.worker_ref != *worker_ref);
|
||||||
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
|
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
|
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2564,9 +2591,9 @@ impl Runtime {
|
|||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
let worker = state.worker_mut(worker_ref)?;
|
let worker = state.worker_mut(worker_ref)?;
|
||||||
worker.apply_worker_state(&snapshot);
|
worker.apply_worker_state(&snapshot);
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2870,8 +2897,8 @@ impl Runtime {
|
|||||||
state
|
state
|
||||||
.observation_events
|
.observation_events
|
||||||
.retain(|event| event.worker_ref != *worker_ref);
|
.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.persist_runtime_snapshot()?;
|
||||||
|
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
|
||||||
Ok(WorkerDeleteResult {
|
Ok(WorkerDeleteResult {
|
||||||
worker_id: removed.worker_id,
|
worker_id: removed.worker_id,
|
||||||
deleted: true,
|
deleted: true,
|
||||||
@@ -2959,13 +2986,13 @@ impl Runtime {
|
|||||||
let worker_state_changed =
|
let worker_state_changed =
|
||||||
state.project_protocol_event_to_worker_state(worker_ref, &payload);
|
state.project_protocol_event_to_worker_state(worker_ref, &payload);
|
||||||
let activity_changed = state.project_internal_worker_activity(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 {
|
if worker_state_changed {
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
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);
|
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
@@ -3021,13 +3048,13 @@ impl Runtime {
|
|||||||
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
||||||
let activity_changed =
|
let activity_changed =
|
||||||
state.project_internal_worker_activity(&worker_ref, &payload);
|
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 {
|
if worker_state_changed {
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
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))
|
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);
|
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
||||||
let activity_changed =
|
let activity_changed =
|
||||||
state.project_internal_worker_activity(&worker_ref, &payload);
|
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 {
|
if worker_state_changed {
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
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))
|
Ok(state.push_worker_observation_event(worker_ref, payload))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -3454,8 +3481,8 @@ impl Runtime {
|
|||||||
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
|
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
|
||||||
candidate.workdir_attachments = workdir_attachments;
|
candidate.workdir_attachments = workdir_attachments;
|
||||||
state.workers.insert(worker_ref.worker_id, candidate);
|
state.workers.insert(worker_ref.worker_id, candidate);
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
Ok(())
|
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
|
/// Enumerate host-authoritative Runtime inventory for Backend orphan
|
||||||
/// reconciliation. Runtime identity and Workspace scope are derived here,
|
/// reconciliation. Runtime identity and Workspace scope are derived here,
|
||||||
/// not accepted in a diagnostic payload.
|
/// not accepted in a diagnostic payload.
|
||||||
@@ -3702,8 +3846,6 @@ struct RuntimeState {
|
|||||||
workspace_config_latest: BTreeMap<String, ConfigBundleRef>,
|
workspace_config_latest: BTreeMap<String, ConfigBundleRef>,
|
||||||
workspace_config_fetch_gates: BTreeMap<String, Arc<Mutex<()>>>,
|
workspace_config_fetch_gates: BTreeMap<String, Arc<Mutex<()>>>,
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
subscription_revision: u64,
|
|
||||||
worker_subject_revisions: BTreeMap<WorkerId, u64>,
|
|
||||||
next_event_subscription_id: u64,
|
next_event_subscription_id: u64,
|
||||||
subscriptions: BTreeMap<u64, SubscriptionSink>,
|
subscriptions: BTreeMap<u64, SubscriptionSink>,
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -3733,8 +3875,6 @@ impl RuntimeState {
|
|||||||
workspace_config_latest: BTreeMap::new(),
|
workspace_config_latest: BTreeMap::new(),
|
||||||
workspace_config_fetch_gates: BTreeMap::new(),
|
workspace_config_fetch_gates: BTreeMap::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
subscription_revision: 0,
|
|
||||||
worker_subject_revisions: BTreeMap::new(),
|
|
||||||
next_event_subscription_id: 1,
|
next_event_subscription_id: 1,
|
||||||
subscriptions: BTreeMap::new(),
|
subscriptions: BTreeMap::new(),
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -3765,8 +3905,6 @@ impl RuntimeState {
|
|||||||
workspace_config_latest: BTreeMap::new(),
|
workspace_config_latest: BTreeMap::new(),
|
||||||
workspace_config_fetch_gates: BTreeMap::new(),
|
workspace_config_fetch_gates: BTreeMap::new(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
subscription_revision: 0,
|
|
||||||
worker_subject_revisions: BTreeMap::new(),
|
|
||||||
next_event_subscription_id: 1,
|
next_event_subscription_id: 1,
|
||||||
subscriptions: BTreeMap::new(),
|
subscriptions: BTreeMap::new(),
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -3895,8 +4033,6 @@ impl RuntimeState {
|
|||||||
workspace_config_fetch_gates: BTreeMap::new(),
|
workspace_config_fetch_gates: BTreeMap::new(),
|
||||||
workspace_owners: persisted.workspace_owners,
|
workspace_owners: persisted.workspace_owners,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
subscription_revision: 0,
|
|
||||||
worker_subject_revisions: BTreeMap::new(),
|
|
||||||
next_event_subscription_id: 1,
|
next_event_subscription_id: 1,
|
||||||
subscriptions: BTreeMap::new(),
|
subscriptions: BTreeMap::new(),
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -4215,11 +4351,6 @@ impl RuntimeState {
|
|||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
resource_key: None,
|
resource_key: None,
|
||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
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(),
|
worker_state: worker.worker_state.clone(),
|
||||||
state: subscription_worker_state(worker.status),
|
state: subscription_worker_state(worker.status),
|
||||||
has_running_internal_workers: worker.internal_workers.has_running_worker(),
|
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> {
|
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
|
let worker = self
|
||||||
.workers
|
.workers
|
||||||
.get(&worker_id)
|
.get(&worker_id)
|
||||||
.ok_or(RuntimeError::WorkerNotFound { worker_id })?;
|
.ok_or(RuntimeError::WorkerNotFound { worker_id })?;
|
||||||
let workspace_id = worker.workspace_id.clone();
|
let workspace_id = worker.workspace_id.clone();
|
||||||
let mut projected = self.subscription_worker(worker)?;
|
let projected = self.subscription_worker(worker)?;
|
||||||
projected.subject_revision = subject_revision;
|
|
||||||
let projected_worker_id = projected.worker_id.clone();
|
let projected_worker_id = projected.worker_id.clone();
|
||||||
self.deliver_worker_subscription_update(
|
self.deliver_worker_subscription_update(
|
||||||
&projected_worker_id,
|
&projected_worker_id,
|
||||||
workspace_id.as_deref(),
|
workspace_id.as_deref(),
|
||||||
RuntimeSubscriptionUpdate {
|
RuntimeSubscriptionUpdate {
|
||||||
subject_revision,
|
|
||||||
payload: SubscriptionEventPayload::WorkerUpserted { worker: projected },
|
payload: SubscriptionEventPayload::WorkerUpserted { worker: projected },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4262,19 +4385,12 @@ impl RuntimeState {
|
|||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
workspace_id: Option<&str>,
|
workspace_id: Option<&str>,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> 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())
|
let worker_id = SubscriptionWorkerId::new(worker_id.to_string())
|
||||||
.map_err(subscription_validation_error)?;
|
.map_err(subscription_validation_error)?;
|
||||||
self.deliver_worker_subscription_update(
|
self.deliver_worker_subscription_update(
|
||||||
&worker_id,
|
&worker_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
RuntimeSubscriptionUpdate {
|
RuntimeSubscriptionUpdate {
|
||||||
subject_revision,
|
|
||||||
payload: SubscriptionEventPayload::WorkerRemoved {
|
payload: SubscriptionEventPayload::WorkerRemoved {
|
||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
@@ -4417,8 +4533,8 @@ impl RuntimeState {
|
|||||||
worker_ref: Some(worker_ref.clone()),
|
worker_ref: Some(worker_ref.clone()),
|
||||||
});
|
});
|
||||||
self.workers.insert(worker_ref.worker_id, candidate);
|
self.workers.insert(worker_ref.worker_id, candidate);
|
||||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
|
||||||
self.persist_runtime_snapshot()?;
|
self.persist_runtime_snapshot()?;
|
||||||
|
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5520,6 +5636,7 @@ mod tests {
|
|||||||
worker,
|
worker,
|
||||||
revision,
|
revision,
|
||||||
status,
|
status,
|
||||||
|
greeting: None,
|
||||||
session: protocol::SessionSnapshot {
|
session: protocol::SessionSnapshot {
|
||||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
@@ -5545,6 +5662,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: protocol::WorkerStatus::Idle.into(),
|
state: protocol::WorkerStatus::Idle.into(),
|
||||||
in_flight: protocol::InFlightSnapshot::default(),
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
@@ -5883,6 +6002,7 @@ mod tests {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6647,7 +6767,6 @@ mod tests {
|
|||||||
let mut subscription = runtime
|
let mut subscription = runtime
|
||||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(subscription.snapshot_revision(), 0);
|
|
||||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||||
panic!("runtime_workers must return a Worker snapshot");
|
panic!("runtime_workers must return a Worker snapshot");
|
||||||
};
|
};
|
||||||
@@ -6655,11 +6774,9 @@ mod tests {
|
|||||||
|
|
||||||
let created = runtime.create_worker(task_request("live")).unwrap();
|
let created = runtime.create_worker(task_request("live")).unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 1);
|
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||||
assert_eq!(worker.subject_revision, 1);
|
|
||||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
}
|
}
|
||||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
@@ -6676,7 +6793,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 2);
|
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
@@ -6695,7 +6811,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 3);
|
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
@@ -6737,7 +6852,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 4);
|
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
@@ -6748,7 +6862,6 @@ mod tests {
|
|||||||
|
|
||||||
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 5);
|
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||||
@@ -6760,6 +6873,87 @@ mod tests {
|
|||||||
runtime.stop_runtime().unwrap();
|
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]
|
#[test]
|
||||||
fn worker_lifecycle_subscription_delivers_only_selected_workers() {
|
fn worker_lifecycle_subscription_delivers_only_selected_workers() {
|
||||||
let runtime = runtime_with_backend();
|
let runtime = runtime_with_backend();
|
||||||
@@ -8108,6 +8302,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 128,
|
context_window: 128,
|
||||||
context_tokens: 64,
|
context_tokens: 64,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: protocol::WorkerStatus::Running.into(),
|
state: protocol::WorkerStatus::Running.into(),
|
||||||
in_flight: protocol::InFlightSnapshot {
|
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,
|
request: &CreateWorkerRequest,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(snapshot) = request.memory_settings.as_ref() else {
|
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(
|
return Err(
|
||||||
"Workspace Worker request is missing its bound Memory settings snapshot"
|
"Workspace Worker request is missing its bound Memory settings snapshot"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -960,16 +960,35 @@ fn bind_workspace_memory_settings(
|
|||||||
snapshot.workspace_id, workspace_api.workspace_id
|
snapshot.workspace_id, workspace_api.workspace_id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if manifest.feature.memory.profile.enabled {
|
||||||
manifest
|
manifest
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.bind_workspace_settings(snapshot.clone())
|
.bind_workspace_settings(snapshot.clone())
|
||||||
.map_err(str::to_string)?;
|
.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
|
manifest
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
|
manifest
|
||||||
|
.feature
|
||||||
|
.subjektiv
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(str::to_string)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,6 +997,12 @@ fn validate_worker_memory_settings(
|
|||||||
request: &CreateWorkerRequest,
|
request: &CreateWorkerRequest,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(expected) = request.memory_settings.as_ref() else {
|
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(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
manifest
|
manifest
|
||||||
@@ -985,9 +1010,14 @@ fn validate_worker_memory_settings(
|
|||||||
.memory
|
.memory
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
if !manifest.feature.memory.profile.enabled {
|
manifest
|
||||||
return Ok(());
|
.feature
|
||||||
}
|
.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
|
let actual = manifest
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
@@ -995,7 +1025,6 @@ fn validate_worker_memory_settings(
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||||
})?;
|
})?;
|
||||||
if &actual != expected {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||||
expected.workspace_id,
|
expected.workspace_id,
|
||||||
@@ -1004,6 +1033,33 @@ fn validate_worker_memory_settings(
|
|||||||
actual.settings_revision
|
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(())
|
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 workspace_client = worker.workspace_client_handle();
|
||||||
let started = prepared.start().await.map_err(|error| match error {
|
let started = prepared.start().await.map_err(|error| match error {
|
||||||
WorkerBootstrapError::Worker(source) => {
|
WorkerBootstrapError::Worker(source) => {
|
||||||
@@ -4391,9 +4443,70 @@ mod tests {
|
|||||||
worker_observation_grants: Vec::new(),
|
worker_observation_grants: Vec::new(),
|
||||||
workspace_api: None,
|
workspace_api: None,
|
||||||
memory_settings: 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]) {
|
fn git(path: &std::path::Path, args: &[&str]) {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -4499,6 +4612,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 1_000,
|
context_window: 1_000,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
hub.workers.lock().unwrap().insert(
|
hub.workers.lock().unwrap().insert(
|
||||||
|
|||||||
@@ -564,6 +564,30 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
.await,
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+296
-11
@@ -151,7 +151,7 @@ impl WorkerHandle {
|
|||||||
.snapshot();
|
.snapshot();
|
||||||
let event = Event::Snapshot {
|
let event = Event::Snapshot {
|
||||||
session,
|
session,
|
||||||
greeting: self.shared_state.greeting.clone(),
|
greeting: self.shared_state.greeting(),
|
||||||
state: self.shared_state.snapshot(),
|
state: self.shared_state.snapshot(),
|
||||||
in_flight,
|
in_flight,
|
||||||
internal_workers: self.spawned_registry.internal_worker_snapshots(),
|
internal_workers: self.spawned_registry.internal_worker_snapshots(),
|
||||||
@@ -269,6 +269,17 @@ async fn set_controller_state(
|
|||||||
snapshot
|
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(
|
async fn set_controller_status(
|
||||||
shared_state: &Arc<WorkerSharedState>,
|
shared_state: &Arc<WorkerSharedState>,
|
||||||
runtime_dir: &RuntimeDir,
|
runtime_dir: &RuntimeDir,
|
||||||
@@ -303,6 +314,12 @@ async fn finish_controller_run<C, St>(
|
|||||||
// the terminal run boundary so reconnect snapshots cannot append stale
|
// the terminal run boundary so reconnect snapshots cannot append stale
|
||||||
// partial text/tool arguments after newer entries.
|
// partial text/tool arguments after newer entries.
|
||||||
worker.clear_in_flight_events();
|
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;
|
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.
|
// the actual registered set instead of a hand-maintained mirror.
|
||||||
worker.engine().tool_server_handle().flush_pending();
|
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 +
|
// === 4. Initial runtime files + WorkerSharedState + WorkerHandle +
|
||||||
// SocketServer ===
|
// SocketServer ===
|
||||||
let manifest_toml = toml::to_string_pretty(worker.manifest()).unwrap_or_default();
|
let manifest_toml = toml::to_string_pretty(worker.manifest()).unwrap_or_default();
|
||||||
@@ -781,6 +811,16 @@ impl WorkerController {
|
|||||||
manifest_toml.clone(),
|
manifest_toml.clone(),
|
||||||
greeting,
|
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 {
|
if let Some(fs_for_view) = fs_for_view {
|
||||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(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.
|
// 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,
|
/// Register the builtin file-manipulation tools, optional memory tools,
|
||||||
/// and the Worker-orchestration tools (SubWorkerSpawn + comm) on the Worker's
|
/// and the Worker-orchestration tools (SubWorkerSpawn + comm) on the Worker's
|
||||||
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
|
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
|
||||||
@@ -1250,28 +1306,68 @@ 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(
|
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
||||||
worker.manifest(),
|
worker.manifest(),
|
||||||
worker.workspace_client_handle(),
|
worker.workspace_client_handle(),
|
||||||
worker.prompts().load_full(),
|
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.resident_summary_source.clone(),
|
||||||
plan.system_prompt_override.clone(),
|
plan.system_prompt_override.clone(),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let memory_lifecycle_config = memory_install_plan
|
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
|
||||||
.as_ref()
|
|
||||||
.map(|plan| plan.resolved_config.clone());
|
|
||||||
if let Some(plan) = memory_install_plan {
|
if let Some(plan) = memory_install_plan {
|
||||||
feature_registry.add_module(plan.module);
|
feature_registry.add_module(plan.module);
|
||||||
}
|
}
|
||||||
if let Some(memory_config) = memory_lifecycle_config
|
let subjektiv_consolidation_plan =
|
||||||
&& let Some(memory_lifecycle) =
|
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(
|
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||||
worker.manifest_lifecycle_features_enabled(),
|
worker.manifest_lifecycle_features_enabled(),
|
||||||
memory_config,
|
memory_lifecycle_config,
|
||||||
worker.committed_session_capture_handle(),
|
worker.committed_session_capture_handle(),
|
||||||
worker.session_extension_handle(),
|
worker.session_extension_handle(),
|
||||||
worker.workspace_client_handle(),
|
worker.workspace_client_handle(),
|
||||||
@@ -1284,6 +1380,33 @@ where
|
|||||||
{
|
{
|
||||||
feature_registry.add_module(memory_lifecycle);
|
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(),
|
||||||
|
worker.manifest().feature.subjektiv.clone(),
|
||||||
|
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(subjektiv_lifecycle);
|
||||||
|
}
|
||||||
if sub_worker_enabled && !worker_enabled {
|
if sub_worker_enabled && !worker_enabled {
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
@@ -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.install_system_prompt_contribution(resident_summary, system_prompt_override);
|
||||||
}
|
}
|
||||||
worker.attach_tracker(tracker);
|
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!(
|
if !matches!(
|
||||||
result,
|
result,
|
||||||
Err(WorkerError::Store(_))
|
Err(WorkerError::Store(_))
|
||||||
@@ -2510,6 +2639,12 @@ async fn controller_loop<C, St>(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
worker.clear_in_flight_events();
|
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 snapshot = shared_state.transition(WorkerState::Idle);
|
||||||
let _ = working_event_tx.send(Event::WorkerState { snapshot });
|
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
|
where
|
||||||
C: LlmClient + 'static,
|
C: LlmClient + 'static,
|
||||||
St: Store,
|
St: Store,
|
||||||
@@ -3360,6 +3512,7 @@ where
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|def| def.name)
|
.map(|def| def.name)
|
||||||
.collect();
|
.collect();
|
||||||
|
let context_tokens = worker.total_tokens().tokens;
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
worker_name: manifest.worker.name.clone(),
|
worker_name: manifest.worker.name.clone(),
|
||||||
cwd: worker
|
cwd: worker
|
||||||
@@ -3368,10 +3521,19 @@ where
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
provider: provider_name,
|
provider: provider_name,
|
||||||
model: model_id,
|
model: model_id,
|
||||||
|
reasoning: manifest
|
||||||
|
.engine
|
||||||
|
.reasoning
|
||||||
|
.as_ref()
|
||||||
|
.map(public_reasoning_config),
|
||||||
scope_summary: worker.scope_snapshot().summary(),
|
scope_summary: worker.scope_snapshot().summary(),
|
||||||
tools: tool_names,
|
tools: tool_names,
|
||||||
context_window,
|
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 tempfile::TempDir;
|
||||||
use tokio::net::UnixListener;
|
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]
|
#[test]
|
||||||
fn no_controller_parent_notification_uses_durable_pending_authority() {
|
fn no_controller_parent_notification_uses_durable_pending_authority() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -3551,6 +3830,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
let notify_buffer = NotifyBuffer::new();
|
let notify_buffer = NotifyBuffer::new();
|
||||||
@@ -3600,6 +3881,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -4308,6 +4591,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 1,
|
context_window: 1,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1507,6 +1507,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -1543,6 +1545,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -1634,6 +1638,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -1661,6 +1667,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -1765,6 +1773,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Paused.into(),
|
state: WorkerStatus::Paused.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -1820,6 +1830,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ pub mod manage_worker;
|
|||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub(crate) mod memory_lifecycle;
|
pub(crate) mod memory_lifecycle;
|
||||||
pub mod memory_staging_output;
|
pub mod memory_staging_output;
|
||||||
|
pub(crate) mod memory_surface_lifecycle;
|
||||||
|
pub(crate) mod memory_surface_output;
|
||||||
pub mod merge_request;
|
pub mod merge_request;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod orchestration;
|
pub mod orchestration;
|
||||||
mod resource_projection;
|
mod resource_projection;
|
||||||
pub mod session_explore;
|
pub mod session_explore;
|
||||||
|
pub(crate) mod subjektiv_memory;
|
||||||
|
pub(crate) mod subjektiv_session;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
pub mod worker_observation;
|
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>,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
source: SourceRef,
|
source: SourceRef,
|
||||||
extract_run_id: String,
|
extract_run_id: String,
|
||||||
|
destination: MemoryStagingDestination,
|
||||||
staged: Arc<Mutex<Vec<String>>>,
|
staged: Arc<Mutex<Vec<String>>>,
|
||||||
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum MemoryStagingDestination {
|
||||||
|
WorkspaceMemory,
|
||||||
|
Subjektiv { session_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
impl MemoryStagingOutputState {
|
impl MemoryStagingOutputState {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
view: SessionCapture,
|
view: SessionCapture,
|
||||||
@@ -49,6 +56,25 @@ impl MemoryStagingOutputState {
|
|||||||
workspace_client,
|
workspace_client,
|
||||||
source,
|
source,
|
||||||
extract_run_id,
|
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())),
|
staged: Arc::new(Mutex::new(Vec::new())),
|
||||||
finished: Arc::new(Mutex::new(None)),
|
finished: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -201,22 +227,27 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
staleness: params.staleness,
|
staleness: params.staleness,
|
||||||
evidence_ids: params.entry_refs,
|
evidence_ids: params.entry_refs,
|
||||||
};
|
};
|
||||||
let result = self
|
let operation = MemoryStageCandidateOperation {
|
||||||
.state
|
|
||||||
.workspace_client
|
|
||||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
|
||||||
MemoryStageCandidateOperation {
|
|
||||||
source: self.state.source.clone(),
|
source: self.state.source.clone(),
|
||||||
extract_run_id: self.state.extract_run_id.clone(),
|
extract_run_id: self.state.extract_run_id.clone(),
|
||||||
candidate,
|
candidate,
|
||||||
evidence,
|
evidence,
|
||||||
source_refs,
|
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
|
.await
|
||||||
.map_err(map_memory_stage_error)?;
|
.map_err(map_memory_stage_error)?;
|
||||||
let staging_ids = match result {
|
match result {
|
||||||
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
|
MemoryBackendOperationResult::StagingWritten(output)
|
||||||
|
if output.staging_count == 1 =>
|
||||||
|
{
|
||||||
output.staging_ids
|
output.staging_ids
|
||||||
}
|
}
|
||||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||||
@@ -230,6 +261,39 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
"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(|| {
|
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
|
||||||
ToolError::ExecutionFailed(
|
ToolError::ExecutionFailed(
|
||||||
@@ -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 {
|
StagingEvidence {
|
||||||
id: entry.entry_ref.to_string(),
|
id: entry.entry_ref.to_string(),
|
||||||
kind: evidence_kind(entry),
|
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 {
|
SourceEvidenceRef {
|
||||||
segment_id: Some(entry.segment_id.clone()),
|
segment_id: Some(entry.segment_id.clone()),
|
||||||
entry_range: Some(entry.entry_range),
|
entry_range: Some(entry.entry_range),
|
||||||
@@ -425,10 +489,45 @@ fn truncate_line(text: &str, max_chars: usize) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
|
|
||||||
use super::*;
|
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 {
|
fn state() -> MemoryStagingOutputState {
|
||||||
MemoryStagingOutputState::new(
|
MemoryStagingOutputState::new(
|
||||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||||
@@ -503,6 +602,39 @@ mod tests {
|
|||||||
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
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]
|
#[tokio::test]
|
||||||
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
||||||
let tool = StageMemoryCandidateTool { state: state() };
|
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.
|
/// Monotonic committed-log revision for the captured Segment.
|
||||||
pub(crate) session_revision: u64,
|
pub(crate) session_revision: u64,
|
||||||
pub(crate) entry_count: usize,
|
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) run_exit: CommittedRunExit,
|
||||||
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||||
pub(crate) usage_history: Vec<UsageRecord>,
|
pub(crate) usage_history: Vec<UsageRecord>,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! need durable domain audit must keep using their domain authority (for example Memory audit).
|
//! need durable domain audit must keep using their domain authority (for example Memory audit).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
use agen::timeline::event::UsageEvent;
|
use agen::timeline::event::UsageEvent;
|
||||||
use agen::{Engine, EngineError, llm_client::LlmClient};
|
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 tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
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::feature::FeatureRegistryBuilder;
|
||||||
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
||||||
use crate::ipc::alerter::Alerter;
|
use crate::ipc::alerter::Alerter;
|
||||||
@@ -409,6 +411,7 @@ enum InternalWorkerSessionCommand {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct InternalWorkerSessionSnapshot {
|
pub(crate) struct InternalWorkerSessionSnapshot {
|
||||||
pub session: protocol::SessionSnapshot,
|
pub session: protocol::SessionSnapshot,
|
||||||
|
pub greeting: Option<protocol::Greeting>,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub in_flight: InFlightSnapshot,
|
pub in_flight: InFlightSnapshot,
|
||||||
@@ -428,6 +431,7 @@ pub(crate) struct InternalWorkerSessionHandle {
|
|||||||
visibility: InternalWorkerVisibility,
|
visibility: InternalWorkerVisibility,
|
||||||
last_error: Arc<Mutex<Option<String>>>,
|
last_error: Arc<Mutex<Option<String>>>,
|
||||||
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
|
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
|
||||||
|
greeting: Option<Arc<RwLock<protocol::Greeting>>>,
|
||||||
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
||||||
sink: SegmentLogSink,
|
sink: SegmentLogSink,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -481,6 +485,12 @@ impl InternalWorkerSessionHandle {
|
|||||||
};
|
};
|
||||||
InternalWorkerSessionSnapshot {
|
InternalWorkerSessionSnapshot {
|
||||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
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() {
|
status: match self.status() {
|
||||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
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(
|
pub(crate) async fn prepare_internal_worker_session(
|
||||||
mut worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
|
mut worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
|
||||||
store: EphemeralSessionStore,
|
store: EphemeralSessionStore,
|
||||||
@@ -851,8 +878,20 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
worker.attach_alerter(alerter.clone());
|
worker.attach_alerter(alerter.clone());
|
||||||
worker.attach_working_event_tx(event_tx.clone());
|
worker.attach_working_event_tx(event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.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);
|
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 session_id = worker.session_id();
|
||||||
let segment_id = worker.segment_id();
|
let segment_id = worker.segment_id();
|
||||||
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8);
|
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8);
|
||||||
@@ -874,6 +913,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
visibility,
|
visibility,
|
||||||
last_error: last_error.clone(),
|
last_error: last_error.clone(),
|
||||||
last_outcome: last_outcome.clone(),
|
last_outcome: last_outcome.clone(),
|
||||||
|
greeting: Some(greeting),
|
||||||
child_registry,
|
child_registry,
|
||||||
sink,
|
sink,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -886,29 +926,13 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
match command {
|
match command {
|
||||||
InternalWorkerSessionCommand::Run(input) => {
|
InternalWorkerSessionCommand::Run(input) => {
|
||||||
actor_in_flight.clear();
|
actor_in_flight.clear();
|
||||||
|
let (turn_status, error, outcome) = {
|
||||||
let cancel_sender = worker.engine_mut().cancel_sender();
|
let cancel_sender = worker.engine_mut().cancel_sender();
|
||||||
let mut run = std::pin::pin!(worker.run_text(&input));
|
let mut run = std::pin::pin!(worker.run_text(&input));
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = &mut run => {
|
result = &mut run => {
|
||||||
let (turn_status, error, outcome) =
|
break classify_internal_turn_result(result);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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() => {
|
command = command_rx.recv() => {
|
||||||
match command {
|
match command {
|
||||||
@@ -930,6 +954,28 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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) => {
|
InternalWorkerSessionCommand::Stop(done) => {
|
||||||
stop_done = Some(done);
|
stop_done = Some(done);
|
||||||
@@ -1174,6 +1220,14 @@ impl session_store::WorkerMetadataStore for EphemeralSessionStore {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn test_internal_worker_session(
|
pub(crate) fn test_internal_worker_session(
|
||||||
visibility: InternalWorkerVisibility,
|
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>) {
|
) -> (InternalWorkerSessionHandle, broadcast::Sender<Event>) {
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
@@ -1206,6 +1260,7 @@ pub(crate) fn test_internal_worker_session(
|
|||||||
visibility,
|
visibility,
|
||||||
last_error: Arc::new(Mutex::new(None)),
|
last_error: Arc::new(Mutex::new(None)),
|
||||||
last_outcome: Arc::new(Mutex::new(None)),
|
last_outcome: Arc::new(Mutex::new(None)),
|
||||||
|
greeting: greeting.map(|greeting| Arc::new(RwLock::new(greeting))),
|
||||||
child_registry: None,
|
child_registry: None,
|
||||||
sink,
|
sink,
|
||||||
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
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);
|
assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1);
|
||||||
let entries_after_first = handle.entries().len();
|
let entries_after_first = handle.entries().len();
|
||||||
assert!(entries_after_first >= 4);
|
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");
|
handle.send("follow-up").await.expect("send follow-up turn");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
handle.wait_until_idle().await,
|
handle.wait_until_idle().await,
|
||||||
InternalWorkerSessionStatus::Idle
|
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!(handle.protocol_snapshot().in_flight.blocks.is_empty());
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
assert!(handle.entries().len() > entries_after_first);
|
assert!(handle.entries().len() > entries_after_first);
|
||||||
|
|||||||
@@ -16,9 +16,23 @@ pub struct WorkerProtocolSessionStreams {
|
|||||||
pub events: broadcast::Receiver<Event>,
|
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 {
|
pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams {
|
||||||
let (snapshot_event, log_entries) = handle.snapshot_event_with_entry_subscription();
|
let ((snapshot_event, log_entries), (alert_snapshot, events)) = subscribe_before_snapshot(
|
||||||
let (alert_snapshot, events) = handle.alerter.subscribe_with_snapshot();
|
|| handle.alerter.subscribe_with_snapshot(),
|
||||||
|
|| handle.snapshot_event_with_entry_subscription(),
|
||||||
|
);
|
||||||
WorkerProtocolSessionStreams {
|
WorkerProtocolSessionStreams {
|
||||||
snapshot_event,
|
snapshot_event,
|
||||||
alert_snapshot,
|
alert_snapshot,
|
||||||
@@ -117,11 +131,158 @@ pub async fn dispatch_worker_protocol_method(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn durable_run_transition_maps_to_session_entry_committed() {
|
fn durable_run_transition_maps_to_session_entry_committed() {
|
||||||
let event = live_log_entry_event(LogEntry::RunYielded {
|
let event = live_log_entry_event(LogEntry::RunYielded {
|
||||||
ts: 42,
|
ts: 42,
|
||||||
|
entry_id: Some(session_store::LoggedSessionHistoryEntryId::new()),
|
||||||
reason: protocol::RunYieldReason::Compaction,
|
reason: protocol::RunYieldReason::Compaction,
|
||||||
active_run_turn_count: 3,
|
active_run_turn_count: 3,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ pub enum WorkerPrompt {
|
|||||||
CompactSystem,
|
CompactSystem,
|
||||||
MemoryExtractSystem,
|
MemoryExtractSystem,
|
||||||
MemoryConsolidationSystem,
|
MemoryConsolidationSystem,
|
||||||
|
SubjektivMemoryConsolidationSystem,
|
||||||
|
SubjektivMemorySurfaceSystem,
|
||||||
FlowVerifierSystem,
|
FlowVerifierSystem,
|
||||||
BackendJobSystem,
|
BackendJobSystem,
|
||||||
NotifyWrapper,
|
NotifyWrapper,
|
||||||
@@ -102,6 +104,7 @@ pub enum WorkerPrompt {
|
|||||||
WorkingBoundariesSection,
|
WorkingBoundariesSection,
|
||||||
AgentsMdSection,
|
AgentsMdSection,
|
||||||
ResidentMemorySummarySection,
|
ResidentMemorySummarySection,
|
||||||
|
ResidentMemoryRestoreSection,
|
||||||
WorkerOrchestrationGuidanceSection,
|
WorkerOrchestrationGuidanceSection,
|
||||||
SubWorkerSpawnToolDescription,
|
SubWorkerSpawnToolDescription,
|
||||||
}
|
}
|
||||||
@@ -112,6 +115,10 @@ impl WorkerPrompt {
|
|||||||
Self::CompactSystem => "internal.compact_system",
|
Self::CompactSystem => "internal.compact_system",
|
||||||
Self::MemoryExtractSystem => "internal.memory_extract_system",
|
Self::MemoryExtractSystem => "internal.memory_extract_system",
|
||||||
Self::MemoryConsolidationSystem => "internal.memory_consolidation_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::FlowVerifierSystem => "internal.flow_verifier_system",
|
||||||
Self::BackendJobSystem => "internal.backend_job_system",
|
Self::BackendJobSystem => "internal.backend_job_system",
|
||||||
Self::NotifyWrapper => "internal.notify_wrapper",
|
Self::NotifyWrapper => "internal.notify_wrapper",
|
||||||
@@ -120,6 +127,7 @@ impl WorkerPrompt {
|
|||||||
Self::WorkingBoundariesSection => "internal.working_boundaries_section",
|
Self::WorkingBoundariesSection => "internal.working_boundaries_section",
|
||||||
Self::AgentsMdSection => "internal.agents_md_section",
|
Self::AgentsMdSection => "internal.agents_md_section",
|
||||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||||
|
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||||
Self::WorkerOrchestrationGuidanceSection => {
|
Self::WorkerOrchestrationGuidanceSection => {
|
||||||
"internal.worker_orchestration_guidance_section"
|
"internal.worker_orchestration_guidance_section"
|
||||||
}
|
}
|
||||||
@@ -131,6 +139,8 @@ impl WorkerPrompt {
|
|||||||
WorkerPrompt::CompactSystem,
|
WorkerPrompt::CompactSystem,
|
||||||
WorkerPrompt::MemoryExtractSystem,
|
WorkerPrompt::MemoryExtractSystem,
|
||||||
WorkerPrompt::MemoryConsolidationSystem,
|
WorkerPrompt::MemoryConsolidationSystem,
|
||||||
|
WorkerPrompt::SubjektivMemoryConsolidationSystem,
|
||||||
|
WorkerPrompt::SubjektivMemorySurfaceSystem,
|
||||||
WorkerPrompt::FlowVerifierSystem,
|
WorkerPrompt::FlowVerifierSystem,
|
||||||
WorkerPrompt::BackendJobSystem,
|
WorkerPrompt::BackendJobSystem,
|
||||||
WorkerPrompt::NotifyWrapper,
|
WorkerPrompt::NotifyWrapper,
|
||||||
@@ -139,6 +149,7 @@ impl WorkerPrompt {
|
|||||||
WorkerPrompt::WorkingBoundariesSection,
|
WorkerPrompt::WorkingBoundariesSection,
|
||||||
WorkerPrompt::AgentsMdSection,
|
WorkerPrompt::AgentsMdSection,
|
||||||
WorkerPrompt::ResidentMemorySummarySection,
|
WorkerPrompt::ResidentMemorySummarySection,
|
||||||
|
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||||
];
|
];
|
||||||
@@ -449,6 +460,21 @@ impl PromptCatalog {
|
|||||||
single("language", language),
|
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> {
|
pub fn flow_verifier_system(&self) -> Result<String, CatalogError> {
|
||||||
self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED)
|
self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED)
|
||||||
}
|
}
|
||||||
@@ -479,6 +505,19 @@ impl PromptCatalog {
|
|||||||
single("summary", summary),
|
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> {
|
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||||
self.render(
|
self.render(
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
@@ -913,6 +952,23 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("LANGUAGE_MARKER")
|
.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!(
|
assert!(
|
||||||
catalog
|
catalog
|
||||||
.notify_wrapper("NOTIFICATION_MARKER")
|
.notify_wrapper("NOTIFICATION_MARKER")
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ mod tests {
|
|||||||
// Durable logical-Run transitions share the committed-entry lane.
|
// Durable logical-Run transitions share the committed-entry lane.
|
||||||
sink.publish(LogEntry::RunResumed {
|
sink.publish(LogEntry::RunResumed {
|
||||||
ts: now_millis(),
|
ts: now_millis(),
|
||||||
|
entry_id: None,
|
||||||
source: protocol::RunResumeSource::Compaction,
|
source: protocol::RunResumeSource::Compaction,
|
||||||
active_run_turn_count: 1,
|
active_run_turn_count: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
|
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
|
||||||
//! range/search, read, and generic evidence projections without granting mutation authority.
|
//! range/search, read, and generic evidence projections without granting mutation authority.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||||
@@ -207,6 +208,14 @@ pub(crate) struct ReadResult {
|
|||||||
pub truncated: bool,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct SessionEntryEvidence {
|
pub(crate) struct SessionEntryEvidence {
|
||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
@@ -600,6 +609,76 @@ impl SessionCapture {
|
|||||||
ReadResult { entries, truncated }
|
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> {
|
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
|
||||||
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
|
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
|
||||||
let excerpt = self
|
let excerpt = self
|
||||||
@@ -1028,4 +1107,20 @@ mod tests {
|
|||||||
assert_eq!(source.entry_range, [0, 0]);
|
assert_eq!(source.entry_range, [0, 0]);
|
||||||
assert_eq!(source.entry_ref.as_str(), "E00000000");
|
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 worker_name: String,
|
||||||
pub segment_id: SegmentId,
|
pub segment_id: SegmentId,
|
||||||
pub manifest_toml: String,
|
pub manifest_toml: String,
|
||||||
pub greeting: protocol::Greeting,
|
greeting: RwLock<protocol::Greeting>,
|
||||||
state: RwLock<WorkerStateSnapshot>,
|
state: RwLock<WorkerStateSnapshot>,
|
||||||
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
|
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
|
||||||
/// Worker-from-the-inside view of the filesystem. Set once in
|
/// Worker-from-the-inside view of the filesystem. Set once in
|
||||||
@@ -62,7 +62,7 @@ impl WorkerSharedState {
|
|||||||
worker_name,
|
worker_name,
|
||||||
segment_id,
|
segment_id,
|
||||||
manifest_toml,
|
manifest_toml,
|
||||||
greeting,
|
greeting: RwLock::new(greeting),
|
||||||
state: RwLock::new(WorkerStateSnapshot::initial()),
|
state: RwLock::new(WorkerStateSnapshot::initial()),
|
||||||
accepted_commands: RwLock::new(VecDeque::new()),
|
accepted_commands: RwLock::new(VecDeque::new()),
|
||||||
fs_view: OnceLock::new(),
|
fs_view: OnceLock::new(),
|
||||||
@@ -90,6 +90,22 @@ impl WorkerSharedState {
|
|||||||
self.flow_transition_enabled.load(Ordering::Acquire)
|
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 {
|
pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot {
|
||||||
let mut snapshot = self
|
let mut snapshot = self
|
||||||
.state
|
.state
|
||||||
@@ -245,9 +261,32 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 0,
|
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]
|
#[test]
|
||||||
fn initial_snapshot_is_idle() {
|
fn initial_snapshot_is_idle() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ mod tests {
|
|||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
|
reasoning: None,
|
||||||
|
context_usage: None,
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ use tokio::sync::{Notify, broadcast};
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use workdir::WorkdirScopeLeaseSet;
|
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::dir::{RuntimeDir, SpawnedWorkerRecord};
|
||||||
use crate::runtime::worker_allocation;
|
use crate::runtime::worker_allocation;
|
||||||
|
|
||||||
@@ -648,6 +650,14 @@ impl SpawnedWorkerRegistry {
|
|||||||
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||||
let protocol_terminal = record.protocol_terminal.clone();
|
let protocol_terminal = record.protocol_terminal.clone();
|
||||||
let mut child_rx = record.session.subscribe_events();
|
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 {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match child_rx.recv().await {
|
match child_rx.recv().await {
|
||||||
@@ -710,6 +720,14 @@ impl SpawnedWorkerRegistry {
|
|||||||
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||||
let protocol_terminal = record.protocol_terminal.clone();
|
let protocol_terminal = record.protocol_terminal.clone();
|
||||||
let mut child_rx = record.session.subscribe_events();
|
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 {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match child_rx.recv().await {
|
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(
|
fn internal_worker_snapshot(
|
||||||
worker: InternalWorkerRef,
|
worker: InternalWorkerRef,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
@@ -995,6 +1048,7 @@ fn internal_worker_snapshot(
|
|||||||
worker,
|
worker,
|
||||||
revision,
|
revision,
|
||||||
session: snapshot.session,
|
session: snapshot.session,
|
||||||
|
greeting: snapshot.greeting,
|
||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
error: snapshot.error,
|
error: snapshot.error,
|
||||||
in_flight: snapshot.in_flight,
|
in_flight: snapshot.in_flight,
|
||||||
@@ -1121,7 +1175,10 @@ mod tests {
|
|||||||
use session_store::LogEntry;
|
use session_store::LogEntry;
|
||||||
|
|
||||||
use super::*;
|
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> {
|
fn registry() -> Arc<SpawnedWorkerRegistry> {
|
||||||
let scope = Scope::from_config(&ScopeConfig {
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
@@ -1141,7 +1198,15 @@ mod tests {
|
|||||||
name: &str,
|
name: &str,
|
||||||
visibility: InternalWorkerVisibility,
|
visibility: InternalWorkerVisibility,
|
||||||
) -> (InternalSpawnedWorkerRecord, broadcast::Sender<Event>) {
|
) -> (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 root = std::path::PathBuf::from("/tmp");
|
||||||
let scope = Scope::from_config(&ScopeConfig {
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
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]
|
#[tokio::test]
|
||||||
async fn visible_internal_output_is_wrapped_after_registry_insertion() {
|
async fn visible_internal_output_is_wrapped_after_registry_insertion() {
|
||||||
let registry = registry();
|
let registry = registry();
|
||||||
|
|||||||
@@ -925,13 +925,7 @@ fn bind_child_memory_settings(
|
|||||||
parent: &manifest::WorkerManifest,
|
parent: &manifest::WorkerManifest,
|
||||||
child: &mut manifest::WorkerManifest,
|
child: &mut manifest::WorkerManifest,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !child.feature.memory.profile.enabled {
|
if child.feature.memory.profile.enabled {
|
||||||
return child
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.validate_execution()
|
|
||||||
.map_err(str::to_string);
|
|
||||||
}
|
|
||||||
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
||||||
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
||||||
.to_string()
|
.to_string()
|
||||||
@@ -941,10 +935,21 @@ fn bind_child_memory_settings(
|
|||||||
.memory
|
.memory
|
||||||
.bind_workspace_settings(workspace_settings)
|
.bind_workspace_settings(workspace_settings)
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
|
}
|
||||||
child
|
child
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.validate_execution()
|
.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
|
||||||
|
.subjektiv
|
||||||
|
.validate_execution()
|
||||||
.map_err(str::to_string)
|
.map_err(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,7 +1297,7 @@ enabled = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 runtime = TempDir::new().unwrap();
|
||||||
let workspace_root = runtime.path().join("project");
|
let workspace_root = runtime.path().join("project");
|
||||||
let bash_output_dir = runtime.path().join("bash-output");
|
let bash_output_dir = runtime.path().join("bash-output");
|
||||||
@@ -1310,6 +1315,17 @@ enabled = false
|
|||||||
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
||||||
deny: Vec::new(),
|
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 spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
|
||||||
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
||||||
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
|
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
|
||||||
@@ -1359,7 +1375,7 @@ enabled = false
|
|||||||
}));
|
}));
|
||||||
let input = serde_json::json!({
|
let input = serde_json::json!({
|
||||||
"name": "reviewer-child",
|
"name": "reviewer-child",
|
||||||
"profile": "project:reviewer",
|
"profile": "builtin:reviewer",
|
||||||
"instruction": "role.reviewer",
|
"instruction": "role.reviewer",
|
||||||
"task": "review immutable commit",
|
"task": "review immutable commit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
@@ -1409,6 +1425,14 @@ enabled = false
|
|||||||
record.installed_tools
|
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!(
|
assert!(
|
||||||
!record
|
!record
|
||||||
.installed_tools
|
.installed_tools
|
||||||
@@ -1881,14 +1905,22 @@ enabled = false
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let mut parent = parent_manifest(temp.path(), None);
|
let mut parent = parent_manifest(temp.path(), None);
|
||||||
parent.feature.memory.profile.enabled = true;
|
parent.feature.memory.profile.enabled = true;
|
||||||
parent
|
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
|
||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
settings_revision: 4,
|
settings_revision: 4,
|
||||||
language: "日本語".to_string(),
|
language: "日本語".to_string(),
|
||||||
})
|
};
|
||||||
|
parent
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.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();
|
.unwrap();
|
||||||
let mut child = parent.clone();
|
let mut child = parent.clone();
|
||||||
child.feature.memory.workspace_settings = None;
|
child.feature.memory.workspace_settings = None;
|
||||||
@@ -1898,6 +1930,15 @@ enabled = false
|
|||||||
child.feature.memory.workspace_settings(),
|
child.feature.memory.workspace_settings(),
|
||||||
parent.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;
|
child.feature.memory.profile.enabled = false;
|
||||||
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
|
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::interceptor::WorkerInterceptor;
|
||||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||||
use crate::prompt::agents_md::read_agents_md;
|
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::source::PromptCatalogSource;
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::runtime::dir;
|
use crate::runtime::dir;
|
||||||
@@ -624,6 +626,10 @@ pub enum WorkspaceServerOperation {
|
|||||||
},
|
},
|
||||||
WorkerObservationSessions,
|
WorkerObservationSessions,
|
||||||
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
|
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>(
|
fn workspace_server_json_request<T: serde::Serialize>(
|
||||||
@@ -702,6 +708,18 @@ fn workspace_server_operation_request(
|
|||||||
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
||||||
workspace_server_json_request(format!("{base}/worker-observation/session"), &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
|
/// Append `entry` to the log: disk write → counter bump → in-memory
|
||||||
/// mirror push → broadcast. The Store owns physical write ordering and
|
/// mirror push → broadcast. The Store owns physical write ordering and
|
||||||
/// partial-write recovery; publication happens only after it returns Ok.
|
/// 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
|
let _append_guard = self
|
||||||
.state
|
.state
|
||||||
.append_lock
|
.append_lock
|
||||||
@@ -2322,9 +2341,15 @@ impl WorkerSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum SystemPromptContribution {
|
||||||
|
Ready(String),
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
||||||
async fn load(&self) -> Option<String>;
|
async fn load(&self) -> SystemPromptContribution;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -2488,6 +2513,8 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
inject_resident_summary: bool,
|
inject_resident_summary: bool,
|
||||||
/// Deferred resident prompt source installed by an enabled Feature.
|
/// Deferred resident prompt source installed by an enabled Feature.
|
||||||
feature_resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
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.
|
/// Complete system prompt replacement installed by an enabled Feature.
|
||||||
feature_system_prompt_override: Option<String>,
|
feature_system_prompt_override: Option<String>,
|
||||||
/// Typed user submissions in submit order. K-th entry corresponds to
|
/// 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,
|
prompts,
|
||||||
inject_resident_summary: true,
|
inject_resident_summary: true,
|
||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
|
restore_resident_summary_refresh_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
sink: SegmentLogSink::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
|
/// Append `entry` to the session log AND publish it through the
|
||||||
/// broadcast sink. The Store is the commit boundary: a failed write is
|
/// broadcast sink. The Store is the commit boundary: a failed write is
|
||||||
/// never counted or published.
|
/// 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();
|
let loc = self.segment_state.location();
|
||||||
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
||||||
self.segment_state.increment_entries();
|
self.segment_state.increment_entries();
|
||||||
@@ -3463,6 +3492,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
let entries = store
|
let entries = store
|
||||||
.read_all(location.session_id, location.segment_id)
|
.read_all(location.session_id, location.segment_id)
|
||||||
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
|
.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
|
let run_exit = entries
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
@@ -3489,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
segment_id: location.segment_id.to_string(),
|
segment_id: location.segment_id.to_string(),
|
||||||
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
|
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
|
||||||
entry_count: entries.len(),
|
entry_count: entries.len(),
|
||||||
|
has_committed_run,
|
||||||
run_exit,
|
run_exit,
|
||||||
history,
|
history,
|
||||||
usage_history: restored.usage_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,
|
/// Render the manifest-supplied instruction template exactly once,
|
||||||
/// just before the first LLM turn, append the fixed trailing
|
/// just before the first LLM turn, append the fixed trailing
|
||||||
/// section (scope summary + optional AGENTS.md), and hand the
|
/// 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
|
/// Subsequent invocations are no-ops: the template field is
|
||||||
/// consumed with `Option::take()`, so the materialised value
|
/// consumed with `Option::take()`, so the materialised value
|
||||||
/// persists across all later turns and compaction.
|
/// 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 {
|
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() {
|
if let Some(rendered) = self.feature_system_prompt_override.take() {
|
||||||
self.engine
|
self.engine
|
||||||
@@ -3849,7 +3948,14 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
let resident_summary = if self.inject_resident_summary {
|
let resident_summary = if self.inject_resident_summary {
|
||||||
match &self.feature_resident_summary_source {
|
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,
|
None => None,
|
||||||
}
|
}
|
||||||
} else {
|
} 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))
|
.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.
|
/// Materialize the initial durable session head without running the model.
|
||||||
///
|
///
|
||||||
/// Runtime-managed Workers call this before creation is accepted so even a
|
/// 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");
|
.expect("pause/yield resume must retain the logical-run turn budget");
|
||||||
self.commit_entry(LogEntry::RunResumed {
|
self.commit_entry(LogEntry::RunResumed {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
source,
|
source,
|
||||||
active_run_turn_count,
|
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.
|
// marked as already delivered to clients through the session-log sink.
|
||||||
self.commit_entry(LogEntry::RunErrored {
|
self.commit_entry(LogEntry::RunErrored {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
message,
|
message,
|
||||||
failure: Some(RunFailureKind::Compaction),
|
failure: Some(RunFailureKind::Compaction),
|
||||||
@@ -5423,6 +5535,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
{
|
{
|
||||||
self.commit_entry(LogEntry::RunErrored {
|
self.commit_entry(LogEntry::RunErrored {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
failure: Some(RunFailureKind::Compaction),
|
failure: Some(RunFailureKind::Compaction),
|
||||||
@@ -5446,6 +5559,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
EngineRunExit::Yielded => {
|
EngineRunExit::Yielded => {
|
||||||
self.commit_entry(LogEntry::RunYielded {
|
self.commit_entry(LogEntry::RunYielded {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
reason: RunYieldReason::Compaction,
|
reason: RunYieldReason::Compaction,
|
||||||
active_run_turn_count: active_run_turn_count
|
active_run_turn_count: active_run_turn_count
|
||||||
.expect("yielded logical run must retain its turn budget"),
|
.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) => {
|
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => {
|
||||||
self.commit_entry(LogEntry::RunCancelled {
|
self.commit_entry(LogEntry::RunCancelled {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
EngineRunExit::Interrupted(reason) => {
|
EngineRunExit::Interrupted(reason) => {
|
||||||
self.commit_entry(LogEntry::RunErrored {
|
self.commit_entry(LogEntry::RunErrored {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
interrupted,
|
interrupted,
|
||||||
message: run_interruption_reason_message(reason),
|
message: run_interruption_reason_message(reason),
|
||||||
failure: Some(RunFailureKind::Engine),
|
failure: Some(RunFailureKind::Engine),
|
||||||
@@ -6537,6 +6653,7 @@ where
|
|||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
inject_resident_summary: true,
|
inject_resident_summary: true,
|
||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
|
restore_resident_summary_refresh_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
sink: SegmentLogSink::new(),
|
sink: SegmentLogSink::new(),
|
||||||
@@ -6626,6 +6743,7 @@ where
|
|||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
inject_resident_summary: true,
|
inject_resident_summary: true,
|
||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
|
restore_resident_summary_refresh_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
sink: SegmentLogSink::new(),
|
sink: SegmentLogSink::new(),
|
||||||
@@ -6750,6 +6868,7 @@ where
|
|||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
inject_resident_summary: true,
|
inject_resident_summary: true,
|
||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
|
restore_resident_summary_refresh_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
sink: SegmentLogSink::new(),
|
sink: SegmentLogSink::new(),
|
||||||
@@ -7131,6 +7250,7 @@ where
|
|||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
inject_resident_summary: true,
|
inject_resident_summary: true,
|
||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
|
restore_resident_summary_refresh_pending: true,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
user_segments: state.user_segments,
|
user_segments: state.user_segments,
|
||||||
// Seed the mirror with the entries we just replayed so a
|
// Seed the mirror with the entries we just replayed so a
|
||||||
@@ -8690,13 +8810,13 @@ mod build_summary_prompt_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
struct TestSystemPromptContributionSource {
|
struct TestSystemPromptContributionSource {
|
||||||
value: Option<String>,
|
value: SystemPromptContribution,
|
||||||
load_count: Arc<AtomicUsize>,
|
load_count: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl SystemPromptContributionSource for TestSystemPromptContributionSource {
|
impl SystemPromptContributionSource for TestSystemPromptContributionSource {
|
||||||
async fn load(&self) -> Option<String> {
|
async fn load(&self) -> SystemPromptContribution {
|
||||||
self.load_count.fetch_add(1, Ordering::SeqCst);
|
self.load_count.fetch_add(1, Ordering::SeqCst);
|
||||||
self.value.clone()
|
self.value.clone()
|
||||||
}
|
}
|
||||||
@@ -8708,7 +8828,10 @@ mod build_summary_prompt_tests {
|
|||||||
let load_count = Arc::new(AtomicUsize::new(0));
|
let load_count = Arc::new(AtomicUsize::new(0));
|
||||||
(
|
(
|
||||||
Arc::new(TestSystemPromptContributionSource {
|
Arc::new(TestSystemPromptContributionSource {
|
||||||
value,
|
value: value.map_or(
|
||||||
|
SystemPromptContribution::Unavailable,
|
||||||
|
SystemPromptContribution::Ready,
|
||||||
|
),
|
||||||
load_count: Arc::clone(&load_count),
|
load_count: Arc::clone(&load_count),
|
||||||
}),
|
}),
|
||||||
load_count,
|
load_count,
|
||||||
@@ -11063,6 +11186,191 @@ mod build_summary_prompt_tests {
|
|||||||
assert!(worker.history().is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn memory_consolidation_prompt_uses_bound_workspace_language() {
|
async fn memory_consolidation_prompt_uses_bound_workspace_language() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -11430,6 +11738,7 @@ mod build_summary_prompt_tests {
|
|||||||
worker
|
worker
|
||||||
.commit_entry(LogEntry::RunErrored {
|
.commit_entry(LogEntry::RunErrored {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
interrupted: true,
|
interrupted: true,
|
||||||
message: "provider failed".to_string(),
|
message: "provider failed".to_string(),
|
||||||
failure: Some(RunFailureKind::Engine),
|
failure: Some(RunFailureKind::Engine),
|
||||||
@@ -11460,6 +11769,7 @@ mod build_summary_prompt_tests {
|
|||||||
worker
|
worker
|
||||||
.commit_entry(LogEntry::RunCancelled {
|
.commit_entry(LogEntry::RunCancelled {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -11470,6 +11780,7 @@ mod build_summary_prompt_tests {
|
|||||||
worker
|
worker
|
||||||
.commit_entry(LogEntry::RunResumed {
|
.commit_entry(LogEntry::RunResumed {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
|
entry_id: None,
|
||||||
source: RunResumeSource::Compaction,
|
source: RunResumeSource::Compaction,
|
||||||
active_run_turn_count: 1,
|
active_run_turn_count: 1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1037,25 +1037,15 @@ permission = "write"
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
let mut resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(workspace.path())
|
.with_workspace_base(workspace.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
||||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
if resolved.manifest.feature.memory.enabled() {
|
assert!(resolved.manifest.feature.subjektiv.enabled());
|
||||||
resolved
|
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
|
||||||
.manifest
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
|
||||||
workspace_id: "workspace-test".to_string(),
|
|
||||||
settings_revision: 1,
|
|
||||||
language: "English".to_string(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let workspace_context =
|
let workspace_context =
|
||||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
||||||
let client = MockClient::new(simple_text_events());
|
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 == "WorkerRemove"));
|
||||||
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ webauthn-rs = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
|
session-store.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tools.workspace = true
|
tools.workspace = true
|
||||||
yoi = { path = "../yoi" }
|
yoi = { path = "../yoi" }
|
||||||
|
|||||||
@@ -820,11 +820,9 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.map(|worker| (assignment, worker))
|
.map(|worker| (assignment, worker))
|
||||||
})
|
})
|
||||||
.map(|(assignment, worker)| {
|
.map(|(assignment, worker)| {
|
||||||
let worker_resource_key = self.store.resource_key(
|
let worker_resource_key = self
|
||||||
&self.workspace_id,
|
.store
|
||||||
WorkspaceResourceKind::Worker,
|
.worker_resource_key(&self.workspace_id, &worker)?;
|
||||||
&worker.worker_id,
|
|
||||||
)?;
|
|
||||||
Ok::<_, Error>(TicketAssignmentSummary {
|
Ok::<_, Error>(TicketAssignmentSummary {
|
||||||
assignment_id: assignment.assignment_id.clone(),
|
assignment_id: assignment.assignment_id.clone(),
|
||||||
runtime_id: worker.runtime_id,
|
runtime_id: worker.runtime_id,
|
||||||
|
|||||||
@@ -534,6 +534,9 @@ pub struct WorkerSpawnRequest {
|
|||||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
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.
|
/// Backend-owned feature enablement; client input cannot set it.
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub resolved_worker_observation_enabled: bool,
|
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(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
_worker_id: &str,
|
_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(
|
pub fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
@@ -2964,6 +3017,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
.map_err(|error| error.to_string())
|
.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(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
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") {
|
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
|
||||||
return "workers:delete";
|
return "workers:delete";
|
||||||
}
|
}
|
||||||
|
if path.starts_with("/v1/session-public/") && method == "POST" {
|
||||||
|
return "workers:read";
|
||||||
|
}
|
||||||
if path.starts_with("/v1/workers/") && method == "GET" {
|
if path.starts_with("/v1/workers/") && method == "GET" {
|
||||||
return "workers:read";
|
return "workers:read";
|
||||||
}
|
}
|
||||||
@@ -5007,6 +5093,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| diagnostic.message)
|
.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(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -5246,6 +5356,7 @@ fn runtime_create_worker_request(
|
|||||||
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
||||||
workspace_api: Some(workspace_api),
|
workspace_api: Some(workspace_api),
|
||||||
memory_settings: request.resolved_memory_settings.clone(),
|
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";
|
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||||
pub(crate) const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-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";
|
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
|
||||||
pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-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!(
|
matches!(
|
||||||
key,
|
key,
|
||||||
MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
||||||
)
|
) || key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkerDisplayMetadata {
|
struct WorkerDisplayMetadata {
|
||||||
@@ -7030,6 +7142,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7318,6 +7431,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7420,6 +7534,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7460,6 +7575,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -753,11 +753,11 @@ CREATE TABLE "worker_control_grants" (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
revoked_at TEXT,
|
revoked_at TEXT,
|
||||||
PRIMARY KEY (workspace_id, grant_id),
|
PRIMARY KEY (workspace_id, grant_id),
|
||||||
UNIQUE (workspace_id, controller_worker_id, operation_id),
|
UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id),
|
||||||
FOREIGN KEY (workspace_id, controller_worker_id)
|
FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id)
|
||||||
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
|
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (workspace_id, subject_worker_id)
|
FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id)
|
||||||
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE
|
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE worker_create_reservations (
|
CREATE TABLE worker_create_reservations (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
@@ -832,7 +832,7 @@ CREATE TABLE "worker_registry" (
|
|||||||
diagnostics_ref TEXT,
|
diagnostics_ref TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE backend_jobs (
|
CREATE TABLE backend_jobs (
|
||||||
@@ -914,31 +914,15 @@ CREATE TABLE worker_registry_observations (
|
|||||||
worker_id TEXT NOT NULL,
|
worker_id TEXT NOT NULL,
|
||||||
availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')),
|
availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')),
|
||||||
worker_json TEXT,
|
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,
|
observed_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
||||||
FOREIGN KEY (workspace_id, worker_id)
|
FOREIGN KEY (workspace_id, runtime_id, worker_id)
|
||||||
REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE
|
REFERENCES worker_registry(workspace_id, runtime_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
|
|
||||||
);
|
);
|
||||||
CREATE TABLE worker_registry_projection_removals (
|
CREATE TABLE worker_registry_projection_removals (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
runtime_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
worker_id TEXT NOT NULL,
|
worker_id TEXT NOT NULL,
|
||||||
projection_revision INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, runtime_id, worker_id)
|
PRIMARY KEY (workspace_id, runtime_id, worker_id)
|
||||||
);
|
);
|
||||||
CREATE TABLE worker_registry_projection_diagnostics (
|
CREATE TABLE worker_registry_projection_diagnostics (
|
||||||
@@ -973,6 +957,17 @@ CREATE TABLE worker_session_archives (
|
|||||||
committed_at TEXT NOT NULL, expires_at TEXT,
|
committed_at TEXT NOT NULL, expires_at TEXT,
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
|
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 (
|
CREATE TABLE worker_tombstones (
|
||||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
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,
|
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,
|
alias TEXT NOT NULL,
|
||||||
linked_at TEXT NOT NULL,
|
linked_at TEXT NOT NULL,
|
||||||
unlinked_at TEXT,
|
unlinked_at TEXT,
|
||||||
PRIMARY KEY (workspace_id, worker_id, workdir_id, alias),
|
PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias),
|
||||||
FOREIGN KEY (workspace_id, worker_id)
|
FOREIGN KEY (workspace_id, runtime_id, worker_id)
|
||||||
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
|
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (workspace_id, workdir_id)
|
FOREIGN KEY (workspace_id, workdir_id)
|
||||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
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);
|
ON worker_workdir_attachment_reservations(workspace_id, reservation_id);
|
||||||
CREATE INDEX worker_control_grants_controller
|
CREATE INDEX worker_control_grants_controller
|
||||||
ON worker_control_grants(
|
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
|
CREATE INDEX worker_control_grants_subject
|
||||||
ON worker_control_grants(
|
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
|
CREATE INDEX worker_create_reservations_worker
|
||||||
ON worker_create_reservations(workspace_id, worker_id);
|
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)
|
ON worker_workdir_links(workspace_id, workdir_id)
|
||||||
WHERE unlinked_at IS NULL;
|
WHERE unlinked_at IS NULL;
|
||||||
CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique
|
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;
|
WHERE unlinked_at IS NULL;
|
||||||
CREATE INDEX worker_workdir_links_workdir
|
CREATE INDEX worker_workdir_links_workdir
|
||||||
ON worker_workdir_links(workspace_id, workdir_id);
|
ON worker_workdir_links(workspace_id, workdir_id);
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ pub enum Error {
|
|||||||
},
|
},
|
||||||
#[error("unknown local repository `{0}`")]
|
#[error("unknown local repository `{0}`")]
|
||||||
UnknownRepository(String),
|
UnknownRepository(String),
|
||||||
|
#[error("subjektiv subject `{0}` was not found in this Workspace")]
|
||||||
|
SubjektivSubjectNotFound(String),
|
||||||
#[error(
|
#[error(
|
||||||
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
|
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use memory::backend::{
|
use memory::backend::{
|
||||||
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
|
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
|
||||||
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
|
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
|
||||||
MemoryStagingCloseAction, MemoryStagingCloseOperation, MemoryStagingListOperation,
|
MemoryResidentSummaryAvailability, MemoryResidentSummaryOutput, MemoryStagingCloseAction,
|
||||||
MemoryStagingReadOperation, MemoryStagingWriteOutput, MemoryToolOutput,
|
MemoryStagingCloseOperation, MemoryStagingListOperation, MemoryStagingReadOperation,
|
||||||
|
MemoryStagingWriteOutput, MemoryToolOutput,
|
||||||
};
|
};
|
||||||
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
|
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
|
||||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||||
@@ -32,7 +33,7 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
|
|||||||
.map(MemoryBackendOperationResult::ToolOutput)
|
.map(MemoryBackendOperationResult::ToolOutput)
|
||||||
}
|
}
|
||||||
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
||||||
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
|
MemoryBackendOperationResult::ResidentSummary(execute_resident_summary(authority)?),
|
||||||
),
|
),
|
||||||
MemoryBackendOperation::AppendAudit(_operation) => Ok(
|
MemoryBackendOperation::AppendAudit(_operation) => Ok(
|
||||||
MemoryBackendOperationResult::Acknowledged(MemoryBackendAckOutput {
|
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()?;
|
let document = authority.ensure_memory_document()?;
|
||||||
if document.body_md.trim().is_empty() {
|
if document.body_md.trim().is_empty() {
|
||||||
Ok(MemoryToolOutput {
|
Ok(MemoryResidentSummaryOutput {
|
||||||
summary: "resident memory summary unavailable".to_string(),
|
availability: MemoryResidentSummaryAvailability::Unavailable,
|
||||||
content: None,
|
content: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Ok(MemoryToolOutput {
|
Ok(MemoryResidentSummaryOutput {
|
||||||
summary: "resident memory document collected".to_string(),
|
availability: MemoryResidentSummaryAvailability::Ready,
|
||||||
content: Some(document.body_md),
|
content: Some(document.body_md),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
|
|||||||
pub operation_id: String,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerOrphanDiagnostic {
|
pub struct WorkerOrphanDiagnostic {
|
||||||
pub diagnostic_id: String,
|
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()));}
|
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())} };
|
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])?;
|
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()));}
|
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
|
||||||
match plan.diagnostics_disposition {
|
match plan.diagnostics_disposition {
|
||||||
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
|
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>> {
|
) -> 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))
|
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(
|
fn orphan_diagnostic(
|
||||||
@@ -1170,6 +1302,32 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn purge_tombstone_commit_is_idempotent() {
|
fn purge_tombstone_commit_is_idempotent() {
|
||||||
let s = setup();
|
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| {
|
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 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')", [])?;
|
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,
|
.state,
|
||||||
WorkerRemovalPlanState::Succeeded
|
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());
|
assert!(s.worker_tombstone("w", &p.worker).unwrap().is_some());
|
||||||
let reservation_state: String = s.with_conn(|conn| {
|
let reservation_state: String = s.with_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
|
|||||||
@@ -33,26 +33,19 @@ pub enum RuntimeSubscriptionBrokerError {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum BrokerSubscriptionEvent {
|
pub enum BrokerSubscriptionEvent {
|
||||||
Snapshot {
|
Snapshot {
|
||||||
connection_generation: u64,
|
|
||||||
snapshot_revision: u64,
|
|
||||||
snapshot: SubscriptionSnapshot,
|
snapshot: SubscriptionSnapshot,
|
||||||
},
|
},
|
||||||
Event {
|
Event {
|
||||||
connection_generation: u64,
|
|
||||||
subject_revision: u64,
|
|
||||||
payload: SubscriptionEventPayload,
|
payload: SubscriptionEventPayload,
|
||||||
},
|
},
|
||||||
Disconnected {
|
Disconnected {
|
||||||
connection_generation: u64,
|
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
Rejected {
|
Rejected {
|
||||||
connection_generation: u64,
|
|
||||||
code: SubscriptionRejectionCode,
|
code: SubscriptionRejectionCode,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
Closed {
|
Closed {
|
||||||
connection_generation: u64,
|
|
||||||
code: SubscriptionTerminationCode,
|
code: SubscriptionTerminationCode,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
@@ -60,7 +53,6 @@ pub enum BrokerSubscriptionEvent {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct RuntimeSubscriptionBrokerStatus {
|
pub struct RuntimeSubscriptionBrokerStatus {
|
||||||
pub connection_generation: u64,
|
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub desired_selectors: usize,
|
pub desired_selectors: usize,
|
||||||
pub upstream_subscriptions: usize,
|
pub upstream_subscriptions: usize,
|
||||||
@@ -82,7 +74,30 @@ impl BrokerSubscription {
|
|||||||
&self.selector
|
&self.selector
|
||||||
}
|
}
|
||||||
pub async fn recv(&mut self) -> Option<BrokerSubscriptionEvent> {
|
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 {
|
pub fn register_remote_runtime(&self, config: RemoteRuntimeConfig) -> u64 {
|
||||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||||
let (commands, receiver) = mpsc::unbounded_channel();
|
let (commands, receiver) = mpsc::unbounded_channel();
|
||||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus::default()));
|
||||||
connection_generation: generation,
|
|
||||||
..Default::default()
|
|
||||||
}));
|
|
||||||
let registration = Registration {
|
let registration = Registration {
|
||||||
generation,
|
generation,
|
||||||
commands: commands.clone(),
|
commands: commands.clone(),
|
||||||
@@ -156,7 +168,6 @@ impl RuntimeSubscriptionBroker {
|
|||||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||||
let (commands, receiver) = mpsc::unbounded_channel();
|
let (commands, receiver) = mpsc::unbounded_channel();
|
||||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||||
connection_generation: generation,
|
|
||||||
connected: true,
|
connected: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}));
|
}));
|
||||||
@@ -257,7 +268,6 @@ impl RuntimeSubscriptionBroker {
|
|||||||
.clone();
|
.clone();
|
||||||
if !initial_status.connected {
|
if !initial_status.connected {
|
||||||
let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected {
|
let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected {
|
||||||
connection_generation: initial_status.connection_generation,
|
|
||||||
message: "Runtime subscription connection is not currently available".to_string(),
|
message: "Runtime subscription connection is not currently available".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,8 +296,7 @@ struct SelectorState {
|
|||||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||||
upstream_id: Option<SubscriptionId>,
|
upstream_id: Option<SubscriptionId>,
|
||||||
pending: bool,
|
pending: bool,
|
||||||
snapshot: Option<(u64, SubscriptionSnapshot)>,
|
snapshot: Option<SubscriptionSnapshot>,
|
||||||
revisions: HashMap<String, u64>,
|
|
||||||
}
|
}
|
||||||
impl SelectorState {
|
impl SelectorState {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
@@ -296,7 +305,6 @@ impl SelectorState {
|
|||||||
upstream_id: None,
|
upstream_id: None,
|
||||||
pending: false,
|
pending: false,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
revisions: HashMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,11 +342,9 @@ impl State {
|
|||||||
selector.upstream_id = None;
|
selector.upstream_id = None;
|
||||||
selector.pending = false;
|
selector.pending = false;
|
||||||
selector.snapshot = None;
|
selector.snapshot = None;
|
||||||
selector.revisions.clear();
|
|
||||||
broadcast(
|
broadcast(
|
||||||
&mut selector.downstreams,
|
&mut selector.downstreams,
|
||||||
BrokerSubscriptionEvent::Disconnected {
|
BrokerSubscriptionEvent::Disconnected {
|
||||||
connection_generation: self.generation,
|
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -347,12 +353,77 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct EmbeddedEntry {
|
struct EmbeddedEntry {
|
||||||
|
lifetime: u64,
|
||||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||||
snapshot_revision: u64,
|
|
||||||
snapshot: SubscriptionSnapshot,
|
snapshot: SubscriptionSnapshot,
|
||||||
task: tokio::task::JoinHandle<()>,
|
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(
|
async fn run_embedded_connection(
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
runtime: worker_runtime::Runtime,
|
runtime: worker_runtime::Runtime,
|
||||||
@@ -364,6 +435,7 @@ async fn run_embedded_connection(
|
|||||||
let (updates, mut update_receiver) = mpsc::unbounded_channel();
|
let (updates, mut update_receiver) = mpsc::unbounded_channel();
|
||||||
let mut entries = HashMap::<EventSubscriptionSelector, EmbeddedEntry>::new();
|
let mut entries = HashMap::<EventSubscriptionSelector, EmbeddedEntry>::new();
|
||||||
let mut downstream_index = HashMap::<u64, EventSubscriptionSelector>::new();
|
let mut downstream_index = HashMap::<u64, EventSubscriptionSelector>::new();
|
||||||
|
let mut next_lifetime = 1_u64;
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
command = commands.recv() => match command {
|
command = commands.recv() => match command {
|
||||||
@@ -371,30 +443,38 @@ async fn run_embedded_connection(
|
|||||||
downstream_index.insert(downstream_id, selector.clone());
|
downstream_index.insert(downstream_id, selector.clone());
|
||||||
if let Some(entry) = entries.get_mut(&selector) {
|
if let Some(entry) = entries.get_mut(&selector) {
|
||||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||||
connection_generation: generation,
|
|
||||||
snapshot_revision: entry.snapshot_revision,
|
|
||||||
snapshot: entry.snapshot.clone(),
|
snapshot: entry.snapshot.clone(),
|
||||||
});
|
});
|
||||||
entry.downstreams.insert(downstream_id, events);
|
entry.downstreams.insert(downstream_id, events);
|
||||||
} else {
|
} else {
|
||||||
match runtime.subscribe_event_selector(selector.clone()) {
|
let lifetime = next_lifetime;
|
||||||
Ok(mut subscription) => {
|
next_lifetime = next_lifetime.saturating_add(1);
|
||||||
let snapshot_revision = subscription.snapshot_revision();
|
match start_embedded_subscription(
|
||||||
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), &runtime_id);
|
&runtime,
|
||||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { connection_generation: generation, snapshot_revision, snapshot: snapshot.clone() });
|
&runtime_id,
|
||||||
let sender = updates.clone();
|
selector.clone(),
|
||||||
let task_selector = selector.clone();
|
lifetime,
|
||||||
let task_runtime_id = runtime_id.clone();
|
updates.clone(),
|
||||||
let task = tokio::spawn(async move {
|
) {
|
||||||
while let Ok(update) = subscription.recv().await {
|
Ok((snapshot, task)) => {
|
||||||
let payload = project_payload_runtime(update.payload, &task_runtime_id);
|
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||||
if sender.send((task_selector.clone(), update.subject_revision, payload)).is_err() { break; }
|
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) => {
|
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)) => {
|
Some(Command::Shutdown(replacement)) => {
|
||||||
for entry in entries.values_mut() {
|
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();
|
entry.task.abort();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -415,16 +495,68 @@ async fn run_embedded_connection(
|
|||||||
None => return,
|
None => return,
|
||||||
},
|
},
|
||||||
update = update_receiver.recv() => {
|
update = update_receiver.recv() => {
|
||||||
let Some((selector, subject_revision, payload)) = update else { return; };
|
let Some(update) = update else { return; };
|
||||||
if let Some(entry) = entries.get_mut(&selector) {
|
match update {
|
||||||
entry.snapshot_revision = entry.snapshot_revision.saturating_add(1);
|
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);
|
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
|
||||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { connection_generation: generation, subject_revision, 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 {
|
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||||
connection_generation: generation,
|
|
||||||
connected: true,
|
connected: true,
|
||||||
desired_selectors: entries.len(),
|
desired_selectors: entries.len(),
|
||||||
upstream_subscriptions: entries.len(),
|
upstream_subscriptions: entries.len(),
|
||||||
@@ -597,10 +729,8 @@ async fn apply_online(
|
|||||||
.selectors
|
.selectors
|
||||||
.entry(selector.clone())
|
.entry(selector.clone())
|
||||||
.or_insert_with(SelectorState::new);
|
.or_insert_with(SelectorState::new);
|
||||||
if let Some((revision, snapshot)) = &entry.snapshot {
|
if let Some(snapshot) = &entry.snapshot {
|
||||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||||
connection_generation: state.generation,
|
|
||||||
snapshot_revision: *revision,
|
|
||||||
snapshot: snapshot.clone(),
|
snapshot: snapshot.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -696,7 +826,6 @@ async fn handle_frame(
|
|||||||
request_id,
|
request_id,
|
||||||
subscription_id,
|
subscription_id,
|
||||||
selector,
|
selector,
|
||||||
snapshot_revision,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
}) => {
|
}) => {
|
||||||
if state.pending.remove(&request_id) != Some(selector.clone()) {
|
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(())?;
|
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||||
entry.pending = false;
|
entry.pending = false;
|
||||||
entry.upstream_id = Some(subscription_id.clone());
|
entry.upstream_id = Some(subscription_id.clone());
|
||||||
entry.snapshot = Some((snapshot_revision, snapshot.clone()));
|
entry.snapshot = Some(snapshot.clone());
|
||||||
entry.revisions = snapshot_revisions(&snapshot);
|
|
||||||
state
|
state
|
||||||
.upstream_index
|
.upstream_index
|
||||||
.insert(subscription_id, selector.clone());
|
.insert(subscription_id, selector.clone());
|
||||||
broadcast(
|
broadcast(
|
||||||
&mut entry.downstreams,
|
&mut entry.downstreams,
|
||||||
BrokerSubscriptionEvent::Snapshot {
|
BrokerSubscriptionEvent::Snapshot { snapshot },
|
||||||
connection_generation: state.generation,
|
|
||||||
snapshot_revision,
|
|
||||||
snapshot,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if entry.downstreams.is_empty() {
|
if entry.downstreams.is_empty() {
|
||||||
maybe_unsubscribe(socket, state, selector).await?;
|
maybe_unsubscribe(socket, state, selector).await?;
|
||||||
@@ -734,18 +858,13 @@ async fn handle_frame(
|
|||||||
if let Some(mut entry) = state.selectors.remove(&selector) {
|
if let Some(mut entry) = state.selectors.remove(&selector) {
|
||||||
broadcast(
|
broadcast(
|
||||||
&mut entry.downstreams,
|
&mut entry.downstreams,
|
||||||
BrokerSubscriptionEvent::Rejected {
|
BrokerSubscriptionEvent::Rejected { code, message },
|
||||||
connection_generation: state.generation,
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||||
subscription_id,
|
subscription_id,
|
||||||
subject_revision,
|
|
||||||
payload,
|
payload,
|
||||||
}) => {
|
}) => {
|
||||||
let selector = state
|
let selector = state
|
||||||
@@ -756,24 +875,12 @@ async fn handle_frame(
|
|||||||
payload.validate_for_selector(&selector).map_err(|_| ())?;
|
payload.validate_for_selector(&selector).map_err(|_| ())?;
|
||||||
let payload = project_payload_runtime(payload, &state.runtime_id);
|
let payload = project_payload_runtime(payload, &state.runtime_id);
|
||||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||||
if let Some(subject) = event_subject(&payload) {
|
if let Some(snapshot) = entry.snapshot.as_mut() {
|
||||||
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);
|
|
||||||
apply_event_to_cached_snapshot(snapshot, &payload);
|
apply_event_to_cached_snapshot(snapshot, &payload);
|
||||||
}
|
}
|
||||||
broadcast(
|
broadcast(
|
||||||
&mut entry.downstreams,
|
&mut entry.downstreams,
|
||||||
BrokerSubscriptionEvent::Event {
|
BrokerSubscriptionEvent::Event { payload },
|
||||||
connection_generation: state.generation,
|
|
||||||
subject_revision,
|
|
||||||
payload,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed {
|
SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed {
|
||||||
@@ -785,14 +892,9 @@ async fn handle_frame(
|
|||||||
let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) {
|
let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) {
|
||||||
entry.upstream_id = None;
|
entry.upstream_id = None;
|
||||||
entry.snapshot = None;
|
entry.snapshot = None;
|
||||||
entry.revisions.clear();
|
|
||||||
broadcast(
|
broadcast(
|
||||||
&mut entry.downstreams,
|
&mut entry.downstreams,
|
||||||
BrokerSubscriptionEvent::Closed {
|
BrokerSubscriptionEvent::Closed { code, message },
|
||||||
connection_generation: state.generation,
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
!entry.downstreams.is_empty()
|
!entry.downstreams.is_empty()
|
||||||
} else {
|
} else {
|
||||||
@@ -822,10 +924,9 @@ fn close_all(state: &mut State, replacement: u64) {
|
|||||||
broadcast(
|
broadcast(
|
||||||
&mut entry.downstreams,
|
&mut entry.downstreams,
|
||||||
BrokerSubscriptionEvent::Closed {
|
BrokerSubscriptionEvent::Closed {
|
||||||
connection_generation: state.generation,
|
|
||||||
code: SubscriptionTerminationCode::ServerShutdown,
|
code: SubscriptionTerminationCode::ServerShutdown,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Runtime connection generation {} was fenced by generation {replacement}",
|
"Runtime registration {} was replaced by {replacement}",
|
||||||
state.generation
|
state.generation
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -846,51 +947,6 @@ fn broadcast(
|
|||||||
downstreams.retain(|id, _| !closed.contains(id));
|
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<(), ()> {
|
async fn send_frame(socket: &mut RuntimeSocket, frame: SubscriptionFrame) -> Result<(), ()> {
|
||||||
frame.validate().map_err(|_| ())?;
|
frame.validate().map_err(|_| ())?;
|
||||||
socket
|
socket
|
||||||
@@ -979,7 +1035,6 @@ fn runtime_token(
|
|||||||
}
|
}
|
||||||
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
|
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
|
||||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||||
connection_generation: state.generation,
|
|
||||||
connected,
|
connected,
|
||||||
desired_selectors: state
|
desired_selectors: state
|
||||||
.selectors
|
.selectors
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ fn create_request(name: &str) -> CreateWorkerRequest {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
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!(second_generation > first_generation);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
next_event(&mut old).await,
|
next_event(&mut old).await,
|
||||||
BrokerSubscriptionEvent::Closed {
|
BrokerSubscriptionEvent::Closed { .. }
|
||||||
connection_generation,
|
|
||||||
..
|
|
||||||
} if connection_generation == first_generation
|
|
||||||
));
|
));
|
||||||
let status = wait_for_status(&broker, "runtime-test", |status| {
|
wait_for_status(&broker, "runtime-test", |status| status.connected).await;
|
||||||
status.connection_generation == second_generation && status.connected
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
assert_eq!(status.connection_generation, second_generation);
|
|
||||||
server.abort();
|
server.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +424,75 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
|
|||||||
server.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
|
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
|
||||||
let config = RemoteRuntimeConfig::new(
|
let config = RemoteRuntimeConfig::new(
|
||||||
|
|||||||
+6648
-115
File diff suppressed because it is too large
Load Diff
+1165
-330
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::{
|
use crate::runtime_subscription::{
|
||||||
BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError,
|
BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError,
|
||||||
};
|
};
|
||||||
use crate::store::{ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit};
|
use crate::store::{
|
||||||
|
ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit,
|
||||||
|
WorkerRegistryProjectionRecord,
|
||||||
|
};
|
||||||
use worker_runtime::identity::RuntimeWorkerRef;
|
use worker_runtime::identity::RuntimeWorkerRef;
|
||||||
|
|
||||||
const PROJECTION_EVENT_CAPACITY: usize = 256;
|
const PROJECTION_EVENT_CAPACITY: usize = 256;
|
||||||
@@ -20,21 +23,42 @@ pub type WorkerProjectionChange = WorkerCatalogChange;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WorkerProjectionEvent {
|
pub struct WorkerProjectionEvent {
|
||||||
pub revision: u64,
|
|
||||||
pub changes: Vec<WorkerProjectionChange>,
|
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
|
/// Owns the durable Runtime-to-Workspace Worker projection and its independent
|
||||||
/// low-frequency publication stream.
|
/// low-frequency publication stream.
|
||||||
///
|
///
|
||||||
/// One Runtime subscription is kept alive per registered Runtime even when no
|
/// The ordering gate covers both durable commits and subscription snapshot creation.
|
||||||
/// browser is connected. Browser subscriptions therefore never become the
|
/// Every subscription therefore starts with one authoritative snapshot and receives
|
||||||
/// authority for populating `worker_registry` observations.
|
/// only commits that completed after it, in the same order in which they were stored.
|
||||||
pub struct WorkerProjectionService {
|
pub struct WorkerProjectionService {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
events: broadcast::Sender<WorkerProjectionEvent>,
|
events: broadcast::Sender<WorkerProjectionEvent>,
|
||||||
connection_generation_epoch: u64,
|
ordering: Mutex<()>,
|
||||||
|
attach_gate: tokio::sync::Mutex<()>,
|
||||||
tasks: Mutex<HashMap<String, JoinHandle<()>>>,
|
tasks: Mutex<HashMap<String, JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +69,8 @@ impl WorkerProjectionService {
|
|||||||
workspace_id: workspace_id.into(),
|
workspace_id: workspace_id.into(),
|
||||||
store,
|
store,
|
||||||
events,
|
events,
|
||||||
connection_generation_epoch: Utc::now().timestamp_nanos_opt().unwrap_or_default().max(0)
|
ordering: Mutex::new(()),
|
||||||
as u64,
|
attach_gate: tokio::sync::Mutex::new(()),
|
||||||
tasks: Mutex::new(HashMap::new()),
|
tasks: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -62,8 +86,38 @@ impl WorkerProjectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<WorkerProjectionEvent> {
|
pub fn subscribe_ordered(&self, limit: usize) -> crate::Result<WorkerProjectionSubscription> {
|
||||||
self.events.subscribe()
|
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(
|
pub async fn attach_runtime(
|
||||||
@@ -71,6 +125,20 @@ impl WorkerProjectionService {
|
|||||||
broker: &RuntimeSubscriptionBroker,
|
broker: &RuntimeSubscriptionBroker,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
) -> Result<(), RuntimeSubscriptionBrokerError> {
|
) -> 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 =
|
let mut subscription =
|
||||||
broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?;
|
broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?;
|
||||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||||
@@ -80,12 +148,14 @@ impl WorkerProjectionService {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. });
|
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!(
|
tracing::warn!(
|
||||||
runtime_id,
|
runtime_id,
|
||||||
error = %error,
|
error = %error,
|
||||||
"failed to initialize durable Worker projection"
|
"failed to initialize durable Worker projection; restarting from a fresh snapshot"
|
||||||
);
|
);
|
||||||
|
subscription.restart()?;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if initialized {
|
if initialized {
|
||||||
break;
|
break;
|
||||||
@@ -99,26 +169,22 @@ impl WorkerProjectionService {
|
|||||||
let Some(service) = service.upgrade() else {
|
let Some(service) = service.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Err(error) = service
|
if let Err(error) = service.apply_broker_event(task_runtime_id.as_str(), event) {
|
||||||
.apply_broker_event(task_runtime_id.as_str(), event)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
runtime_id = %task_runtime_id,
|
runtime_id = %task_runtime_id,
|
||||||
error = %error,
|
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
|
self.tasks
|
||||||
.tasks
|
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Worker projection task lock poisoned")
|
.expect("Worker projection task lock poisoned")
|
||||||
.insert(runtime_id, task)
|
.insert(runtime_id, task);
|
||||||
{
|
|
||||||
previous.abort();
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,110 +194,96 @@ impl WorkerProjectionService {
|
|||||||
worker: &protocol::subscription::SubscriptionWorker,
|
worker: &protocol::subscription::SubscriptionWorker,
|
||||||
observed_at: &str,
|
observed_at: &str,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let commit = self.store.apply_worker_registry_observation(
|
self.publish_ordered(|| {
|
||||||
|
self.store
|
||||||
|
.apply_worker_registry_observation(
|
||||||
self.workspace_id.as_str(),
|
self.workspace_id.as_str(),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
0,
|
|
||||||
worker,
|
worker,
|
||||||
observed_at,
|
observed_at,
|
||||||
)?;
|
)
|
||||||
self.publish_commit(commit)
|
.map(|commit| ((), commit))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> {
|
pub fn refresh(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> {
|
||||||
let commit = self
|
self.publish_ordered(|| {
|
||||||
.store
|
self.store
|
||||||
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)?;
|
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)
|
||||||
self.publish_commit(commit)
|
.map(|commit| ((), commit))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_broker_event(
|
fn apply_broker_event(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
event: BrokerSubscriptionEvent,
|
event: BrokerSubscriptionEvent,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let observed_at = Utc::now().to_rfc3339();
|
let observed_at = Utc::now().to_rfc3339();
|
||||||
let projection_generation = |connection_generation: u64| {
|
self.publish_ordered(|| {
|
||||||
self.connection_generation_epoch
|
|
||||||
.saturating_add(connection_generation)
|
|
||||||
};
|
|
||||||
let commit = match event {
|
let commit = match event {
|
||||||
BrokerSubscriptionEvent::Snapshot {
|
BrokerSubscriptionEvent::Snapshot {
|
||||||
connection_generation,
|
|
||||||
snapshot_revision,
|
|
||||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||||
} => self.store.reconcile_worker_registry_snapshot(
|
} => self.store.reconcile_worker_registry_snapshot(
|
||||||
self.workspace_id.as_str(),
|
self.workspace_id.as_str(),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
projection_generation(connection_generation),
|
|
||||||
snapshot_revision,
|
|
||||||
workers.as_slice(),
|
workers.as_slice(),
|
||||||
observed_at.as_str(),
|
observed_at.as_str(),
|
||||||
)?,
|
)?,
|
||||||
BrokerSubscriptionEvent::Snapshot { .. } => return Ok(()),
|
BrokerSubscriptionEvent::Snapshot { .. } => {
|
||||||
|
return Ok((
|
||||||
|
(),
|
||||||
|
WorkerRegistryProjectionCommit {
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
BrokerSubscriptionEvent::Event {
|
BrokerSubscriptionEvent::Event {
|
||||||
connection_generation,
|
|
||||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||||
..
|
|
||||||
} => self.store.apply_worker_registry_observation(
|
} => self.store.apply_worker_registry_observation(
|
||||||
self.workspace_id.as_str(),
|
self.workspace_id.as_str(),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
projection_generation(connection_generation),
|
|
||||||
&worker,
|
&worker,
|
||||||
observed_at.as_str(),
|
observed_at.as_str(),
|
||||||
)?,
|
)?,
|
||||||
BrokerSubscriptionEvent::Event {
|
BrokerSubscriptionEvent::Event {
|
||||||
connection_generation,
|
|
||||||
subject_revision,
|
|
||||||
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
|
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
|
||||||
} => self.store.mark_worker_registry_observation_unavailable(
|
} => self.store.mark_worker_registry_observation_unavailable(
|
||||||
self.workspace_id.as_str(),
|
self.workspace_id.as_str(),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
projection_generation(connection_generation),
|
|
||||||
Some(worker_id.as_str()),
|
Some(worker_id.as_str()),
|
||||||
Some(subject_revision),
|
|
||||||
observed_at.as_str(),
|
observed_at.as_str(),
|
||||||
)?,
|
)?,
|
||||||
BrokerSubscriptionEvent::Event { .. } => return Ok(()),
|
BrokerSubscriptionEvent::Event { .. } => {
|
||||||
BrokerSubscriptionEvent::Disconnected {
|
return Ok((
|
||||||
connection_generation,
|
(),
|
||||||
..
|
WorkerRegistryProjectionCommit {
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
| BrokerSubscriptionEvent::Rejected {
|
BrokerSubscriptionEvent::Disconnected { .. }
|
||||||
connection_generation,
|
| BrokerSubscriptionEvent::Rejected { .. }
|
||||||
..
|
| BrokerSubscriptionEvent::Closed { .. } => {
|
||||||
}
|
self.store.mark_worker_registry_observation_unavailable(
|
||||||
| BrokerSubscriptionEvent::Closed {
|
|
||||||
connection_generation,
|
|
||||||
..
|
|
||||||
} => self.store.mark_worker_registry_observation_unavailable(
|
|
||||||
self.workspace_id.as_str(),
|
self.workspace_id.as_str(),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
projection_generation(connection_generation),
|
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
observed_at.as_str(),
|
observed_at.as_str(),
|
||||||
)?,
|
)?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
self.publish_commit(commit)
|
Ok(((), commit))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast an already committed catalog payload. Persistence always happens
|
fn publish_commit(&self, commit: WorkerRegistryProjectionCommit) {
|
||||||
/// 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<()> {
|
|
||||||
if commit.changes.is_empty() {
|
if commit.changes.is_empty() {
|
||||||
return Ok(());
|
return;
|
||||||
}
|
}
|
||||||
let _ = self.events.send(WorkerProjectionEvent {
|
let _ = self.events.send(WorkerProjectionEvent {
|
||||||
revision: commit.revision,
|
|
||||||
changes: commit.changes,
|
changes: commit.changes,
|
||||||
});
|
});
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,3 +292,159 @@ impl Drop for WorkerProjectionService {
|
|||||||
self.shutdown();
|
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(),
|
display_name: "Workspace A".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: InitialRepositoryIntent {
|
||||||
uri: repository.path().display().to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
repository_key: "main".to_string(),
|
repository_key: "platform".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -322,13 +322,11 @@ mod tests {
|
|||||||
assert_eq!(signing_identity.algorithm, "ed25519");
|
assert_eq!(signing_identity.algorithm, "ed25519");
|
||||||
assert!(signing_identity.public_key.is_some());
|
assert!(signing_identity.public_key.is_some());
|
||||||
assert!(signing_identity.public_key_fingerprint.is_some());
|
assert!(signing_identity.public_key_fingerprint.is_some());
|
||||||
assert_eq!(
|
let repositories = store
|
||||||
store
|
|
||||||
.list_repositories(&created.workspace.workspace_id)
|
.list_repositories(&created.workspace.workspace_id)
|
||||||
.unwrap()
|
.unwrap();
|
||||||
.len(),
|
assert_eq!(repositories.len(), 1);
|
||||||
1
|
assert_eq!(repositories[0].repository_key, "platform");
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.load_workspace_config(&created.workspace.workspace_id)
|
.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]
|
#[tokio::test]
|
||||||
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -73,12 +73,11 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"worker_orphan_diagnostics",
|
"worker_orphan_diagnostics",
|
||||||
"worker_registry",
|
"worker_registry",
|
||||||
"worker_registry_observations",
|
"worker_registry_observations",
|
||||||
"worker_registry_projection_cursors",
|
|
||||||
"worker_registry_projection_diagnostics",
|
"worker_registry_projection_diagnostics",
|
||||||
"worker_registry_projection_removals",
|
"worker_registry_projection_removals",
|
||||||
"worker_registry_projection_revisions",
|
|
||||||
"worker_removal_operations",
|
"worker_removal_operations",
|
||||||
"worker_retention_audit_events",
|
"worker_retention_audit_events",
|
||||||
|
"worker_session_archive_observe_grants",
|
||||||
"worker_session_archives",
|
"worker_session_archives",
|
||||||
"worker_singleton_owners",
|
"worker_singleton_owners",
|
||||||
"worker_tombstones",
|
"worker_tombstones",
|
||||||
|
|||||||
@@ -271,7 +271,6 @@ async fn run_worker_protocol(
|
|||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
runtime_id: Some(runtime_id),
|
runtime_id: Some(runtime_id),
|
||||||
},
|
},
|
||||||
snapshot_revision: 0,
|
|
||||||
snapshot: SubscriptionSnapshot::WorkerProtocol {
|
snapshot: SubscriptionSnapshot::WorkerProtocol {
|
||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
events: vec![initial_event],
|
events: vec![initial_event],
|
||||||
@@ -284,13 +283,10 @@ async fn run_worker_protocol(
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut subject_revision = 0_u64;
|
|
||||||
while let Some(event) = events.recv().await {
|
while let Some(event) = events.recv().await {
|
||||||
subject_revision = subject_revision.saturating_add(1);
|
|
||||||
let frame =
|
let frame =
|
||||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||||
subscription_id: subscription_id.clone(),
|
subscription_id: subscription_id.clone(),
|
||||||
subject_revision,
|
|
||||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
event,
|
event,
|
||||||
@@ -333,12 +329,10 @@ async fn run_workspace_workers(
|
|||||||
subscription_id: SubscriptionId,
|
subscription_id: SubscriptionId,
|
||||||
outbound: mpsc::Sender<WsMessage>,
|
outbound: mpsc::Sender<WsMessage>,
|
||||||
) {
|
) {
|
||||||
// Subscribe before reading so commits racing with the snapshot are replayed.
|
let Ok(mut projection) = api
|
||||||
let mut events = api.worker_projection.subscribe();
|
.worker_projection
|
||||||
let Ok((snapshot_revision, records)) = api.store.worker_registry_projection_snapshot(
|
.subscribe_ordered(api.config.max_records.max(1))
|
||||||
&api.config.workspace_id,
|
else {
|
||||||
api.config.max_records.max(1),
|
|
||||||
) else {
|
|
||||||
let _ = send_rejected(
|
let _ = send_rejected(
|
||||||
&outbound,
|
&outbound,
|
||||||
request_id,
|
request_id,
|
||||||
@@ -348,7 +342,8 @@ async fn run_workspace_workers(
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut workers = records
|
let mut workers = projection
|
||||||
|
.take_snapshot()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|record| project_registry_worker(&api, record))
|
.filter_map(|record| project_registry_worker(&api, record))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -360,7 +355,6 @@ async fn run_workspace_workers(
|
|||||||
request_id,
|
request_id,
|
||||||
subscription_id: subscription_id.clone(),
|
subscription_id: subscription_id.clone(),
|
||||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||||
snapshot_revision,
|
|
||||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
@@ -372,16 +366,14 @@ async fn run_workspace_workers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match events.recv().await {
|
match projection.recv().await {
|
||||||
Ok(event) if event.revision <= snapshot_revision => continue,
|
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
for change in event.changes {
|
for change in event.changes {
|
||||||
let payload = match change {
|
let payload = match change {
|
||||||
crate::worker_projection::WorkerProjectionChange::Upsert(record) => {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
worker.subject_revision = event.revision;
|
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker }
|
SubscriptionEventPayload::WorkerUpserted { worker }
|
||||||
}
|
}
|
||||||
crate::worker_projection::WorkerProjectionChange::Removed(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
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
@@ -431,7 +423,6 @@ fn project_registry_worker(
|
|||||||
let mut worker = if let Some(observation) = record.observation {
|
let mut worker = if let Some(observation) = record.observation {
|
||||||
let mut worker = observation.worker;
|
let mut worker = observation.worker;
|
||||||
worker.availability = observation.availability;
|
worker.availability = observation.availability;
|
||||||
worker.subject_revision = observation.projection_revision;
|
|
||||||
worker
|
worker
|
||||||
} else {
|
} else {
|
||||||
SubscriptionWorker {
|
SubscriptionWorker {
|
||||||
@@ -442,7 +433,6 @@ fn project_registry_worker(
|
|||||||
runtime_id: Some(runtime_id.clone()),
|
runtime_id: Some(runtime_id.clone()),
|
||||||
resource_key: record.resource_key.clone(),
|
resource_key: record.resource_key.clone(),
|
||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
|
||||||
subject_revision: 0,
|
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
state: protocol::subscription::SubscriptionWorkerState::Stopped,
|
state: protocol::subscription::SubscriptionWorkerState::Stopped,
|
||||||
has_running_internal_workers: false,
|
has_running_internal_workers: false,
|
||||||
@@ -559,14 +549,12 @@ fn sort_workers(workers: &mut [SubscriptionWorker]) {
|
|||||||
async fn send_event(
|
async fn send_event(
|
||||||
outbound: &mpsc::Sender<WsMessage>,
|
outbound: &mpsc::Sender<WsMessage>,
|
||||||
subscription_id: &SubscriptionId,
|
subscription_id: &SubscriptionId,
|
||||||
subject_revision: u64,
|
|
||||||
payload: SubscriptionEventPayload,
|
payload: SubscriptionEventPayload,
|
||||||
) -> Result<(), ()> {
|
) -> Result<(), ()> {
|
||||||
send_frame(
|
send_frame(
|
||||||
outbound,
|
outbound,
|
||||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||||
subscription_id: subscription_id.clone(),
|
subscription_id: subscription_id.clone(),
|
||||||
subject_revision,
|
|
||||||
payload,
|
payload,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -617,7 +605,6 @@ mod tests {
|
|||||||
runtime_id: Some("runtime-1".to_string()),
|
runtime_id: Some("runtime-1".to_string()),
|
||||||
resource_key: Some("W-1".to_string()),
|
resource_key: Some("W-1".to_string()),
|
||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
subject_revision: 1,
|
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
state: protocol::subscription::SubscriptionWorkerState::Idle,
|
state: protocol::subscription::SubscriptionWorkerState::Idle,
|
||||||
has_running_internal_workers: false,
|
has_running_internal_workers: false,
|
||||||
|
|||||||
+31
-2
@@ -2986,7 +2986,7 @@ backend = "shared"
|
|||||||
"--display-name".to_string(),
|
"--display-name".to_string(),
|
||||||
"Workspace A".to_string(),
|
"Workspace A".to_string(),
|
||||||
"--repository-key".to_string(),
|
"--repository-key".to_string(),
|
||||||
"main".to_string(),
|
"platform".to_string(),
|
||||||
"--repository".to_string(),
|
"--repository".to_string(),
|
||||||
repository.path().display().to_string(),
|
repository.path().display().to_string(),
|
||||||
"--default-ref".to_string(),
|
"--default-ref".to_string(),
|
||||||
@@ -3000,7 +3000,7 @@ backend = "shared"
|
|||||||
};
|
};
|
||||||
assert_eq!(options.backend_url, "http://backend.example");
|
assert_eq!(options.backend_url, "http://backend.example");
|
||||||
assert_eq!(options.display_name, "Workspace A");
|
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.default_ref.as_deref(), Some("develop"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
options.repository_root,
|
options.repository_root,
|
||||||
@@ -3009,6 +3009,35 @@ backend = "shared"
|
|||||||
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
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]
|
#[test]
|
||||||
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+4
-1
@@ -16,7 +16,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
|||||||
8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||||
9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority.
|
9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority.
|
||||||
10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection.
|
10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection.
|
||||||
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — deprecated single-document Workspace Memory authority and its separation from subject-scoped subjektiv Memory.
|
||||||
12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
||||||
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
|
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
|
||||||
14. [`development/ui-ux/design-language.md`](development/ui-ux/design-language.md) — Workspace Webの視覚・interaction原則、文法、token、汎用component。
|
14. [`development/ui-ux/design-language.md`](development/ui-ux/design-language.md) — Workspace Webの視覚・interaction原則、文法、token、汎用component。
|
||||||
@@ -30,6 +30,9 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
|||||||
22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
|
22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
|
||||||
23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
|
23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
|
||||||
24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。
|
24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。
|
||||||
|
25. [`design/subjektiv-store.md`](design/subjektiv-store.md) — subject-scoped Memoryのownership、authorization、Session attribution、revision、candidate consolidation境界。
|
||||||
|
26. [`design/subjektiv-surface.md`](design/subjektiv-surface.md) — confirmed Memoryから生成するresident surface、availability、append-only restore injection。
|
||||||
|
27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。
|
||||||
|
|
||||||
## What belongs here
|
## What belongs here
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# Workspace Memory authority
|
# Legacy Workspace Memory authority
|
||||||
|
|
||||||
Generated Memory is durable Workspace context owned by the Workspace Server control plane. Runtime Workers query and mutate it through typed Workspace API operations; they do not read or write repository-local paths.
|
The single-document Workspace Memory product is a **legacy/deprecated** compatibility surface. Its document, staging candidates, and staging resolutions are durable Workspace context owned by the Workspace Server control-plane database. It is separate from subject-scoped subjektiv Memory, whose persistence authority is the Workspace's Server-managed `subjektiv` Feature database.
|
||||||
|
|
||||||
The active Memory document and staging/audit lifecycle are stored by the Server-owned backend. Worker-side `memory` code contains only shared schema, extraction, audit, and transport types needed to construct those operations.
|
Legacy Runtime Workers query and mutate the old document through the established typed `/api/w/{workspace_id}/memory...` operations and `MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and legacy `MemoryStaging*` tools. While those surfaces remain available, their existing inputs keep their existing Workspace-document meaning. They must not be silently redirected to subjektiv, assigned an inferred subject, or reused with a new interpretation.
|
||||||
|
|
||||||
|
Worker-side `memory` code owns shared schemas and transport types, not persistence. Legacy `AppendAudit` is acknowledgement-only and has no dedicated durable Memory-audit rows. General Session logs and diagnostics remain independently durable under their own retention policies.
|
||||||
|
|
||||||
Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command.
|
Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command.
|
||||||
|
|
||||||
Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only session records for exact current facts.
|
Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only Session records for exact current facts.
|
||||||
|
|
||||||
|
## subjektiv cutover and reset
|
||||||
|
|
||||||
|
Enabling `feature.subjektiv` does not migrate or erase legacy rows. The only supported destructive boundary is the explicit, authorized legacy reset API described by the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md). It atomically deletes the old document, staging candidates, and staging resolutions while retaining Workspace Memory settings, Sessions and diagnostics, Tickets, Knowledge, Skills, the subjektiv Feature database, and unrelated Workspace data. The reset is operator-triggered and idempotent; it is never a startup migration or Profile side effect.
|
||||||
|
|
||||||
## Upgrade guidance
|
## Upgrade guidance
|
||||||
|
|
||||||
Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the Workspace-backed product surface available to the deployment. Stale files may be archived or deleted after verification.
|
Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the intended Workspace-backed product surface. Do not assume the legacy control-plane reset imports repository-local or legacy Workspace data into subjektiv. Stale files may be archived or deleted only after verification.
|
||||||
|
|
||||||
## Preserved filesystem boundaries
|
## Preserved filesystem boundaries
|
||||||
|
|
||||||
This removal does not affect repository checkouts, Git metadata, Runtime-owned Workdirs/session logs/artifacts, Server data directories, or XDG client/server configuration. It removes only ambient repository-local Memory authority.
|
Repository-local Memory removal and the control-plane legacy reset do not affect repository checkouts, Git metadata, Runtime-owned Workdirs, Session logs or artifacts, Server data directories, XDG client/server configuration, or the subjektiv Feature database. They remove only their explicitly named legacy authority.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
|
|||||||
- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend.
|
- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend.
|
||||||
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
||||||
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
||||||
- `memory` owns shared Memory domain, extraction, audit-event, and Workspace API transport types. Persistence and lifecycle execution belong to the Workspace Server control plane.
|
- `memory` owns shared legacy Memory transport plus extraction/evidence schemas, not persistence. Legacy single-document Memory lives in the Workspace Server control-plane database; subject-scoped Memory lives in the Workspace's Server-managed `subjektiv` Feature database.
|
||||||
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
||||||
- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state.
|
- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Server-managed Feature storage
|
# Server-managed Feature storage
|
||||||
|
|
||||||
`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool.
|
`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool. Subject-scoped Memory is persisted in the Workspace's `subjektiv` Feature database through this boundary; legacy single-document Workspace Memory remains in the control-plane database and is not owned, imported, or reset by `FeatureStorage`.
|
||||||
|
|
||||||
## Ownership boundary
|
## Ownership boundary
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ Not registering/opening a disabled Feature creates nothing. Disabling a previous
|
|||||||
|
|
||||||
`WorkspaceFeatureStorage::backup` uses SQLite's online backup API for each discovered Feature database, so a live WAL is not ignored or copied as a bare database file. Each image passes `integrity_check` and `foreign_key_check`, and a manifest binds the snapshot to its Workspace and Feature identifiers. The snapshot directory is published with an atomic rename only after all images succeed.
|
`WorkspaceFeatureStorage::backup` uses SQLite's online backup API for each discovered Feature database, so a live WAL is not ignored or copied as a bare database file. Each image passes `integrity_check` and `foreign_key_check`, and a manifest binds the snapshot to its Workspace and Feature identifiers. The snapshot directory is published with an atomic rename only after all images succeed.
|
||||||
|
|
||||||
Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers.
|
Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers. In particular, the [subjektiv product cutover](../development/subjektiv-product-cutover.md) stops legacy and subjektiv activity before pairing a control-plane backup with the subjektiv Feature backup; the explicit legacy reset never deletes or recreates the Feature database.
|
||||||
|
|
||||||
`restore` rejects the wrong Workspace manifest, corrupt/foreign-key-invalid images, an existing destination, or any live connection. It builds validated databases in a temporary directory and atomically publishes the Workspace storage directory. Restore is an operator/Server lifecycle operation, not a model API.
|
`restore` rejects the wrong Workspace manifest, corrupt/foreign-key-invalid images, an existing destination, or any live connection. It builds validated databases in a temporary directory and atomically publishes the Workspace storage directory. Restore is an operator/Server lifecycle operation, not a model API.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
# subjektiv subject Memory store
|
# subjektiv subject Memory store
|
||||||
|
|
||||||
T-667 introduces a trusted Server-side `subjektiv` repository in
|
The trusted Server-side `subjektiv` repository in
|
||||||
`yoi_workspace_server::subjektiv`. It is a domain store, not a Worker tool or a
|
`yoi_workspace_server::subjektiv` is the persistence authority for subject-scoped
|
||||||
cutover of the existing Workspace Memory authority.
|
Memory. It is a domain store, not a Worker tool. Legacy single-document Workspace
|
||||||
|
Memory remains a separate, deprecated control-plane authority until an operator
|
||||||
|
performs the explicit reset defined by the
|
||||||
|
[subjektiv product cutover runbook](../development/subjektiv-product-cutover.md);
|
||||||
|
legacy rows are never imported, dual-written, or silently reinterpreted as subject
|
||||||
|
Memory.
|
||||||
|
|
||||||
## Ownership and scope
|
## Ownership and scope
|
||||||
|
|
||||||
@@ -19,14 +24,17 @@ cutover of the existing Workspace Memory authority.
|
|||||||
Async callers must run these synchronous repository calls on a blocking
|
Async callers must run these synchronous repository calls on a blocking
|
||||||
executor.
|
executor.
|
||||||
- The store contains no `current_worker_id` or equivalent current link. Yoi's
|
- The store contains no `current_worker_id` or equivalent current link. Yoi's
|
||||||
keyed singleton remains the sole authority for the current Worker. Historical
|
keyed singleton remains the sole authority for the current Worker. T-668
|
||||||
Worker/Session attribution is provenance and will be connected separately.
|
connects Workers using the opaque `subjektiv:<subject-id>` key and records
|
||||||
|
immutable historical Worker/Session attribution as provenance; that history is
|
||||||
|
never consulted as a current Worker link.
|
||||||
|
|
||||||
Registration is deliberately separate from open. Trusted Server construction
|
Registration is deliberately separate from open. Trusted Server construction
|
||||||
registers `REGISTRATION` once per `FeatureStorage` manager, then reuses its
|
registers `REGISTRATION` once per `FeatureStorage` manager, then reuses its
|
||||||
`RegisteredFeature` for each Workspace. Merely adding this module does not open
|
`RegisteredFeature` for each Workspace. Merely adding this module does not open
|
||||||
or create a subjektiv database; product enablement and old-Memory cutover are
|
or create a subjektiv database. T-668 opens it lazily for explicit subject
|
||||||
later work.
|
operations or an authenticated subject extraction write; leaving the Feature
|
||||||
|
disabled has no subject-store side effect.
|
||||||
|
|
||||||
## Versioned records
|
## Versioned records
|
||||||
|
|
||||||
@@ -82,8 +90,9 @@ immutable. A resolution stores both the resolution JSON and the exact staged JSO
|
|||||||
bytes that were resolved. Aggregate seal rows prevent evidence, derivations,
|
bytes that were resolved. Aggregate seal rows prevent evidence, derivations,
|
||||||
resolution targets, or snapshot references from being appended after their
|
resolution targets, or snapshot references from being appended after their
|
||||||
parent JSON is finalized. Foreign keys plus retention triggers prevent dangling
|
parent JSON is finalized. Foreign keys plus retention triggers prevent dangling
|
||||||
candidate, derivation, resolution-target, and snapshot references. There is no
|
candidate, derivation, resolution-target, and snapshot references. Ordinary
|
||||||
record deletion API in this baseline.
|
subject operations expose no record deletion API; the legacy reset contract does
|
||||||
|
not touch this Feature database.
|
||||||
|
|
||||||
Search columns and JSON projections are written together by the typed repository.
|
Search columns and JSON projections are written together by the typed repository.
|
||||||
Raw SQL remains private trusted implementation detail. Existing Workspace Memory
|
Raw SQL remains private trusted implementation detail. Existing Workspace Memory
|
||||||
@@ -120,3 +129,191 @@ online backup/restore includes the entire subjektiv database, including immutabl
|
|||||||
history and provenance. Workspace deletion and shutdown use the common lifecycle
|
history and provenance. Workspace deletion and shutdown use the common lifecycle
|
||||||
fences and close retained repository handles. Disabling subjektiv does not erase
|
fences and close retained repository handles. Disabling subjektiv does not erase
|
||||||
its database.
|
its database.
|
||||||
|
|
||||||
|
## Extraction connection (T-668)
|
||||||
|
|
||||||
|
`feature.subjektiv` is an explicit Worker profile boundary. It reuses the
|
||||||
|
committed-run threshold, restricted Internal extraction Worker,
|
||||||
|
`SessionEntryRef` evidence resolution, explicit finish, generation fence, and
|
||||||
|
success-only pointer progression from the established Memory lifecycle. Its
|
||||||
|
pointer domain is separate from legacy Workspace Memory, and a Worker is rejected
|
||||||
|
at installation when both automatic extraction destinations are enabled. Ordinary
|
||||||
|
SubWorkers and Reviewer SubWorkers have lifecycle Features disabled by their host;
|
||||||
|
only evidence actually committed into the parent Session can enter the parent's
|
||||||
|
capture.
|
||||||
|
|
||||||
|
The staging request contains the existing model contract (`kind`, `claim`,
|
||||||
|
`why_useful`, optional `staleness`, and entry references after host resolution)
|
||||||
|
plus the committed parent `session_id`. It contains no subject id. The Server
|
||||||
|
derives subject scope from authenticated Runtime/Worker source proof and the
|
||||||
|
current keyed-singleton lease, then attaches the session id to every source
|
||||||
|
reference and records immutable historical `{subject, runtime, worker, session}`
|
||||||
|
provenance atomically with staging. A Session already attributed to another
|
||||||
|
subject is rejected. Preference candidates still require exclusively HumanInput
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
Subject retirement does not stop a Worker, and stopping/replacing a Worker does
|
||||||
|
not retire the subject. A replacement uses the same `subjektiv:<subject-id>` key;
|
||||||
|
retries therefore recover through Yoi's singleton authority without a second
|
||||||
|
current-worker mapping in this database.
|
||||||
|
|
||||||
|
Candidates are immutable writes that occur before `FinishMemoryExtraction`. If a
|
||||||
|
run stages one or more candidates and then fails, is cancelled, or loses its
|
||||||
|
generation fence, those candidates remain recorded and the extraction pointer
|
||||||
|
does not advance. A retry may therefore produce semantically duplicate candidates;
|
||||||
|
subsequent consolidation must compare them using the existing candidate
|
||||||
|
identity/content boundary. The system must not claim that the partial writes were
|
||||||
|
rolled back or silently delete them. Subject recall, consolidation, and legacy
|
||||||
|
Memory migration remain separate work.
|
||||||
|
|
||||||
|
## Recall and explicit proposal tools (T-669)
|
||||||
|
|
||||||
|
An enabled `feature.subjektiv` installs five names that do not overlap the legacy
|
||||||
|
single-Markdown Memory tools: `SubjektivMemoryQuery`, `SubjektivMemoryRead`,
|
||||||
|
`SubjektivMemoryListRevisions`, `SubjektivMemoryRemember`, and
|
||||||
|
`SubjektivMemoryProposeRevision`. Runtime-signed Worker source plus current
|
||||||
|
`subjektiv:<subject-id>` singleton ownership is re-evaluated for every operation;
|
||||||
|
none of the model-visible inputs contains a subject, Runtime, Worker, Session,
|
||||||
|
origin, or raw provenance object.
|
||||||
|
|
||||||
|
Query searches only current projections. Omitted `states` means `active`; explicit
|
||||||
|
empty `states` or `kinds` is invalid. Output defaults to 20 and is capped at 100,
|
||||||
|
ordered by `updated_at DESC, memory_id ASC`. Its opaque cursor binds the subject,
|
||||||
|
canonical filters, offset, and subject `store_revision`; any confirmed-Memory
|
||||||
|
change makes it a typed stale-cursor conflict rather than silently mixing
|
||||||
|
snapshots. Revision conflicts and stale cursors retain their diagnostic code as a
|
||||||
|
structured `{status: "error", error: {code, message}}` tool result instead of
|
||||||
|
requiring models to parse prose. Revision history is ordered by revision
|
||||||
|
descending. Its first page
|
||||||
|
fixes the maximum revision, so later revisions neither duplicate nor displace old
|
||||||
|
page members.
|
||||||
|
|
||||||
|
Read accepts a positive exact revision or resolves the current revision once. A
|
||||||
|
missing historical revision never falls back to current. Markdown pagination is
|
||||||
|
line-based (default 200, maximum 1000) with a preliminary 16 KiB UTF-8 body
|
||||||
|
cap and a global 56 KiB budget over the exact pretty-serialized model-visible
|
||||||
|
response. JSON escaping, provenance, and metadata therefore reduce the current
|
||||||
|
body page before the Worker content ceiling is reached. It reports
|
||||||
|
`body_truncated`, `body_next_offset`, and `body_next_byte_offset`; an oversized
|
||||||
|
or escape-heavy single line resumes from the returned UTF-8 byte boundary within
|
||||||
|
that same line. Every continuation offset must also supply the exact revision
|
||||||
|
returned by the first page, so a current-revision change cannot mix body versions.
|
||||||
|
Provenance uses a separate immutable-revision-bound cursor and returns one
|
||||||
|
candidate/derivation reference per page. New staged candidates contain at most 10
|
||||||
|
evidence records and 10 source references; read pages project at most two of each,
|
||||||
|
with displayed anchor text capped at 64 UTF-8 bytes and nested offsets carried by
|
||||||
|
the ordinary evidence cursor. Compatible candidates persisted before the admission
|
||||||
|
cap remain readable across as many evidence pages as needed, while every current
|
||||||
|
staging path rejects new larger anchor sets. Candidate evidence is the bounded
|
||||||
|
host-resolved anchor saved in staging; raw Session bodies are not copied
|
||||||
|
into Memory responses. Resolved, retracted, and historical revisions remain
|
||||||
|
addressable by ID.
|
||||||
|
|
||||||
|
Remember and ProposeRevision only stage candidates. Entry references are resolved
|
||||||
|
against the host's committed Session capture, and preference candidates continue
|
||||||
|
to require exclusively `HumanInput` evidence. When no entry is supplied, a
|
||||||
|
non-preference request returns `pending_commit`; after the run commits, the host
|
||||||
|
reconstructs pending receipts from the durable tool-call/tool-result history and
|
||||||
|
uses the committed tool-call entry itself as model-origin evidence. Receipt lookup
|
||||||
|
reports `pending_commit`, `staged`, or `missing`; the post-commit hook retries
|
||||||
|
immediately, the pre-request hook replays durable pending receipts after Worker
|
||||||
|
restore, and the fail-closed pre-rewrite hook prevents compaction from dropping an
|
||||||
|
unstaged receipt. Hook failure and process restart therefore retain an idempotent
|
||||||
|
retry path, while an operation that never commits is not staged. Receipt identity is derived from Session and tool-call identity,
|
||||||
|
and exact backend retries return the first candidate rather than creating another
|
||||||
|
one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
|
||||||
|
or a surface snapshot.
|
||||||
|
|
||||||
|
|
||||||
|
## Historical Session discovery (T-673)
|
||||||
|
|
||||||
|
An enabled `feature.subjektiv` also installs exactly three read-only tools:
|
||||||
|
`SubjektivSessionList`, `SubjektivSessionSearch`, and `SubjektivSessionRead`.
|
||||||
|
The Server binds subject, Workspace, Runtime, Worker, retention catalogue, and
|
||||||
|
archive authority from the authenticated caller. Model input contains only
|
||||||
|
Session/segment/entry selectors, search filters, limits, and opaque cursors;
|
||||||
|
knowing any identifier does not grant access. Reads use Yoi's retained or
|
||||||
|
committed-archive Session storage and a shared public projection. They do not
|
||||||
|
copy Session bodies into the subjektiv database, restore Workers, migrate
|
||||||
|
Session storage, create candidates or Memories, revise a surface, or promote a
|
||||||
|
re-read tool result to HumanInput.
|
||||||
|
|
||||||
|
The public projection covers committed records in every persisted segment,
|
||||||
|
including non-active branches and pre-compaction entries, while excluding
|
||||||
|
system prompts, hidden reasoning, traces/diagnostics, unfinished run tails, and
|
||||||
|
attachment or pasted-artifact bodies. Results preserve exact Session, segment,
|
||||||
|
entry, provenance, and lineage identity so inherited entries are not presented
|
||||||
|
as a current decision. Full entry reads are UTF-8-boundary paged and remain
|
||||||
|
subject to the same projection as snippets and search.
|
||||||
|
|
||||||
|
The subject's first committed primary-Worker Session is attributed independently
|
||||||
|
of extraction thresholds or candidate creation. Recording is idempotent and is
|
||||||
|
retried on later committed-run and pre-request lifecycle points after transient
|
||||||
|
failure; no attribution is attempted before a committed capture exists. This is
|
||||||
|
not a distributed transaction with Session persistence. In particular, the
|
||||||
|
system does **not** backfill older unattributed Sessions from their text, display
|
||||||
|
name, current singleton ownership, Memory references, or other inference.
|
||||||
|
Historical Sessions without trustworthy Host-recorded attribution remain outside
|
||||||
|
subject discovery until a separately authorized provenance mechanism exists.
|
||||||
|
|
||||||
|
Pagination cursors bind scope, operation, filters, stable ordering, and the
|
||||||
|
public storage generation and are never authorization credentials. Every page
|
||||||
|
rechecks current subject and observation authority. Deleted-Worker archives are
|
||||||
|
eligible only when both the committed Server catalogue and Runtime manifest
|
||||||
|
agree and the archived observation grant remains valid; incomplete, expired,
|
||||||
|
corrupt, unavailable, or changed sources are reported explicitly rather than as
|
||||||
|
an empty successful search.
|
||||||
|
|
||||||
|
Revision proposals add optional `revision_proposal` metadata to the existing v2
|
||||||
|
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and
|
||||||
|
its model schema is unchanged. The metadata is serialized atomically with the
|
||||||
|
candidate and therefore remains present in candidate reads and immutable
|
||||||
|
resolution copies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"intent": "revise",
|
||||||
|
"memory_id": "memory-…",
|
||||||
|
"expected_revision": 3,
|
||||||
|
"change_reason": "The committed evidence corrects the prior condition"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the T-670 handoff fixture. Consolidation must re-read this typed metadata,
|
||||||
|
re-check subject ownership, `expected_revision`, and the requested state
|
||||||
|
transition in the same transaction that applies the candidate, and leave a stale
|
||||||
|
proposal unresolved/conflicted. It must not parse target information from the
|
||||||
|
claim, retarget to the latest revision, or revive a retracted Memory. Valid
|
||||||
|
transitions at staging are revise while active/resolved, active→resolved,
|
||||||
|
active/resolved→retracted, and resolved→active. Retraction remains terminal.
|
||||||
|
|
||||||
|
## Candidate consolidation and corrections (T-670)
|
||||||
|
|
||||||
|
The Backend starts a restricted `builtin:subjektiv-memory-consolidation` Worker
|
||||||
|
for exactly one authenticated subject. The Host binds the subject; model-visible
|
||||||
|
inputs cannot select a Workspace, subject, Runtime, Worker, or Session. Its tools
|
||||||
|
list/read pending immutable candidates, query/read confirmed revisions, and make
|
||||||
|
one candidate decision. Candidate text is evidence rather than authority.
|
||||||
|
|
||||||
|
Every decision carries a stable request ID. An exact retry returns the committed
|
||||||
|
receipt, while reuse with different input is a conflict. Applying a candidate
|
||||||
|
atomically writes one new confirmed revision, its exact candidate and derivation
|
||||||
|
edges, the immutable resolution and affected revision references, and advances
|
||||||
|
the subject store revision. A non-applied decision explicitly records
|
||||||
|
`discarded`, `invalid`, `duplicate`, or `already_covered`; an empty model response,
|
||||||
|
transport failure, or aborted consolidation records no disposition and leaves the
|
||||||
|
candidate pending. A successful applied response is the only evidence that
|
||||||
|
confirmed Memory changed.
|
||||||
|
|
||||||
|
A typed revision proposal must be applied to its exact `memory_id` and
|
||||||
|
`expected_revision`, preserving its intent and change reason. The transaction
|
||||||
|
rechecks the current revision and state transition. It never rebases or retargets
|
||||||
|
a stale proposal, revives retracted Memory, or extracts a target from claim prose.
|
||||||
|
Correction/refinement of the same experience appends under the same Memory ID;
|
||||||
|
a corrected experience after terminal retraction receives a new Memory ID and may
|
||||||
|
cite the retracted fixed revision as a derivation. Surface generation is a
|
||||||
|
separate post-consolidation lifecycle: its failure cannot roll back a committed
|
||||||
|
candidate decision.
|
||||||
|
|
||||||
|
For the product activation, legacy reset, and operator recovery boundary, follow
|
||||||
|
the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md).
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Subject Memory surface generation
|
||||||
|
|
||||||
|
The subject Memory surface is a bounded start-time context derived from confirmed, revisioned Memory. It is not another Memory authority and is never used as extraction or consolidation input.
|
||||||
|
|
||||||
|
## Fixed policy
|
||||||
|
|
||||||
|
The Backend captures one generation from current `active` revisions for exactly one subject. Staging records, Session history, historical revisions, and prior surfaces are not queried.
|
||||||
|
|
||||||
|
Selection is deterministic:
|
||||||
|
|
||||||
|
1. categories use the fixed order `preference`, `constraint`, `decision`, `working_assumption`, `open_question`, `lesson`;
|
||||||
|
2. each category is ordered by `updated_at DESC, memory_id ASC`;
|
||||||
|
3. at most 8 records are retained per category;
|
||||||
|
4. records are allocated round-robin by category, up to 24 records total;
|
||||||
|
5. the 10,000-token material budget is initially divided equally across categories as a fairness reservation; unused shares are then reclaimed in the same canonical round-robin order, so one large category cannot exclude another non-empty category or strand usable capacity;
|
||||||
|
6. both category and aggregate material bounds use the provider-independent estimate `ceil(UTF-8 bytes / 4)`;
|
||||||
|
7. before invoking the editor, the Worker measures the actual normalized Agen request fields containing the rendered system prompt, conversation items (including the fixed question and material JSON), and the typed `SubmitMemorySurface` tool definition/schema against the 12,000-token complete-input budget. It reapplies equal per-category fairness reservations under that final estimator, reclaims unused shares in canonical order, fails rather than publishing a false empty surface if no grounded material fits, and guards every subsequent correction request before it reaches the provider. Provider-specific HTTP envelope bytes are outside this normalized request budget.
|
||||||
|
|
||||||
|
The clean-context editor uses the effective `builtin:subjektiv-memory-consolidation` profile model (`codex-oauth/gpt-5.6-luna`, medium reasoning). A generation permits three model turns (the initial attempt plus at most two correction turns). A store-generation conflict causes at most one complete re-read and regeneration. The published Markdown is capped at 1,024 tokens by the same estimate.
|
||||||
|
|
||||||
|
Every editor point carries one or more exact `{memory_id, revision}` references. The Host checks the output shape, non-empty grounding, budget, subject scope, membership in the captured materials, and current store generation. These checks prove reference existence and freshness only; they do not claim semantic correctness.
|
||||||
|
|
||||||
|
Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition.
|
||||||
|
|
||||||
|
Resident injection uses five distinct product states:
|
||||||
|
|
||||||
|
- `ready`: inject the non-empty snapshot only when its `built_from_store_revision`
|
||||||
|
equals the subject's exact current store revision;
|
||||||
|
- `ready-empty`: a successful current generation selected no Markdown; inject no
|
||||||
|
prose, but preserve the explicit successful-empty state;
|
||||||
|
- `ungenerated`: no successful current generation exists; inject nothing;
|
||||||
|
- `stale`: confirmed Memory changed after publication (or the snapshot predates
|
||||||
|
this generation policy); inject nothing until regeneration succeeds;
|
||||||
|
- `failed`: the generation attempt failed; inject nothing and preserve the typed
|
||||||
|
failure for diagnostics/retry rather than representing it as empty Memory.
|
||||||
|
|
||||||
|
The resident Backend transports availability separately from optional Markdown.
|
||||||
|
In storage, `ready-empty` is a current `ready` snapshot with empty Markdown; it is
|
||||||
|
never conflated with `ungenerated`, `stale`, `failed`, request failure, or
|
||||||
|
omission. Publication is append-only: generation inputs/runs, failures, snapshots,
|
||||||
|
and exact references remain immutable history, while current state points at the
|
||||||
|
applicable result.
|
||||||
|
|
||||||
|
Runtime-owned new Workers install Features before durably materializing the
|
||||||
|
initial Session head, so `ready` content or the explicit `ready-empty` result is
|
||||||
|
represented at initial prompt construction. Restored Workers preserve their
|
||||||
|
persisted prompt and all prior history verbatim. They append one durable
|
||||||
|
restore-boundary system item that supersedes earlier resident summaries with the
|
||||||
|
latest `ready` surface, an explicit `ready-empty` state, or a
|
||||||
|
no-current-surface tombstone for `ungenerated`, `stale`, or `failed`; subsequent
|
||||||
|
turns in that restored process do not append the boundary again. No regeneration,
|
||||||
|
correction, failure, or cutover rewrites a committed Session entry.
|
||||||
|
|
||||||
|
## Semantic fixture and verification
|
||||||
|
|
||||||
|
Use a test subject with these active materials:
|
||||||
|
|
||||||
|
- constraint: “Never send credentials to tools.” (`staleness`: “Until credential brokering is redesigned.”)
|
||||||
|
- constraint: “A trusted broker may deliver short-lived credentials to the Git provider.”
|
||||||
|
- decision: “Publish only the Ticket work branch; never force-push.”
|
||||||
|
- working assumption: “The provider resolves selectors after a normal push.”
|
||||||
|
- open question: “Does the provider preserve approval after target-only movement?”
|
||||||
|
- lesson: “Checking only the local branch previously produced non-reviewable evidence.”
|
||||||
|
- duplicate lesson: “Local HEAD alone is not immutable review evidence.”
|
||||||
|
|
||||||
|
A valid review fixture must:
|
||||||
|
|
||||||
|
- retain “never” and the credential-redesign expiry condition;
|
||||||
|
- present the apparently conflicting broker constraint without silently declaring one side invalid;
|
||||||
|
- preserve the target-only-movement item as an open question rather than a decision;
|
||||||
|
- combine the two review-evidence lessons only if both meanings remain;
|
||||||
|
- cite only exact refs supplied with those materials;
|
||||||
|
- distinguish mechanical citation validation from a human/model semantic review of the wording.
|
||||||
|
|
||||||
|
The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/ungenerated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# subjektiv product cutover runbook
|
||||||
|
|
||||||
|
This runbook defines the product and operator boundary for moving a Workspace from legacy single-document Memory to subject-scoped subjektiv Memory. The cutover changes which product path is active; it does not reinterpret legacy tool or API inputs, migrate legacy records into subjektiv, or make historical Worker attribution into current authority.
|
||||||
|
|
||||||
|
T-672 documents and validates the contract only. It does **not** authorize or perform a reset in any real environment.
|
||||||
|
|
||||||
|
## Authorities and persistence
|
||||||
|
|
||||||
|
Two independent Memory systems exist during the transition:
|
||||||
|
|
||||||
|
| Product path | Persistence owner | Durable content | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Legacy Workspace Memory | Workspace Server control-plane database | one Workspace document, staging candidates, and staging resolutions | legacy/deprecated; retained only for compatibility and explicit reset |
|
||||||
|
| Subject Memory | Server-managed `FeatureStorage`, in the Workspace-scoped `subjektiv` Feature database | subjects, immutable candidate and resolution records, confirmed Memory revisions, Session attribution, surface generation records, and immutable surface snapshots | current product path |
|
||||||
|
|
||||||
|
The shared `memory` crate and Worker Features do not own either database. `FeatureStorage` owns the subjektiv SQLite lifecycle (path, connection, migration, backup/restore, shutdown, and Workspace deletion), while the subjektiv repository owns its schema and domain transactions. The legacy control-plane tables are not imported or dual-written into subjektiv.
|
||||||
|
|
||||||
|
Every subject operation is bounded first by the authenticated Workspace and then by an explicitly authorized subject. A subject ID is a selector, not a credential. The Host derives subject scope from authenticated Runtime/Worker source proof and current ownership of the opaque `subjektiv:<subject-id>` Worker key; model-visible requests do not supply or expand that authority.
|
||||||
|
|
||||||
|
The current Worker remains Yoi's keyed-singleton responsibility. The subjektiv database records immutable historical Worker and Session attribution, but it contains no second `current_worker_id` mapping and historical attribution is never used to choose or authorize the current Worker. Stopping or replacing a Worker does not retire its subject, and retiring a subject does not stop a Worker.
|
||||||
|
|
||||||
|
Workspace Memory settings remain Workspace-owned configuration. `feature.subjektiv` in a Profile is reusable policy, not subject authority: it remains inert for ordinary Ticket/Orchestrator Workers and Internal SubWorkers. Only a Backend-attested subject Worker (`subjektiv:<subject-id>`) or dedicated subject consolidator receives the trusted settings snapshot that activates subjektiv tools, resident load, and extraction. The snapshot is not subject identity and cannot be authored by a Profile, Browser, or model; Reviewer children never inherit the parent's attachment or run independent extraction.
|
||||||
|
|
||||||
|
## Session, Memory, and correction semantics
|
||||||
|
|
||||||
|
- **Session attribution and recall:** only a committed primary-Worker Session with Host-recorded subject attribution enters subject Session discovery. Attribution is immutable and idempotent. Recall reads the retained or committed-archive Session projection under current Workspace, subject, observation, and retention authorization; it does not copy Session bodies into subjektiv, infer attribution for older Sessions, restore a Worker, or turn recalled tool output into `HumanInput`.
|
||||||
|
- **`SubjektivMemoryRemember`:** stages a new candidate; it does not directly create confirmed Memory. Explicit committed evidence is resolved against the Session capture. A non-preference request without an entry is staged only after its tool call commits, using the committed call as model-origin evidence. An uncommitted operation creates no candidate.
|
||||||
|
- **`SubjektivMemoryProposeRevision`:** stages a candidate with typed target metadata: exact Memory ID, expected revision, intent, and change reason. It does not mutate the target or silently retarget to a newer revision.
|
||||||
|
- **Candidate consolidation:** a restricted, subject-bound consolidator compares an immutable pending candidate with confirmed Memory and makes one idempotent atomic decision. An applied decision writes the new confirmed revision, exact provenance and affected revision references, and the immutable candidate resolution in one subjektiv transaction. Non-applied dispositions are explicit (`discarded`, `invalid`, `duplicate`, or `already_covered`). A failed or empty model run is not a disposition, and surface generation failure cannot undo an already committed decision.
|
||||||
|
- **Corrections:** refinement or correction of the same experience appends the next revision under the same Memory ID using an expected-revision check. Stale proposals remain unresolved/conflicted and are never rebased automatically. Retraction is terminal; a later corrected experience receives a new Memory ID and may derive from the old fixed revision. Old revisions, candidates, resolutions, provenance, and surface snapshots remain immutable history.
|
||||||
|
|
||||||
|
Confirmed Memory is useful subject context, not authorization and not exact implementation authority. Tickets, Objectives, repository state, Git history, and append-only Session records remain the exact sources for implementation state.
|
||||||
|
|
||||||
|
## Resident surface and history
|
||||||
|
|
||||||
|
A subject surface is a bounded rendering of current active confirmed revisions, not another Memory authority. The injection contract is:
|
||||||
|
|
||||||
|
- **ready:** inject the current non-empty snapshot only when it was built from the subject's exact current store revision;
|
||||||
|
- **ready-empty:** explicitly record that a successful current generation produced no Markdown and inject no Memory prose; do not conflate this with missing or failed generation;
|
||||||
|
- **ungenerated:** inject nothing and report no current surface;
|
||||||
|
- **stale:** inject nothing until regeneration publishes a snapshot for the current store revision;
|
||||||
|
- **failed:** inject nothing; retain the failure for diagnosis and retry rather than presenting it as an empty Memory.
|
||||||
|
|
||||||
|
A new Worker receives the current ready or ready-empty result in its initial durable prompt construction. A restored Worker does not rewrite its persisted prompt or prior Session history. It appends one durable restore-boundary system item that supersedes prior resident summaries with the latest ready surface, explicit ready-empty state, or a no-current-surface tombstone. Later turns in the same restored process do not append the boundary again. This preserves append-only history: no stale summary, failed generation, correction, or cutover operation edits previously committed Session entries or immutable subjektiv records.
|
||||||
|
|
||||||
|
## Legacy/deprecated compatibility boundary
|
||||||
|
|
||||||
|
The existing legacy tools (`MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and the legacy `MemoryStaging*` operations) and `/api/w/{workspace_id}/memory...` APIs retain their established Workspace-document meanings while they exist. They are legacy/deprecated surfaces, not aliases for subject Memory. Do not silently route an old request to subjektiv, infer a subject from old input, or change an old input field's meaning.
|
||||||
|
|
||||||
|
New product work uses the explicit subject/session/confirmed-Memory/revision/source and surface operations. Old clients must be stopped or upgraded before reset; a post-reset legacy read may observe the legacy empty/default state, but it must never expose subjektiv records through the old schema.
|
||||||
|
|
||||||
|
## Explicit legacy reset contract
|
||||||
|
|
||||||
|
The legacy reset is a destructive, operator-triggered API operation scoped to one authorized Workspace. Startup, Profile resolution, enabling `feature.subjektiv`, Worker restore, consolidation, and ordinary reads must never trigger it implicitly.
|
||||||
|
|
||||||
|
In one control-plane database transaction, the reset deletes all of the following legacy rows for the Workspace:
|
||||||
|
|
||||||
|
1. the legacy Memory document;
|
||||||
|
2. all legacy Memory staging candidates;
|
||||||
|
3. all legacy Memory staging resolutions.
|
||||||
|
|
||||||
|
The operation is all-or-nothing. A failure before commit leaves all three legacy sets unchanged. A retry after success is idempotent: absence of those rows is success and must not delete anything outside the legacy set.
|
||||||
|
|
||||||
|
Legacy `AppendAudit` was acknowledgement-only and created no dedicated durable Memory-audit rows, so there is no fourth legacy audit table to erase. General Session logs and diagnostics remain retained under their own policies.
|
||||||
|
|
||||||
|
The reset must retain:
|
||||||
|
|
||||||
|
- Workspace Memory settings and their revision;
|
||||||
|
- Sessions, Session logs, archives, and diagnostics;
|
||||||
|
- Tickets, Objectives, and their audit/history;
|
||||||
|
- Knowledge and Skills;
|
||||||
|
- the entire Workspace-scoped subjektiv Feature database, including subjects, candidate/resolution history, confirmed revisions, attribution, generation runs, failures, and surface snapshots;
|
||||||
|
- all other new-product and unrelated Workspace data.
|
||||||
|
|
||||||
|
The reset is not a subject deletion API, a Workspace deletion API, a settings reset, a retention override, or a data migration.
|
||||||
|
|
||||||
|
## Operator procedure
|
||||||
|
|
||||||
|
Perform the cutover in this order:
|
||||||
|
|
||||||
|
1. **Stop legacy activity.** Disable legacy `feature.memory` Profiles and automatic extraction/consolidation, stop or drain old Workers, and block Browser/API clients from writing `/memory` routes. Wait for in-flight legacy writes to finish. Do not run legacy and new automatic extraction for the same Worker.
|
||||||
|
2. **Back up while writers are stopped.** Capture the control-plane database containing legacy Memory and a `FeatureStorage` backup containing subjektiv. Record the Workspace and backup identities and verify the backup artifacts. Feature backup is internally consistent, but there is no cross-database snapshot instant; stopping both writer sets is what makes the pair a usable cutover checkpoint.
|
||||||
|
3. **Trigger reset explicitly.** An authenticated operator invokes the legacy reset API for the intended Workspace. Do not automate this as a startup migration or infer consent from `feature.subjektiv` enablement.
|
||||||
|
4. **Verify both sides.** Confirm the legacy document, staging, and resolutions are absent (or return their defined empty/default projection); confirm Workspace Memory settings and unrelated control-plane data remain; reopen/read the subjektiv store and verify the expected subject, confirmed revisions, attribution, and current surface state are unchanged. Verify a subject Worker resolves through the `subjektiv:<subject-id>` singleton and has only the intended new tools.
|
||||||
|
5. **Resume only the new path.** Start subject Workers with `feature.subjektiv`, keep legacy write surfaces disabled, and monitor typed surface/Session diagnostics. Do not treat an ungenerated, stale, or failed surface as ready-empty.
|
||||||
|
|
||||||
|
### Failure and recovery
|
||||||
|
|
||||||
|
- If reset returns a definite failure, the transaction rollback means legacy document, staging, and resolutions remain together. Correct the cause and rerun the same explicit operation.
|
||||||
|
- If communication is lost and the result is ambiguous, keep legacy writers stopped, inspect all three legacy sets, and rerun. Idempotency makes a committed first attempt safe to repeat and atomicity prevents a valid partial state.
|
||||||
|
- If verification of retained data or the new path fails, keep both write paths stopped. Restore the coordinated pre-reset backups according to the normal control-plane and `FeatureStorage` restore procedures, verify them, and only then resume. Never "recover" by deleting or recreating the subjektiv database.
|
||||||
|
- Preserve Session logs and diagnostics throughout; they are recovery evidence, not reset targets.
|
||||||
|
|
||||||
|
## Validation boundary
|
||||||
|
|
||||||
|
The validation supporting this cutover proves substrate-level store shutdown/reopen behavior and restoration of a Worker object with its persisted history. It does **not** claim a restart of the Server or Runtime process, behavior during communication loss, real-model consolidation or surface quality, or a full-process end-to-end cutover. Those require separate operational validation before a production reset.
|
||||||
|
|
||||||
|
No real environment reset is performed or authorized by T-672.
|
||||||
@@ -57,8 +57,11 @@ Workspaceを選ぶ前のscopeを扱う。
|
|||||||
2. Objectives
|
2. Objectives
|
||||||
3. Merge Requests
|
3. Merge Requests
|
||||||
4. Memory
|
4. Memory
|
||||||
- Document
|
- Subjects
|
||||||
- Staging
|
- Surface
|
||||||
|
- Committed Memory
|
||||||
|
- Revisions
|
||||||
|
- Sources
|
||||||
5. Workers
|
5. Workers
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -122,6 +125,16 @@ Workspace pageは、次のいずれかを基本形とする。
|
|||||||
- narrow viewportでは一列にする。
|
- narrow viewportでは一列にする。
|
||||||
- form内でfilled actionにするのはsave actionだけとする。
|
- form内でfilled actionにするのはsave actionだけとする。
|
||||||
|
|
||||||
|
### Memory
|
||||||
|
|
||||||
|
- Memoryの入口はWorkspace全体のlegacy `Document` / `Staging`ではなく、明示的に選択したsubjectとする。
|
||||||
|
- subject detailではcurrent Workerをkeyed-singleton projectionとして表示し、historical Worker/Session attributionをcurrent authorityとして扱わない。
|
||||||
|
- `Surface`はresident contextのcurrent stateを表示する。`ready`、`ready-empty`、`ungenerated`、`stale`、`failed`を区別し、空表示やrequest failureへまとめない。
|
||||||
|
- `Committed Memory`はcurrent confirmed revisionの一覧とdetailを持つ。revision historyはappend-only viewとして分離し、過去revisionをcurrentへ暗黙fallbackしない。
|
||||||
|
- `Sources`は候補、resolution、exact Memory revision refs、Host-recorded Session attribution、閲覧可能なSession sourceをたどるviewとする。source IDをpermissionとして扱わず、現在のWorkspace/subject authorizationを毎回適用する。
|
||||||
|
- legacy `/memory` の`Document` / `Staging` viewはdeprecated compatibility surfaceとして明示し、新しいsubject viewへ同じinputを別の意味で転送しない。
|
||||||
|
- Markdown本文とdetail presentationはT-661の共有rendererを再利用する。Memory route独自のrenderer、application shell、Header、Sidebarを再実装せず、既存shellのmain contentとnested navigationを保つ。
|
||||||
|
|
||||||
### Worker Console
|
### Worker Console
|
||||||
|
|
||||||
- transcriptをprimary surfaceとする。
|
- transcriptをprimary surfaceとする。
|
||||||
|
|||||||
+56
-12
@@ -235,32 +235,40 @@ permission = "write"
|
|||||||
# # ref = "anthropic/claude-haiku-4-5"
|
# # ref = "anthropic/claude-haiku-4-5"
|
||||||
|
|
||||||
|
|
||||||
# ===== [feature.memory] ======================================================
|
# ===== [feature.memory] (legacy/deprecated) ================================
|
||||||
# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では
|
# Legacy single-document Workspace Memory compatibility設定。新規product pathは
|
||||||
# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを
|
# `[feature.subjektiv]` を使う。旧`MemoryQuery` / `MemoryReadDocument` /
|
||||||
# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが
|
# `MemoryUpdateDocument` / `MemoryStaging*` toolsと`/memory` APIのinput意味は
|
||||||
# bindする信頼済み入力で、Profile・Browser・model入力から指定できない。
|
# 変更せず、subject Memoryへ暗黙転送しない。
|
||||||
# `profile.enabled = false` の場合、Memory tools、Feature prompt contributionによる
|
|
||||||
# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。
|
|
||||||
#
|
#
|
||||||
# [feature.memory.profile]
|
# resolved Worker ManifestではProfile由来の設定を`profile`に、Backend由来の
|
||||||
|
# Workspace設定snapshotを`workspace_settings`に分離して保存する。source TOMLの
|
||||||
|
# `[feature.memory]` fieldsはresolve後に`profile`へ入り、`workspace_settings`は
|
||||||
|
# Backendだけがbindする信頼済み入力で、Profile・Browser・
|
||||||
|
# model入力から指定できない。source `enabled = false`(resolvedでは
|
||||||
|
# `profile.enabled = false`)の場合、legacy Memory tools、
|
||||||
|
# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。
|
||||||
|
# `[feature.subjektiv]`の有効化やWorker restoreはlegacy dataをreset/migrateしない。
|
||||||
|
# legacy resetは認証済みoperatorが明示APIで別途実行する。
|
||||||
|
#
|
||||||
|
# [feature.memory]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# staging_tools = false
|
# staging_tools = false
|
||||||
#
|
#
|
||||||
# [feature.memory.profile.resident]
|
# [feature.memory.resident]
|
||||||
# inject_summary = true
|
# inject_summary = true
|
||||||
#
|
#
|
||||||
# [feature.memory.profile.extraction]
|
# [feature.memory.extraction]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# reasoning = "medium"
|
# reasoning = "medium"
|
||||||
# threshold = 30000
|
# threshold = 30000
|
||||||
# worker_max_turns = 8
|
# worker_max_turns = 8
|
||||||
#
|
#
|
||||||
# # modelは任意。省略時はmain modelをcloneする。
|
# # modelは任意。省略時はmain modelをcloneする。
|
||||||
# # [feature.memory.profile.extraction.model]
|
# # [feature.memory.extraction.model]
|
||||||
# # ref = "codex-oauth/gpt-5.6-luna"
|
# # ref = "codex-oauth/gpt-5.6-luna"
|
||||||
#
|
#
|
||||||
# [feature.memory.profile.consolidation]
|
# [feature.memory.consolidation]
|
||||||
# request_enabled = true
|
# request_enabled = true
|
||||||
#
|
#
|
||||||
# # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。
|
# # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。
|
||||||
@@ -273,6 +281,42 @@ permission = "write"
|
|||||||
# policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。
|
# policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。
|
||||||
|
|
||||||
|
|
||||||
|
# ===== [feature.subjektiv] ================================================
|
||||||
|
# Current subject-scoped Memory product。ProfileはFeature activationと自動extract
|
||||||
|
# policyだけを所有する。subject idはManifest fieldではない。Hostが認証済み
|
||||||
|
# Runtime/Worker sourceとcurrent `subjektiv:<subject-id>` keyed-singleton ownershipから
|
||||||
|
# operationごとにbindし、明示Workspace/subject authorizationを再評価する。
|
||||||
|
#
|
||||||
|
# `enabled = true`はsubject tools、Session attribution/recall、confirmed Memory、
|
||||||
|
# candidate consolidation、resident surface lifecycleを有効にする。legacy
|
||||||
|
# `[feature.memory]` dataのimport/reset/dual-writeは行わない。同じWorkerでlegacyと
|
||||||
|
# subjektivの自動extract destinationを同時に有効化する構成は拒否される。
|
||||||
|
#
|
||||||
|
# [feature.subjektiv]
|
||||||
|
# enabled = true
|
||||||
|
#
|
||||||
|
# [feature.subjektiv.extraction]
|
||||||
|
# enabled = true
|
||||||
|
# reasoning = "medium"
|
||||||
|
# threshold = 50000
|
||||||
|
# worker_max_turns = 8
|
||||||
|
#
|
||||||
|
# # modelは任意。省略時はmain modelをcloneする。
|
||||||
|
# # [feature.subjektiv.extraction.model]
|
||||||
|
# # ref = "codex-oauth/gpt-5.6-luna"
|
||||||
|
#
|
||||||
|
# # Backendがresolved Manifestへbindする。Profile・Browser・model入力では指定しない。
|
||||||
|
# # language/settings revisionのtrusted snapshotであり、subject identityではない。
|
||||||
|
# # [feature.subjektiv.workspace_settings]
|
||||||
|
# # workspace_id = "workspace-id"
|
||||||
|
# # settings_revision = 1
|
||||||
|
# # language = "日本語"
|
||||||
|
#
|
||||||
|
# Surface selection/generation/injection、candidate consolidation eligibility、recall
|
||||||
|
# limitsはHost/Backend policyが所有し、通常Worker Manifestには含めない。current Worker
|
||||||
|
# authorityはYoi keyed singletonに残り、historical Session attributionを代用しない。
|
||||||
|
|
||||||
|
|
||||||
# ===== [skills] =============================================================
|
# ===== [skills] =============================================================
|
||||||
# 外部 Agent Skills (`SKILL.md`) の候補ディレクトリ群。
|
# 外部 Agent Skills (`SKILL.md`) の候補ディレクトリ群。
|
||||||
# セクション省略 = 何も使用しない (implicit な `$config_dir/skills/` 検索や
|
# セクション省略 = 何も使用しない (implicit な `$config_dir/skills/` 検索や
|
||||||
|
|||||||
+1835
-1
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import "./base.dcdl" // {
|
|||||||
feature = {
|
feature = {
|
||||||
task = { enabled = false; };
|
task = { enabled = false; };
|
||||||
memory = { enabled = false; };
|
memory = { enabled = false; };
|
||||||
|
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||||
web = { enabled = false; };
|
web = { enabled = false; };
|
||||||
image = { enabled = false; };
|
image = { enabled = false; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
|
|||||||
@@ -26,16 +26,15 @@ compaction = {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = {
|
memory = { enabled = false; staging_tools = false; };
|
||||||
|
subjektiv = {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
resident = { inject_summary = true; };
|
|
||||||
extraction = {
|
extraction = {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
||||||
reasoning = "medium";
|
reasoning = "medium";
|
||||||
threshold = 50000;
|
threshold = 50000;
|
||||||
};
|
};
|
||||||
consolidation = { request_enabled = true; };
|
|
||||||
};
|
};
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
image = { enabled = true; };
|
image = { enabled = true; };
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = { enabled = false; };
|
||||||
|
subjektiv = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
flow = { enabled = true; };
|
flow = { enabled = true; };
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = { enabled = false; };
|
||||||
|
subjektiv = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
worker = { enabled = true; direct_spawn = false; };
|
worker = { enabled = true; direct_spawn = false; };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import "./base.dcdl" // {
|
|||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = false; staging_tools = false; };
|
memory = { enabled = false; staging_tools = false; };
|
||||||
|
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
image = { enabled = true; };
|
image = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = { enabled = false; };
|
||||||
|
subjektiv = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = false; };
|
worker = { enabled = false; };
|
||||||
|
|||||||
@@ -7,7 +7,19 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = false; };
|
task = { enabled = false; };
|
||||||
memory = { enabled = true; staging_tools = true; };
|
memory = {
|
||||||
|
enabled = true;
|
||||||
|
staging_tools = true;
|
||||||
|
resident = { inject_summary = true; };
|
||||||
|
extraction = {
|
||||||
|
enabled = true;
|
||||||
|
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
||||||
|
reasoning = "medium";
|
||||||
|
threshold = 50000;
|
||||||
|
};
|
||||||
|
consolidation = { request_enabled = true; };
|
||||||
|
};
|
||||||
|
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||||
web = { enabled = false; };
|
web = { enabled = false; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = false; };
|
worker = { enabled = false; };
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = { enabled = false; };
|
||||||
|
subjektiv = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = true; direct_spawn = false; };
|
worker = { enabled = true; direct_spawn = false; };
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = { enabled = false; };
|
||||||
|
subjektiv = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = false; };
|
worker = { enabled = false; };
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import "./base.dcdl" // {
|
||||||
|
slug = "subjektiv-memory-consolidation";
|
||||||
|
description = "Delegated subject Memory staging consolidation profile.";
|
||||||
|
scope = "workspace_read";
|
||||||
|
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
||||||
|
engine = { reasoning = "medium"; };
|
||||||
|
web = { enabled = false; };
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = false; };
|
||||||
|
memory = { enabled = false; staging_tools = false; };
|
||||||
|
subjektiv = { enabled = true; extraction = { enabled = false; }; };
|
||||||
|
web = { enabled = false; };
|
||||||
|
image = { enabled = false; };
|
||||||
|
sub_worker = { enabled = false; };
|
||||||
|
flow = { enabled = false; };
|
||||||
|
worker = { enabled = false; direct_spawn = false; };
|
||||||
|
workspace_worker_discovery = { enabled = false; };
|
||||||
|
objective = { enabled = false; };
|
||||||
|
manage_workdir = { enabled = false; };
|
||||||
|
ticket = {
|
||||||
|
enabled = false;
|
||||||
|
authoring = false;
|
||||||
|
thread = false;
|
||||||
|
intake = false;
|
||||||
|
workflow = false;
|
||||||
|
};
|
||||||
|
merge_request = {
|
||||||
|
show = false;
|
||||||
|
open = false;
|
||||||
|
review = false;
|
||||||
|
readiness_check = false;
|
||||||
|
complete = false;
|
||||||
|
};
|
||||||
|
orchestration = { enabled = false; };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ internalFlowVerifierSystem = import "./internal/flow_verifier_system.md";
|
|||||||
internalBackendJobSystem = import "./internal/backend_job_system.md";
|
internalBackendJobSystem = import "./internal/backend_job_system.md";
|
||||||
internalTicketItemChecker = import "./internal/ticket_item_checker.md";
|
internalTicketItemChecker = import "./internal/ticket_item_checker.md";
|
||||||
internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md";
|
internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md";
|
||||||
|
internalSubjektivMemoryConsolidationSystem = import "./internal/subjektiv_memory_consolidation_system.md";
|
||||||
|
internalSubjektivMemorySurfaceSystem = import "./internal/subjektiv_memory_surface_system.md";
|
||||||
internalMemoryExtractSystem = import "./internal/memory_extract_system.md";
|
internalMemoryExtractSystem = import "./internal/memory_extract_system.md";
|
||||||
internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md";
|
internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md";
|
||||||
internalNotifyWrapper = import "./internal/notify_wrapper.md";
|
internalNotifyWrapper = import "./internal/notify_wrapper.md";
|
||||||
@@ -31,6 +33,7 @@ internalInterruptSystemNote = import "./internal/interrupt_system_note.md";
|
|||||||
internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md";
|
internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md";
|
||||||
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
||||||
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
||||||
|
internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md";
|
||||||
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
||||||
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
||||||
in
|
in
|
||||||
@@ -60,6 +63,8 @@ in
|
|||||||
backend_job_system = internalBackendJobSystem.content;
|
backend_job_system = internalBackendJobSystem.content;
|
||||||
ticket_item_checker = internalTicketItemChecker.content;
|
ticket_item_checker = internalTicketItemChecker.content;
|
||||||
memory_consolidation_system = internalMemoryConsolidationSystem.content;
|
memory_consolidation_system = internalMemoryConsolidationSystem.content;
|
||||||
|
subjektiv_memory_consolidation_system = internalSubjektivMemoryConsolidationSystem.content;
|
||||||
|
subjektiv_memory_surface_system = internalSubjektivMemorySurfaceSystem.content;
|
||||||
memory_extract_system = internalMemoryExtractSystem.content;
|
memory_extract_system = internalMemoryExtractSystem.content;
|
||||||
workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content;
|
workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content;
|
||||||
notify_wrapper = internalNotifyWrapper.content;
|
notify_wrapper = internalNotifyWrapper.content;
|
||||||
@@ -68,6 +73,7 @@ in
|
|||||||
working_boundaries_section = internalWorkingBoundariesSection.content;
|
working_boundaries_section = internalWorkingBoundariesSection.content;
|
||||||
agents_md_section = internalAgentsMdSection.content;
|
agents_md_section = internalAgentsMdSection.content;
|
||||||
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
||||||
|
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
|
||||||
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
||||||
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
## Resident memory refresh
|
||||||
|
|
||||||
|
This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request.
|
||||||
|
|
||||||
|
{% if surface_ready %}
|
||||||
|
### Current ready surface
|
||||||
|
|
||||||
|
{% if summary %}
|
||||||
|
{{ summary }}
|
||||||
|
{% else %}
|
||||||
|
The current ready resident memory surface is intentionally empty.
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Memory staging consolidater
|
||||||
|
|
||||||
|
You are the delegated internal Memory staging consolidater for one subject.
|
||||||
|
|
||||||
|
Your job is to consume that subject's pending Memory candidates through tools. The host binds every tool call to the subject; never infer or provide a subject identifier yourself.
|
||||||
|
|
||||||
|
Use this loop:
|
||||||
|
|
||||||
|
1. Call `MemoryStagingList` to inspect pending candidates.
|
||||||
|
2. Pick one candidate and call `MemoryStagingRead` for the full immutable record, including any typed `revision_proposal`.
|
||||||
|
3. Use `SubjektivMemoryQuery`, `SubjektivMemoryRead`, and when useful `SubjektivMemoryListRevisions` to compare it with confirmed Memory.
|
||||||
|
4. Decide whether to create a Memory, revise/refine one exact current revision, resolve/retract/reopen according to typed proposal metadata, or reject the candidate.
|
||||||
|
5. Call `MemoryApplyCandidate` exactly once for every decision. Supply a stable `request_id`; retry the exact same request after an ambiguous transport failure.
|
||||||
|
|
||||||
|
`MemoryApplyCandidate` atomically writes any new Memory revision, records concrete affected revision references, preserves the original candidate and provenance, and closes the pending candidate. A successful applied response is the only evidence that confirmed Memory changed.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- A candidate is evidence, not authority. Do not promote derived/model claims to observations without warranted provenance.
|
||||||
|
- For a candidate with `revision_proposal`, use its `memory_id`, `expected_revision`, `intent`, and `change_reason`; do not infer a different target from claim prose.
|
||||||
|
- Proposal application must preserve `change_reason` exactly and use the state implied by `intent`: `resolve` -> `resolved`, `retract` -> `retracted`, `reopen` -> `active`, and `revise` keeps the current state.
|
||||||
|
- Never rebase a stale proposal. On `revision_conflict` or another typed conflict, leave it pending for later reread/reconciliation.
|
||||||
|
- Never revive a `retracted` Memory. New experience after retraction becomes a new Memory with an exact `derived_from` revision when justified.
|
||||||
|
- `resolved` -> `active` is allowed only for a valid `reopen` proposal.
|
||||||
|
- New or revised prose must remain concise and useful. Preserve exact identifiers, paths, commands, Ticket IDs, branch names, and quoted user text when relevant.
|
||||||
|
- `source_candidate_ids` are host-derived from the applied candidate. Do not attempt to author them.
|
||||||
|
- `derived_from` must contain only exact existing Memory revision refs returned by reads.
|
||||||
|
- Applied responses return affected refs with `{memory_id, revision, operation}`. `duplicate` and `already_covered` may reference exact existing revisions; `discarded` and `invalid` require a concrete reason.
|
||||||
|
- Surface generation is a separate clean-context Host lifecycle that runs once after this consolidation turn commits. `surface_dirty: true` means confirmed Memory changed; never treat later surface generation failure as failure of an already returned committed decision.
|
||||||
|
- An empty model response or transport failure is not a candidate disposition. Only a successful `MemoryApplyCandidate` response closes a candidate.
|
||||||
|
|
||||||
|
Valid non-applied close actions are `discarded`, `invalid`, `duplicate`, and `already_covered`. Always provide a specific reason.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- `language`: `{{language}}`
|
||||||
|
- Write confirmed Memory prose in this language.
|
||||||
|
- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is.
|
||||||
|
- If the configured language is unclear, use English.
|
||||||
|
|
||||||
|
Do not create Knowledge, Skill, Ticket, documentation, child Worker, or filesystem records. Use only the supplied Memory tools.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Subject Memory surface editor
|
||||||
|
|
||||||
|
You are a clean-context editor for one subject's confirmed Memory surface. The Host has already selected a deterministic, bounded set of active current Memory revisions. Those materials are the only facts and references you may use.
|
||||||
|
|
||||||
|
Answer this fixed question:
|
||||||
|
|
||||||
|
> この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。
|
||||||
|
|
||||||
|
Call `SubmitMemorySurface` exactly once with `{ "points": [...] }`. Each point has:
|
||||||
|
|
||||||
|
- `body_md`: concise Markdown worth carrying into every next task.
|
||||||
|
- `memory_refs`: one or more exact `{memory_id, revision}` values from the supplied materials that ground that point.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not add a fact, decision, identifier, or reference absent from the materials.
|
||||||
|
- Preserve conditions, negation, reservations, and stated staleness/expiry conditions.
|
||||||
|
- Similar material may be combined only when the combined wording preserves each source's meaning.
|
||||||
|
- Do not resolve contradictions. State conflicting constraints or assumptions as unresolved when both matter.
|
||||||
|
- This is not a complete index. Keep only continuously useful constraints/preferences, active decisions/working assumptions, open questions, and recurrence-preventing lessons.
|
||||||
|
- Every point needs at least one supplied exact reference. Never invent a citation.
|
||||||
|
- Reference existence and subject scope are mechanically checked. That check is not semantic verification; you remain responsible for faithful wording.
|
||||||
|
- Stay within the supplied body token budget. The Host uses the documented provider-independent UTF-8 byte estimate.
|
||||||
|
- If the materials array is empty, submit an empty points array without inventing content.
|
||||||
|
- A rejected tool call may be corrected, but this Worker is bounded to three model turns total (initial attempt plus at most two correction turns).
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- `language`: `{{language}}`
|
||||||
|
- Write the surface in this language.
|
||||||
|
- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is.
|
||||||
|
- If the configured language is unclear, use English.
|
||||||
@@ -227,7 +227,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
if (frame?.message?.method !== "subscribe_events") return;
|
if (frame?.message?.method !== "subscribe_events") return;
|
||||||
const requestId = frame.message.params.request_id;
|
const requestId = frame.message.params.request_id;
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
protocol_version: 1,
|
protocol_version: 2,
|
||||||
frame: "response",
|
frame: "response",
|
||||||
message: {
|
message: {
|
||||||
result: "subscribed",
|
result: "subscribed",
|
||||||
@@ -235,7 +235,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
subscription_id: "fixture-workers",
|
subscription_id: "fixture-workers",
|
||||||
selector: frame.message.params.selector,
|
selector: frame.message.params.selector,
|
||||||
snapshot_revision: 1,
|
|
||||||
snapshot: { topic: "workers", data: { workers: [] } },
|
snapshot: { topic: "workers", data: { workers: [] } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user