chore: merge develop into hare/develop

This commit is contained in:
2026-10-03 19:14:17 +09:00
147 changed files with 33175 additions and 2270 deletions
+3
View File
@@ -30,6 +30,9 @@ pub enum ToolError {
/// Internal error
#[error("Internal error: {0}")]
Internal(String),
/// Machine-readable domain conflict that a tool adapter may project as structured output.
#[error("{code}: {message}")]
StructuredConflict { code: String, message: String },
/// Cooperative cancellation completed with bounded terminal output.
#[error("Tool execution cancelled")]
Cancelled(ToolOutput),
+135 -2
View File
@@ -3,8 +3,11 @@ use server_api::{
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
};
use ticket::{
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
@@ -410,6 +413,97 @@ impl BackendWorkspaceProductClient {
crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit)
}
pub fn list_subjektiv_subjects(
&self,
limit: usize,
) -> Result<SubjektivSubjectListResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
self.generated(move |client| async move {
client
.subjektiv_subject_list(
workspace_id,
SubjektivSubjectListQuery {
limit: Some(limit),
cursor: None,
},
)
.await
})
}
pub fn subjektiv_subject(
&self,
subject_id: &str,
) -> Result<SubjektivSubjectResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string();
self.generated(move |client| async move {
client.subjektiv_subject_get(workspace_id, subject_id).await
})
}
pub fn subjektiv_resident_surface(
&self,
subject_id: &str,
) -> Result<SubjektivResidentSurfaceResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string();
self.generated(move |client| async move {
client
.subjektiv_resident_surface(workspace_id, subject_id)
.await
})
}
pub fn list_subjektiv_memories(
&self,
subject_id: &str,
query: &SubjektivMemoryListQuery,
) -> Result<SubjektivMemoryQueryResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string();
let query = query.clone();
self.generated(move |client| async move {
client
.subjektiv_memory_list(workspace_id, subject_id, query)
.await
})
}
pub fn subjektiv_memory(
&self,
subject_id: &str,
memory_id: &str,
query: &SubjektivMemoryDetailQuery,
) -> Result<SubjektivMemoryReadResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string();
let memory_id = memory_id.to_string();
let query = query.clone();
self.generated(move |client| async move {
client
.subjektiv_memory_detail(workspace_id, subject_id, memory_id, query)
.await
})
}
pub fn list_subjektiv_memory_revisions(
&self,
subject_id: &str,
memory_id: &str,
query: &SubjektivMemoryRevisionsQuery,
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string();
let memory_id = memory_id.to_string();
let query = query.clone();
self.generated(move |client| async move {
client
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query)
.await
})
}
pub fn launch_ticket_intake(
&self,
ticket_id: &str,
@@ -917,6 +1011,45 @@ mod tests {
handle.join().unwrap();
}
#[test]
fn subjektiv_memory_list_encodes_typed_filters_as_bounded_query_scalars() {
let (base_url, request, handle) =
one_response_server("200 OK", r#"{"items":[],"has_more":false}"#);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client
.list_subjektiv_memories(
"subject/a",
&SubjektivMemoryListQuery {
query: Some("stable cursor".to_string()),
kinds: None,
states: Some(vec![
server_api::SubjektivMemoryState::Active,
server_api::SubjektivMemoryState::Resolved,
]),
limit: Some(25),
cursor: None,
},
)
.unwrap();
assert!(response.items.is_empty());
let request = request.recv().unwrap();
assert!(
request.starts_with("GET /api/w/workspace-a/subjektiv/subjects/subject%2Fa/memories?")
);
assert!(request.contains("query=stable+cursor"));
assert!(request.contains("states=active%2Cresolved"));
assert!(request.contains("limit=25"));
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
handle.join().unwrap();
}
#[test]
fn objective_list_uses_workspace_scoped_backend_route() {
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#;
+7
View File
@@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
description: "Internal Memory consolidation service.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:subjektiv-memory-consolidation"),
path: "profiles/subjektiv-memory-consolidation.dcdl",
source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"),
description: "Delegated subject Memory consolidation service.",
imports: BASE_IMPORT,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
+104 -3
View File
@@ -19,9 +19,10 @@ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ResolvedSubjektivFeatureConfig,
ScopeConfig, SessionConfig, SkillsConfig, SubjektivFeatureProfileConfig, TicketFeatureConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta,
};
/// Partial-form Worker manifest. Every field is optional; one or more
@@ -76,6 +77,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub memory: Option<MemoryFeatureConfigPartial>,
#[serde(default)]
pub subjektiv: Option<SubjektivFeatureConfigPartial>,
#[serde(default)]
pub web: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub image: Option<FeatureFlagConfigPartial>,
@@ -104,6 +107,11 @@ impl FeatureConfigPartial {
Self {
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
subjektiv: merge_option(
self.subjektiv,
other.subjektiv,
SubjektivFeatureConfigPartial::merge,
),
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
sub_worker: merge_option(
@@ -227,6 +235,28 @@ pub struct MemoryConsolidationProfileConfigPartial {
pub request_enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubjektivFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
impl SubjektivFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
extraction: merge_option(
self.extraction,
other.extraction,
MemoryExtractionProfileConfigPartial::merge,
),
}
}
}
impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
@@ -331,6 +361,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.memory
.map(ResolvedMemoryFeatureConfig::from)
.unwrap_or_default(),
subjektiv: value
.subjektiv
.map(ResolvedSubjektivFeatureConfig::from)
.unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
sub_worker: value
@@ -447,6 +481,42 @@ impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
}
}
impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
fn from(value: SubjektivFeatureConfigPartial) -> Self {
let extraction = value.extraction.unwrap_or_default();
Self {
profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
reasoning: extraction.reasoning,
threshold: extraction.threshold.or(Some(50_000)),
worker_max_turns: extraction
.worker_max_turns
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
},
},
workspace_settings: None,
}
}
}
impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
fn from(value: ResolvedSubjektivFeatureConfig) -> Self {
Self {
enabled: Some(value.profile.enabled),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
reasoning: value.profile.extraction.reasoning,
threshold: value.profile.extraction.threshold,
worker_max_turns: value.profile.extraction.worker_max_turns,
}),
}
}
}
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
fn from(value: TicketFeatureConfigPartial) -> Self {
Self {
@@ -500,6 +570,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
Self {
task: Some(value.task.into()),
memory: Some(value.memory.into()),
subjektiv: Some(value.subjektiv.into()),
web: Some(value.web.into()),
image: Some(value.image.into()),
sub_worker: Some(value.sub_worker.into()),
@@ -2490,6 +2561,36 @@ directories = [".claude/skills", ".cursor/skills"]
);
}
#[test]
fn subjektiv_config_is_explicit_and_roundtrips_without_subject_identity() {
let config = WorkerManifestConfig::from_toml(
r#"
[worker]
name = "subject-worker"
[feature.subjektiv]
enabled = true
[feature.subjektiv.extraction]
enabled = true
threshold = 321
worker_max_turns = 7
"#,
)
.unwrap();
let partial = config.feature.subjektiv.unwrap();
assert_eq!(partial.enabled, Some(true));
assert_eq!(partial.extraction.as_ref().unwrap().threshold, Some(321));
let resolved = ResolvedSubjektivFeatureConfig::from(partial);
assert!(resolved.profile.enabled);
assert_eq!(resolved.profile.extraction.threshold, Some(321));
assert_eq!(resolved.profile.extraction.worker_max_turns, Some(7));
let encoded = serde_json::to_value(SubjektivFeatureConfigPartial::from(resolved)).unwrap();
assert!(encoded.get("subject_id").is_none());
assert_eq!(encoded["enabled"], true);
}
#[test]
fn merge_preserves_ref() {
let lower = WorkerManifestConfig {
+109 -1
View File
@@ -108,6 +108,11 @@ pub struct FeatureConfig {
pub task: FeatureFlagConfig,
#[serde(default)]
pub memory: ResolvedMemoryFeatureConfig,
/// Subject-scoped Memory extraction. Subject identity is resolved by the
/// Workspace host from the Worker's keyed singleton lease, never from this
/// model-visible manifest.
#[serde(default)]
pub subjektiv: ResolvedSubjektivFeatureConfig,
#[serde(default)]
pub web: FeatureFlagConfig,
#[serde(default)]
@@ -139,6 +144,7 @@ impl Default for FeatureConfig {
Self {
task: FeatureFlagConfig::disabled(),
memory: ResolvedMemoryFeatureConfig::default(),
subjektiv: ResolvedSubjektivFeatureConfig::default(),
web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(),
@@ -350,6 +356,85 @@ impl ResolvedMemoryFeatureConfig {
}
}
/// Profile-owned subjektiv activation and extraction policy. The subject id is
/// deliberately absent: the Workspace host binds it from `subjektiv:<id>`
/// singleton ownership for each operation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct SubjektivFeatureProfileConfig {
pub enabled: bool,
pub extraction: MemoryExtractionProfileConfig,
}
impl Default for SubjektivFeatureProfileConfig {
fn default() -> Self {
Self {
enabled: false,
extraction: MemoryExtractionProfileConfig::default(),
}
}
}
/// Immutable subjektiv execution configuration persisted in a resolved Worker
/// Manifest. Workspace settings provide only trusted extraction language and
/// are not subject identity.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub struct ResolvedSubjektivFeatureConfig {
pub profile: SubjektivFeatureProfileConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
}
impl ResolvedSubjektivFeatureConfig {
pub fn enabled(&self) -> bool {
self.profile.enabled
}
/// Returns whether the host attached this profile policy to trusted subject
/// authority. Profile enablement without a settings snapshot is inert.
pub fn execution_enabled(&self) -> bool {
self.profile.enabled && self.workspace_settings.is_some()
}
pub fn bind_workspace_settings(
&mut self,
settings: WorkspaceMemorySettingsSnapshot,
) -> Result<(), &'static str> {
if !self.profile.enabled {
if self.workspace_settings.is_some() {
return Err("disabled subjektiv feature must not carry Workspace settings");
}
return Ok(());
}
if self.workspace_settings.is_some() {
return Err("subjektiv Workspace settings are already bound");
}
self.workspace_settings = Some(settings);
Ok(())
}
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
self.workspace_settings.clone()
}
pub fn validate_execution(&self) -> Result<(), &'static str> {
// `profile.enabled` is reusable policy. Only a host-bound settings
// snapshot activates subject execution; an ordinary Worker or Internal
// SubWorker with the same Profile remains inert.
if !self.profile.enabled && self.workspace_settings.is_some() {
return Err("disabled subjektiv feature must not carry Workspace settings");
}
if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0
|| !is_normalized_workspace_memory_language(&settings.language))
{
return Err("subjektiv Workspace settings snapshot metadata is invalid");
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketFeatureConfig {
#[serde(default)]
@@ -963,7 +1048,9 @@ impl Default for CompactionConfig {
impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
self.profile.is_some()
|| self.feature.memory.workspace_settings.is_some()
|| self.feature.subjektiv.workspace_settings.is_some()
}
/// Parse a manifest from a TOML string.
@@ -1638,6 +1725,27 @@ model_id = "claude-sonnet-4-20250514"
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedSubjektivFeatureConfig::default();
enabled.profile.enabled = true;
assert!(enabled.validate_execution().is_ok());
assert!(!enabled.execution_enabled());
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
assert!(enabled.validate_execution().is_ok());
assert!(enabled.execution_enabled());
let mut disabled = ResolvedSubjektivFeatureConfig::default();
disabled.workspace_settings = Some(snapshot.clone());
assert!(disabled.validate_execution().is_err());
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn current_manifest_rejects_legacy_top_level_memory_authority() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
+123 -9
View File
@@ -144,6 +144,7 @@ pub enum WorkspaceAuthorityRequirement {
Flow,
ManageWorkdir,
Memory,
Subjektiv,
MergeRequest,
Objective,
Orchestration,
@@ -157,6 +158,7 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
Self::Flow => formatter.write_str("feature.flow"),
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
Self::Memory => formatter.write_str("feature.memory"),
Self::Subjektiv => formatter.write_str("feature.subjektiv"),
Self::MergeRequest => formatter.write_str("feature.merge_request"),
Self::Objective => formatter.write_str("feature.objective"),
Self::Orchestration => formatter.write_str("feature.orchestration"),
@@ -185,6 +187,9 @@ pub fn validate_profile_execution_target(
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
requirements.insert(WorkspaceAuthorityRequirement::Memory);
}
if feature.subjektiv.profile.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
}
if feature.merge_request.show
|| feature.merge_request.open
|| feature.merge_request.review
@@ -1252,6 +1257,64 @@ mod tests {
}
}
#[test]
fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() {
let tmp = TempDir::new().unwrap();
let resolve = |name: &str| {
ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, name),
ProfileResolveOptions::with_worker_name(format!("{name}-worker")),
)
.unwrap()
.manifest
};
for name in ["coder", "companion", "intake", "reviewer", "orchestrator"] {
let manifest = resolve(name);
assert!(
manifest.feature.subjektiv.profile.enabled,
"{name} must enable subjektiv"
);
assert!(
manifest.feature.subjektiv.profile.extraction.enabled,
"{name} must enable subject extraction"
);
assert!(
!manifest.feature.memory.profile.enabled,
"{name} must not enable legacy Workspace Memory"
);
}
for name in ["default", "standalone", "backend-job"] {
let manifest = resolve(name);
assert!(!manifest.feature.memory.profile.enabled, "{name}");
assert!(!manifest.feature.subjektiv.profile.enabled, "{name}");
}
let legacy_consolidator = resolve("memory-consolidation");
assert!(legacy_consolidator.feature.memory.profile.enabled);
assert!(legacy_consolidator.feature.memory.profile.staging_tools);
assert!(
legacy_consolidator
.feature
.memory
.profile
.extraction
.enabled
);
assert!(
legacy_consolidator
.feature
.memory
.profile
.consolidation
.request_enabled
);
assert!(!legacy_consolidator.feature.subjektiv.profile.enabled);
}
#[test]
fn builtin_profiles_pin_role_models_and_reasoning() {
use crate::model::{ReasoningControl, ReasoningEffort};
@@ -1276,6 +1339,7 @@ mod tests {
"coder",
"reviewer",
"memory-consolidation",
"subjektiv-memory-consolidation",
] {
let role = resolve(name);
assert_eq!(
@@ -1308,7 +1372,7 @@ mod tests {
role.manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::High))
);
let extraction = &role.manifest.feature.memory.profile.extraction;
let extraction = &role.manifest.feature.subjektiv.profile.extraction;
assert_eq!(
extraction
.model
@@ -1322,14 +1386,63 @@ mod tests {
);
}
let consolidation = resolve("memory-consolidation");
for name in ["memory-consolidation", "subjektiv-memory-consolidation"] {
let consolidation = resolve(name);
assert_eq!(
consolidation.manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.6-luna")
);
assert_eq!(
consolidation.manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
);
}
}
#[test]
fn subjektiv_memory_consolidation_profile_is_lifecycle_and_capability_isolated() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(
ProfileRegistrySource::Builtin,
"subjektiv-memory-consolidation",
),
ProfileResolveOptions::with_worker_name("subject-consolidator"),
)
.unwrap();
let manifest = resolved.manifest;
let feature = &manifest.feature;
assert!(!feature.memory.profile.enabled);
assert!(!feature.memory.profile.staging_tools);
assert!(feature.subjektiv.profile.enabled);
assert!(!feature.subjektiv.profile.extraction.enabled);
assert!(!feature.task.enabled);
assert!(!feature.web.enabled);
assert!(!feature.image.enabled);
assert!(!feature.sub_worker.enabled);
assert!(!feature.flow.enabled);
assert!(!feature.worker.enabled);
assert!(!feature.worker.direct_spawn);
assert!(!feature.workspace_worker_discovery.enabled);
assert!(!feature.objective.enabled);
assert!(!feature.manage_workdir.enabled);
assert!(!feature.ticket.enabled);
assert!(!feature.ticket.authoring);
assert!(!feature.ticket.thread);
assert!(!feature.ticket.intake);
assert!(!feature.ticket.workflow);
assert!(!feature.merge_request.show);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
assert!(!feature.merge_request.readiness_check);
assert!(!feature.merge_request.complete);
assert!(!feature.orchestration.enabled);
assert_eq!(
consolidation.manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.6-luna")
);
assert_eq!(
consolidation.manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
manifest.web.as_ref().and_then(|web| web.enabled),
Some(false)
);
}
@@ -1477,7 +1590,8 @@ mod tests {
panic!("unexpected error: {error}");
};
assert_eq!(target, ProfileExecutionTarget::Standalone);
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv));
assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
+18
View File
@@ -41,6 +41,7 @@ pub enum MemoryBackendHttpResponse {
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryBackendOperationResult {
ToolOutput(MemoryToolOutput),
ResidentSummary(MemoryResidentSummaryOutput),
Acknowledged(MemoryBackendAckOutput),
StagingWritten(MemoryStagingWriteOutput),
}
@@ -52,6 +53,23 @@ pub struct MemoryToolOutput {
pub content: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MemoryResidentSummaryAvailability {
Ready,
Ungenerated,
Stale,
Failed,
Unavailable,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MemoryResidentSummaryOutput {
pub availability: MemoryResidentSummaryAvailability,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MemoryQueryOperation {
#[serde(default, skip_serializing_if = "Option::is_none")]
+117 -7
View File
@@ -766,6 +766,10 @@ pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef,
pub revision: u64,
pub session: SessionSnapshot,
/// Public execution metadata for this child. Older snapshots and service
/// workers may omit it; clients must never substitute the parent's values.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub greeting: Option<Greeting>,
#[serde(default)]
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1241,6 +1245,16 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_read_input_tokens: Option<u64>,
},
/// Authoritative current-context occupancy after a non-request boundary.
///
/// Unlike [`Event::Usage`], this is not cumulative request traffic. It lets
/// connected clients converge with reconnect snapshots after run completion,
/// compaction, rewind, and other history rewrites. `None` explicitly means
/// the current value is unavailable and stale occupancy must be cleared.
ContextUsage {
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<ContextUsage>,
},
RunEnd {
result: RunResult,
},
@@ -1667,11 +1681,45 @@ impl InFlightToolCallState {
}
}
/// Worker self-description rendered by the TUI when a session starts empty.
/// Public reasoning configuration resolved for the model client.
///
/// Built once in the Worker controller from the resolved manifest and
/// transmitted alongside `Event::Snapshot` so clients don't need
/// their own view of the manifest.
/// This deliberately exposes only the effective provider control, not the
/// Worker manifest or provider credentials. The tagged shape prevents token
/// budgets from being presented as effort labels.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ReasoningConfig {
Effort { effort: String },
BudgetTokens { budget_tokens: i32 },
}
/// Provenance of the current-context token count in a [`Greeting`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ContextTokenSource {
/// Provider-reported input occupancy from the most recent LLM request.
Measured,
/// Current history projected from the Worker's token accounting records.
Estimated,
}
/// Current prompt-context occupancy, distinct from cumulative run traffic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ContextUsage {
pub tokens: u64,
pub source: ContextTokenSource,
}
/// Worker self-description rendered by clients when a session starts empty.
///
/// Snapshot metadata contains only resolved public execution information so
/// clients do not need their own copy of the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1680,14 +1728,22 @@ pub struct Greeting {
pub cwd: String,
pub provider: String,
pub model: String,
/// Effective reasoning control. Absent means unspecified or unavailable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
pub scope_summary: String,
pub tools: Vec<String>,
/// Model context window in tokens. Always filled by the Worker greeting.
/// Resolved effective model context window in tokens. Legacy producers use
/// zero when this is unavailable; clients must treat that as unknown.
#[serde(default)]
pub context_window: u64,
/// Estimated current session context tokens at connect time.
/// Legacy numeric mirror retained for mixed-version clients.
#[serde(default)]
pub context_tokens: u64,
/// Typed current-context value. Older snapshots omit it; clients may fall
/// back to `context_tokens` only when `context_window` is known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_usage: Option<ContextUsage>,
}
// ---------------------------------------------------------------------------
@@ -2354,6 +2410,11 @@ mod tests {
tools: vec!["Read".into()],
context_window: 200_000,
context_tokens: 42_000,
reasoning: Some(ReasoningConfig::BudgetTokens { budget_tokens: -1 }),
context_usage: Some(ContextUsage {
tokens: 42_000,
source: ContextTokenSource::Measured,
}),
},
state: WorkerStatus::Paused.into(),
in_flight: InFlightSnapshot::default(),
@@ -2372,16 +2433,63 @@ mod tests {
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
assert_eq!(
parsed["data"]["greeting"]["reasoning"]["kind"],
"budget_tokens"
);
assert_eq!(parsed["data"]["greeting"]["reasoning"]["budget_tokens"], -1);
assert_eq!(
parsed["data"]["greeting"]["context_usage"]["tokens"],
42_000
);
assert_eq!(
parsed["data"]["greeting"]["context_usage"]["source"],
"measured"
);
assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
}
#[test]
fn context_usage_event_roundtrips_typed_value_and_unknown() {
let event = Event::ContextUsage {
usage: Some(ContextUsage {
tokens: 25_000,
source: ContextTokenSource::Estimated,
}),
};
let json = serde_json::to_string(&event).unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap(),
serde_json::json!({
"event": "context_usage",
"data": {
"usage": { "tokens": 25_000, "source": "estimated" }
}
})
);
assert!(matches!(
serde_json::from_str::<Event>(r#"{"event":"context_usage","data":{}}"#).unwrap(),
Event::ContextUsage { usage: None }
));
}
#[test]
fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
Event::Snapshot {
greeting,
in_flight,
..
} => {
assert!(in_flight.is_empty());
assert_eq!(greeting.reasoning, None);
assert_eq!(greeting.context_usage, None);
assert_eq!(greeting.context_window, 0);
assert_eq!(greeting.context_tokens, 0);
}
other => panic!("expected Snapshot, got {other:?}"),
}
@@ -2399,6 +2507,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Running.into(),
in_flight: InFlightSnapshot {
+7 -14
View File
@@ -12,7 +12,7 @@ use serde::{Deserialize, Deserializer, Serialize};
use crate::Event as WorkerProtocolEvent;
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1;
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 2;
/// Browser/runtime wire guardrails. These bounds are generated into the Workspace
/// Browser validator so aggregate limits remain part of the Rust-owned contract.
pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024;
@@ -426,7 +426,6 @@ pub enum SubscriptionResponse {
request_id: SubscriptionRequestId,
subscription_id: SubscriptionId,
selector: EventSubscriptionSelector,
snapshot_revision: u64,
snapshot: SubscriptionSnapshot,
},
Unsubscribed {
@@ -512,7 +511,6 @@ pub enum SubscriptionTerminationCode {
pub enum SubscriptionEvent {
Event {
subscription_id: SubscriptionId,
subject_revision: u64,
payload: SubscriptionEventPayload,
},
SubscriptionClosed {
@@ -666,9 +664,7 @@ pub struct SubscriptionWorker {
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
#[serde(default)]
pub availability: SubscriptionWorkerAvailability,
/// Producer-owned monotonic revision for this Worker subject.
pub subject_revision: u64,
/// Latest revisioned foreground state observed from the Worker. This remains
/// Latest foreground state observed from the Worker. This remains
/// absent until an authoritative Worker snapshot/event has been applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<crate::WorkerStateSnapshot>,
@@ -970,7 +966,6 @@ mod tests {
runtime_id: None,
resource_key: None,
availability: SubscriptionWorkerAvailability::Observed,
subject_revision: 0,
worker_state: None,
state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false,
@@ -1093,7 +1088,7 @@ mod tests {
assert_eq!(
json,
serde_json::json!({
"protocol_version": 1,
"protocol_version": 2,
"frame": "request",
"message": {
"method": "subscribe_events",
@@ -1119,7 +1114,6 @@ mod tests {
request_id: request_id(),
subscription_id: subscription_id(),
selector: EventSubscriptionSelector::RuntimeWorkers,
snapshot_revision: 7,
snapshot: SubscriptionSnapshot::Workers {
workers: vec![worker("worker-1")],
},
@@ -1158,10 +1152,10 @@ mod tests {
subscription_id: subscription_id(),
},
));
frame.protocol_version = 2;
frame.protocol_version = 1;
assert!(matches!(
frame.validate(),
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 2 })
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 1 })
));
}
@@ -1180,7 +1174,6 @@ mod tests {
let event = SubscriptionEvent::Event {
subscription_id: subscription_id(),
subject_revision: 8,
payload: SubscriptionEventPayload::WorkerRemoved {
worker_id: worker_id("worker-2"),
runtime_id: None,
@@ -1211,7 +1204,7 @@ mod tests {
assert_eq!(
serde_json::to_value(frame).unwrap(),
serde_json::json!({
"protocol_version": 1,
"protocol_version": 2,
"frame": "worker_protocol",
"message": {
"subscription_id": "subscription-1",
@@ -1253,7 +1246,7 @@ mod tests {
assert_eq!(
serde_json::to_value(frame).unwrap(),
serde_json::json!({
"protocol_version": 1,
"protocol_version": 2,
"frame": "event",
"message": {
"event": "subscription_closed",
+71 -19
View File
@@ -6,17 +6,18 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId,
RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{
@@ -116,10 +117,13 @@ pub fn generated_protocol_types() -> String {
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<ReasoningConfig>(&cfg, &mut output);
push_decl::<ContextTokenSource>(&cfg, &mut output);
push_decl::<ContextUsage>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output);
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
@@ -230,7 +234,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
runtime_id: Some("fixture-runtime".to_string()),
resource_key: Some("fixture-worker-resource".to_string()),
availability: SubscriptionWorkerAvailability::default(),
subject_revision: 1,
worker_state: None,
state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false,
@@ -250,7 +253,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
request_id,
subscription_id: subscription_id.clone(),
selector: EventSubscriptionSelector::WorkspaceWorkers,
snapshot_revision: 1,
snapshot: SubscriptionSnapshot::Workers {
workers: vec![worker],
},
@@ -258,21 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
));
let pending =
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id,
subject_revision: 2,
subscription_id: subscription_id.clone(),
payload: SubscriptionEventPayload::WorkerProtocol {
worker_id,
worker_id: worker_id.clone(),
event: Event::PendingSubmissionsChanged {
pending: PendingSubmissionsSnapshot::default(),
},
},
}));
for fixture in [&subscribed, &pending] {
let greeting = Greeting {
worker_name: "fixture-worker".into(),
cwd: "/fixture".into(),
provider: "openai_responses".into(),
model: "gpt-6-astra".into(),
reasoning: Some(ReasoningConfig::Effort {
effort: "high".into(),
}),
scope_summary: "fixture scope".into(),
tools: vec!["Read".into()],
context_window: 272_000,
context_tokens: 142_000,
context_usage: Some(ContextUsage {
tokens: 142_000,
source: ContextTokenSource::Measured,
}),
};
let snapshot =
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id,
payload: SubscriptionEventPayload::WorkerProtocol {
worker_id,
event: Event::Snapshot {
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: greeting.clone(),
state: WorkerStateSnapshot::initial(),
in_flight: InFlightSnapshot::default(),
internal_workers: vec![InternalWorkerSnapshot {
worker: InternalWorkerRef {
session_id: "fixture-child-session".into(),
name: "fixture-child".into(),
parent_session_id: Some("fixture-parent-session".into()),
kind: InternalWorkerKind::SubWorker,
},
revision: 1,
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: Some(greeting),
status: WorkerStatus::Idle,
error: None,
in_flight: InFlightSnapshot::default(),
internal_workers: Vec::new(),
}],
},
},
}));
for fixture in [&subscribed, &pending, &snapshot] {
fixture
.validate()
.expect("Rust-serialized Browser compatibility fixture must validate");
}
vec![subscribed, pending]
vec![subscribed, pending, snapshot]
}
fn close_object_shapes(schema: &mut Value) {
+214 -1
View File
@@ -444,6 +444,10 @@ pub struct CreateWorkerRequest {
pub workspace_api: Option<WorkspaceApiRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
/// Backend-attested subject or consolidation attachment. Profile policy alone
/// never activates subjektiv for an ordinary Workspace Worker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub subjektiv_attached: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -692,6 +696,183 @@ pub enum WorkerSessionHistoryAvailability {
},
}
/// Backend-selected storage source. This type is never exposed as model tool
/// input; Runtime paths remain behind the authenticated Workspace capability.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "storage", rename_all = "snake_case")]
pub enum SessionPublicSource {
Retained { worker_id: WorkerId },
Archived { archive_id: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionPublicEntryKind {
User,
Assistant,
Tool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionPublicToolPart {
Input,
Output,
Both,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionPublicReadMode {
Compact,
Full,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionPublicLineageKind {
Root,
Fork,
Compact,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPublicLineage {
pub kind: SessionPublicLineageKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_segment_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_turn_index: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPublicSearchRequest {
pub workspace_id: String,
pub source: SessionPublicSource,
pub expected_session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_generation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<SessionPublicEntryKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
pub tool_part: SessionPublicToolPart,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan_cursor: Option<String>,
pub limit: usize,
pub max_scan_bytes: u64,
pub max_segments: usize,
pub max_entries: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPublicReadRequest {
pub workspace_id: String,
pub source: SessionPublicSource,
pub expected_session_id: String,
pub expected_generation: Option<String>,
pub segment_id: String,
pub entry_ref: String,
pub mode: SessionPublicReadMode,
pub byte_offset: usize,
pub max_content_bytes: usize,
pub max_scan_bytes: u64,
pub max_segments: usize,
pub max_entries: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPublicSearchItem {
pub segment_id: String,
pub entry_ref: String,
pub kind: SessionPublicEntryKind,
pub origin: protocol::SessionEntryProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_part: Option<SessionPublicToolPart>,
pub compact: String,
pub compact_truncated: bool,
pub lineage: SessionPublicLineage,
/// Opaque Runtime continuation immediately before this result.
pub scan_cursor: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPublicSearchPage {
pub session_id: String,
pub generation: String,
pub items: Vec<SessionPublicSearchItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_scan_cursor: Option<String>,
pub has_more: bool,
pub scanned_bytes: u64,
pub scanned_segments: usize,
pub scanned_entries: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_manifest: Option<WorkerSessionArchiveManifest>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPublicReadPage {
pub session_id: String,
pub generation: String,
pub segment_id: String,
pub entry_ref: String,
pub kind: SessionPublicEntryKind,
pub origin: protocol::SessionEntryProvenance,
pub lineage: SessionPublicLineage,
pub mode: SessionPublicReadMode,
pub content: String,
pub next_byte_offset: Option<usize>,
pub has_more: bool,
pub scanned_bytes: u64,
pub scanned_segments: usize,
pub scanned_entries: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_manifest: Option<WorkerSessionArchiveManifest>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionPublicUnavailableReason {
NotFound,
RetentionMissing,
RetentionExpired,
ArchiveIncomplete,
CorruptLog,
MigrationRequired,
StorageUnavailable,
InvalidCursor,
ResourceLimit,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "availability", rename_all = "snake_case")]
pub enum SessionPublicSearchAvailability {
Page {
page: SessionPublicSearchPage,
},
Unavailable {
reason: SessionPublicUnavailableReason,
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "availability", rename_all = "snake_case")]
pub enum SessionPublicReadAvailability {
Page {
page: SessionPublicReadPage,
},
Unavailable {
reason: SessionPublicUnavailableReason,
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedSessionIdentity {
pub session_id: String,
@@ -766,6 +947,18 @@ pub trait RuntimeApi {
#[query] request: WorkerSessionHistoryRequest,
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
#[post("/v1/session-public/search", status = 200, error_status = 400)]
async fn session_public_search(
&self,
#[body] request: SessionPublicSearchRequest,
) -> Result<SessionPublicSearchAvailability, RuntimeApiError>;
#[post("/v1/session-public/read", status = 200, error_status = 400)]
async fn session_public_read(
&self,
#[body] request: SessionPublicReadRequest,
) -> Result<SessionPublicReadAvailability, RuntimeApiError>;
#[post("/v1/workers", status = 200, error_status = 400)]
async fn create_worker(
&self,
@@ -1112,6 +1305,26 @@ mod tests {
})
}
async fn session_public_search(
&self,
_request: SessionPublicSearchRequest,
) -> Result<SessionPublicSearchAvailability, RuntimeApiError> {
Ok(SessionPublicSearchAvailability::Unavailable {
reason: SessionPublicUnavailableReason::StorageUnavailable,
message: "unsupported".to_string(),
})
}
async fn session_public_read(
&self,
_request: SessionPublicReadRequest,
) -> Result<SessionPublicReadAvailability, RuntimeApiError> {
Ok(SessionPublicReadAvailability::Unavailable {
reason: SessionPublicUnavailableReason::StorageUnavailable,
message: "unsupported".to_string(),
})
}
async fn create_worker(
&self,
_request: CreateWorkerRequest,
@@ -1248,7 +1461,7 @@ mod tests {
#[test]
fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 17);
assert_eq!(operations.len(), 19);
let mut routes = operations
.iter()
.map(|operation| (format!("{:?}", operation.method), operation.path))
File diff suppressed because it is too large Load Diff
+18 -10
View File
@@ -211,7 +211,11 @@ impl FsStore {
}
}
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
fn parse_jsonl(
content: &[u8],
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> {
let complete = Self::complete_jsonl_prefix(content);
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
line: complete[..error.valid_up_to()]
@@ -222,15 +226,19 @@ impl FsStore {
message: error.to_string(),
})?;
let mut entries = Vec::new();
for (i, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
let mut record_start = 0_u64;
for (index, record) in content.split_inclusive('\n').enumerate() {
let line = record.strip_suffix('\n').unwrap_or(record);
if !line.trim().is_empty() {
let mut entry: LogEntry =
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
line: index + 1,
message: error.to_string(),
})?;
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
entries.push(entry);
}
let entry: T = serde_json::from_str(line).map_err(|e| StoreError::Corrupt {
line: i + 1,
message: e.to_string(),
})?;
entries.push(entry);
record_start = record_start.saturating_add(record.len() as u64);
}
Ok(entries)
}
@@ -294,7 +302,7 @@ impl Store for FsStore {
return Err(StoreError::NotFound(segment_id));
}
let content = fs::read(&path)?;
Self::parse_jsonl(&content)
Self::parse_jsonl(&content, session_id, segment_id)
}
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
+4 -1
View File
@@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize};
use crate::LoggedItem;
/// Stable logical identity of one model-visible history entry.
/// Stable logical identity of one durable public Session entry.
///
/// Model-visible history entries and typed Run transitions share this identity
/// space so every projection can reconcile the same committed record.
///
/// This value is generated at the trusted Worker session boundary and copied
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
+11
View File
@@ -75,6 +75,7 @@ pub mod history;
mod legacy_session_log;
pub mod logged_item;
mod paste_artifact;
pub mod public_index;
pub mod public_snapshot;
pub mod segment;
pub mod segment_log;
@@ -95,6 +96,16 @@ pub use history::{
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits;
pub use public_index::{
DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES,
DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES,
SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind,
SessionPublicIndexEntryRead, SessionPublicIndexLimits, SessionPublicIndexLineage,
SessionPublicIndexOriginKind, SessionPublicIndexPage, SessionPublicIndexPageEntry,
SessionPublicIndexReadError, SessionPublicIndexScanPosition, SessionPublicIndexSegment,
SessionPublicIndexToolPart, read_session_public_index, read_session_public_index_entry,
read_session_public_index_page,
};
pub use public_snapshot::{
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
File diff suppressed because it is too large Load Diff
+358 -11
View File
@@ -978,7 +978,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
project_session_snapshot_for_segment(session_id, None, log)
}
fn project_session_snapshot_for_segment(
pub(crate) fn project_session_snapshot_for_segment(
session_id: SessionId,
segment_id: Option<SegmentId>,
log: &[LogEntry],
@@ -1041,15 +1041,16 @@ fn project_session_snapshot_for_segment(
)),
LogEntry::RunYielded {
ts,
entry_id,
reason,
active_run_turn_count: count,
} => {
active_run_turn_count = Some(*count);
entries.push(legacy_entry(
entries.push(run_transition_entry(
entry_id.as_ref(),
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunYielded {
reason: *reason,
@@ -1059,15 +1060,16 @@ fn project_session_snapshot_for_segment(
}
LogEntry::RunResumed {
ts,
entry_id,
source,
active_run_turn_count: count,
} => {
active_run_turn_count = Some(*count);
entries.push(legacy_entry(
entries.push(run_transition_entry(
entry_id.as_ref(),
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunResumed {
source: *source,
@@ -1075,29 +1077,30 @@ fn project_session_snapshot_for_segment(
},
));
}
LogEntry::RunCancelled { ts } => {
LogEntry::RunCancelled { ts, entry_id } => {
active_run_turn_count = None;
entries.push(legacy_entry(
entries.push(run_transition_entry(
entry_id.as_ref(),
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunCancelled,
));
}
LogEntry::RunErrored {
ts,
entry_id,
message,
failure,
..
} => {
active_run_turn_count = None;
entries.push(legacy_entry(
entries.push(run_transition_entry(
entry_id.as_ref(),
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunError {
message: message.clone(),
@@ -1234,6 +1237,25 @@ fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
.unwrap_or_default()
}
fn run_transition_entry(
persisted_entry_id: Option<&crate::LoggedSessionHistoryEntryId>,
session_key: &SessionId,
segment_id: Option<&SegmentId>,
log_index: usize,
timestamp: u64,
data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry {
SessionSnapshotEntry {
entry_id: persisted_entry_id
.map(|entry_id| entry_id.0.clone())
.unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)),
timestamp,
provenance: SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data,
}
}
fn legacy_entry(
session_key: &SessionId,
segment_id: Option<&SegmentId>,
@@ -2812,22 +2834,344 @@ mod tests {
));
}
fn run_entry_ids(entries: &[SessionSnapshotEntry]) -> Vec<String> {
entries
.iter()
.filter(|entry| {
matches!(
entry.data,
SessionSnapshotEntryData::RunYielded { .. }
| SessionSnapshotEntryData::RunResumed { .. }
| SessionSnapshotEntryData::RunCancelled
| SessionSnapshotEntryData::RunError { .. }
)
})
.map(|entry| entry.entry_id.clone())
.collect()
}
#[test]
fn saved_run_transition_ids_match_snapshot_retained_and_paged_history() {
let worker_name = "worker-run-identities";
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
let initial = vec![
segment_start(session_id, Vec::new(), None, None),
LogEntry::AnnotatedUserInput {
ts: 2,
segments: vec![Segment::text("compact")],
history: vec![user_message(1)],
extensions: Vec::new(),
},
];
let (_root, aggregate_root) = persist_history_fixture(
worker_name,
session_id,
segment_id,
vec![(segment_id, initial)],
);
let store = WorkerSessionStore::new(aggregate_root.join("session")).unwrap();
let transitions = [
LogEntry::RunYielded {
ts: 10,
entry_id: None,
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunResumed {
ts: 10,
entry_id: None,
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunErrored {
ts: 10,
entry_id: None,
interrupted: false,
message: "mid-run compaction failed: unavailable".into(),
failure: Some(protocol::RunFailureKind::Compaction),
},
LogEntry::RunErrored {
ts: 10,
entry_id: None,
interrupted: false,
message: "mid-run compaction failed: unavailable".into(),
failure: Some(protocol::RunFailureKind::Compaction),
},
LogEntry::RunCancelled {
ts: 10,
entry_id: None,
},
];
for entry in transitions {
crate::segment::append_entry(&store, session_id, segment_id, entry).unwrap();
}
let stored = store.read_all(session_id, segment_id).unwrap();
let stored_ids = run_entry_ids(
&project_session_snapshot_for_segment(session_id, Some(segment_id), &stored).entries,
);
assert_eq!(stored_ids.len(), 5);
assert_eq!(stored_ids.iter().collect::<HashSet<_>>().len(), 5);
assert!(
stored_ids
.iter()
.all(|entry_id| !entry_id.starts_with("l-run-"))
);
let retained = read_retained_session_snapshot(
&aggregate_root,
worker_name,
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
)
.unwrap();
assert_eq!(run_entry_ids(&retained.snapshot.entries), stored_ids);
let page = read_retained_session_history_page(
&aggregate_root,
worker_name,
None,
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(page.turns.len(), 1);
assert_eq!(run_entry_ids(&page.turns[0].entries), stored_ids);
let reconnect = read_retained_session_snapshot(
&aggregate_root,
worker_name,
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
)
.unwrap();
assert_eq!(run_entry_ids(&reconnect.snapshot.entries), stored_ids);
}
#[test]
fn run_transition_ids_survive_segment_lineage_and_history_page_boundaries() {
let worker_name = "worker-run-lineage-identities";
let session_id = crate::new_session_id();
let source_segment = crate::new_segment_id();
let active_segment = crate::new_segment_id();
let source = vec![
segment_start(session_id, Vec::new(), None, None),
LogEntry::AnnotatedUserInput {
ts: 2,
segments: vec![Segment::text("source turn")],
history: vec![user_message(1)],
extensions: Vec::new(),
},
];
let (_root, aggregate_root) = persist_history_fixture(
worker_name,
session_id,
source_segment,
vec![(source_segment, source)],
);
let store = WorkerSessionStore::new(aggregate_root.join("session")).unwrap();
crate::segment::append_entry(
&store,
session_id,
source_segment,
LogEntry::RunYielded {
ts: 3,
entry_id: None,
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 1,
},
)
.unwrap();
store
.append(
session_id,
source_segment,
&LogEntry::TurnEnd {
ts: 4,
turn_count: 1,
},
)
.unwrap();
store
.create_segment(
session_id,
active_segment,
&[
segment_start(
session_id,
vec![user_message(1)],
None,
Some(SegmentOrigin {
segment_id: source_segment,
at_turn_index: 1,
}),
),
LogEntry::AnnotatedUserInput {
ts: 5,
segments: vec![Segment::text("active turn")],
history: vec![user_message(2)],
extensions: Vec::new(),
},
],
)
.unwrap();
for entry in [
LogEntry::RunResumed {
ts: 6,
entry_id: None,
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 1,
},
LogEntry::RunErrored {
ts: 7,
entry_id: None,
interrupted: false,
message: "mid-run compaction failed".into(),
failure: Some(protocol::RunFailureKind::Compaction),
},
] {
crate::segment::append_entry(&store, session_id, active_segment, entry).unwrap();
}
WorkerAggregateStore::new(&aggregate_root, worker_name)
.unwrap()
.write(&crate::WorkerMetadata::new(
worker_name,
Some(crate::WorkerActiveSegmentRef::active_segment(
session_id,
active_segment,
)),
))
.unwrap();
let active_ids = run_entry_ids(
&project_session_snapshot_for_segment(
session_id,
Some(active_segment),
&store.read_all(session_id, active_segment).unwrap(),
)
.entries,
);
let source_ids = run_entry_ids(
&project_session_snapshot_for_segment(
session_id,
Some(source_segment),
&store.read_all(session_id, source_segment).unwrap(),
)
.entries,
);
assert_eq!(active_ids.len(), 2);
assert_eq!(source_ids.len(), 1);
let newest = read_retained_session_history_page(
&aggregate_root,
worker_name,
None,
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(run_entry_ids(&newest.turns[0].entries), active_ids);
assert!(newest.has_more);
let older = read_retained_session_history_page(
&aggregate_root,
worker_name,
newest.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(run_entry_ids(&older.turns[0].entries), source_ids);
}
#[test]
fn legacy_run_transition_ids_use_stable_distinct_record_positions() {
let worker_name = "worker-legacy-run-identities";
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
let log = vec![
segment_start(session_id, Vec::new(), None, None),
LogEntry::AnnotatedUserInput {
ts: 2,
segments: vec![Segment::text("legacy")],
history: vec![user_message(1)],
extensions: Vec::new(),
},
LogEntry::RunErrored {
ts: 10,
entry_id: None,
interrupted: false,
message: "same legacy failure".into(),
failure: None,
},
LogEntry::RunErrored {
ts: 10,
entry_id: None,
interrupted: false,
message: "same legacy failure".into(),
failure: None,
},
];
let (_root, aggregate_root) =
persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]);
let log_path = aggregate_root
.join("session")
.join("segments")
.join(format!("{segment_id}.jsonl"));
let persisted_before = std::fs::read(&log_path).unwrap();
assert!(!String::from_utf8_lossy(&persisted_before).contains("entry_id\":\"l-run-"));
let retained = read_retained_session_snapshot(
&aggregate_root,
worker_name,
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
)
.unwrap();
let snapshot_ids = run_entry_ids(&retained.snapshot.entries);
assert_eq!(snapshot_ids.len(), 2);
assert_ne!(snapshot_ids[0], snapshot_ids[1]);
assert!(
snapshot_ids
.iter()
.all(|entry_id| entry_id.starts_with("l-run-"))
);
let page = read_retained_session_history_page(
&aggregate_root,
worker_name,
None,
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(run_entry_ids(&page.turns[0].entries), snapshot_ids);
let reread = read_retained_session_snapshot(
&aggregate_root,
worker_name,
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
)
.unwrap();
assert_eq!(run_entry_ids(&reread.snapshot.entries), snapshot_ids);
assert_eq!(std::fs::read(log_path).unwrap(), persisted_before);
}
#[test]
fn run_transitions_project_with_timestamps_and_typed_causes() {
let session_id = crate::new_session_id();
let log = vec![
LogEntry::RunYielded {
ts: 10,
entry_id: None,
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunResumed {
ts: 20,
entry_id: None,
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunErrored {
ts: 30,
entry_id: None,
interrupted: false,
message: "mid-run compaction failed: unavailable".into(),
failure: Some(protocol::RunFailureKind::Compaction),
@@ -2869,7 +3213,10 @@ mod tests {
ts: 10,
trigger: protocol::InvokeKind::UserSend,
},
LogEntry::RunCancelled { ts: 20 },
LogEntry::RunCancelled {
ts: 20,
entry_id: None,
},
],
);
+5 -1
View File
@@ -347,6 +347,7 @@ pub fn save_run_yielded(
segment_id,
LogEntry::RunYielded {
ts: segment_log::now_millis(),
entry_id: None,
reason,
active_run_turn_count,
},
@@ -367,6 +368,7 @@ pub fn save_run_resumed(
segment_id,
LogEntry::RunResumed {
ts: segment_log::now_millis(),
entry_id: None,
source,
active_run_turn_count,
},
@@ -390,6 +392,7 @@ pub fn save_run_errored(
segment_id,
LogEntry::RunErrored {
ts: segment_log::now_millis(),
entry_id: None,
interrupted,
message,
failure: Some(RunFailureKind::Engine),
@@ -568,7 +571,8 @@ pub fn append_entry(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
entry: LogEntry,
mut entry: LogEntry,
) -> Result<(), StoreError> {
entry.ensure_session_entry_id();
store.append(session_id, segment_id, &entry)
}
+63 -2
View File
@@ -140,6 +140,12 @@ pub enum LogEntry {
/// preceding Invoke and a later RunResumed or terminal record.
RunYielded {
ts: u64,
/// Stable public identity allocated before the record is committed.
/// `None` is accepted only when reading logs written before this field
/// existed; read paths derive the same legacy identity from the durable
/// record position without rewriting the log.
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
reason: RunYieldReason,
active_run_turn_count: usize,
},
@@ -149,19 +155,27 @@ pub enum LogEntry {
/// It deliberately does not assert that a subsequent model call occurred.
RunResumed {
ts: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
source: RunResumeSource,
active_run_turn_count: usize,
},
/// The logical Run ended because the user intentionally cancelled it.
/// This is terminal, but is not an execution failure.
RunCancelled { ts: u64 },
RunCancelled {
ts: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
},
/// `run()` / `resume()` が `EngineError` で終了した。
/// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。
/// Audit-only metadata: replay は `interrupted` のみ反映する。
RunErrored {
ts: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
interrupted: bool,
message: String,
/// Older records omit this field. New records distinguish failures in
@@ -236,6 +250,48 @@ pub struct SegmentOrigin {
pub at_turn_index: usize,
}
impl LogEntry {
/// Allocate the durable public identity for a newly committed Run
/// transition. Calling this repeatedly is idempotent so the exact value
/// written to storage is also the value published to live subscribers.
pub fn ensure_session_entry_id(&mut self) {
let entry_id = match self {
Self::RunYielded { entry_id, .. }
| Self::RunResumed { entry_id, .. }
| Self::RunCancelled { entry_id, .. }
| Self::RunErrored { entry_id, .. } => entry_id,
_ => return,
};
if entry_id.is_none() {
*entry_id = Some(crate::LoggedSessionHistoryEntryId::new());
}
}
/// Supply an in-memory compatibility identity for a legacy Run transition
/// that predates persisted entry IDs. The physical record position is
/// stable within the append-only Segment and distinguishes equal payloads.
/// This helper never writes the synthesized value back to the log.
pub(crate) fn ensure_legacy_session_entry_id(
&mut self,
session_id: crate::SessionId,
segment_id: crate::SegmentId,
record_start: u64,
) {
let entry_id = match self {
Self::RunYielded { entry_id, .. }
| Self::RunResumed { entry_id, .. }
| Self::RunCancelled { entry_id, .. }
| Self::RunErrored { entry_id, .. } => entry_id,
_ => return,
};
if entry_id.is_none() {
*entry_id = Some(crate::LoggedSessionHistoryEntryId(format!(
"l-run-{session_id}-{segment_id}-{record_start}"
)));
}
}
}
/// State collected from log entries.
#[derive(Debug, Clone)]
pub struct RestoredState {
@@ -878,6 +934,7 @@ mod tests {
},
LogEntry::RunYielded {
ts: 300,
entry_id: None,
reason: RunYieldReason::Compaction,
active_run_turn_count: 2,
},
@@ -888,6 +945,7 @@ mod tests {
},
LogEntry::RunResumed {
ts: 500,
entry_id: None,
source: RunResumeSource::Compaction,
active_run_turn_count: 2,
},
@@ -909,7 +967,10 @@ mod tests {
ts: 200,
turn_count: 1,
},
LogEntry::RunCancelled { ts: 300 },
LogEntry::RunCancelled {
ts: 300,
entry_id: None,
},
]);
assert!(!state.last_run_interrupted);
+12
View File
@@ -157,6 +157,16 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Restore-boundary refresh of the current resident Memory projection.
/// The exact rendered body is retained so replay is byte-identical; a
/// refresh with no ready summary explicitly supersedes older resident
/// context without deleting or rewriting history.
ResidentSummaryRefresh {
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short.
@@ -181,6 +191,7 @@ impl SystemItem {
format!("Ignored legacy procedure item: /{slug}")
}
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(),
}
}
@@ -202,6 +213,7 @@ impl SystemItem {
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::Interrupt { .. } => "interrupt",
}
}
@@ -61,6 +61,8 @@ pub(crate) struct RetainedLogRecord {
pub(crate) struct RetainedSegmentReader {
file: File,
session_id: SessionId,
segment_id: SegmentId,
read_end: u64,
buffer_start: u64,
buffer: Vec<u8>,
@@ -103,7 +105,12 @@ impl RetainedSegmentReader {
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
let mut entry: LogEntry = serde_json::from_slice(&record)?;
entry.ensure_legacy_session_entry_id(
self.session_id,
self.segment_id,
record_start,
);
return Ok((
Some(RetainedLogRecord {
start_offset: record_start,
@@ -255,7 +262,10 @@ impl WorkerSessionStore {
if byte_len > max_bytes {
return Err(StoreError::ReadLimitExceeded);
}
Ok((parse_jsonl(&bytes)?, byte_len))
Ok((
parse_session_log_jsonl(&bytes, session_id, segment_id)?,
byte_len,
))
}
pub(crate) fn read_first_log_record_read_only_bounded(
@@ -264,6 +274,7 @@ impl WorkerSessionStore {
segment_id: SegmentId,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
let record_start = 0;
self.validate_retained_session(session_id)?;
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
@@ -289,7 +300,8 @@ impl WorkerSessionStore {
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
let mut entry: LogEntry = serde_json::from_slice(&record)?;
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
Ok((
Some(RetainedLogRecord {
start_offset: 0,
@@ -308,6 +320,7 @@ impl WorkerSessionStore {
start_offset: u64,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
let record_start = start_offset;
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
@@ -334,7 +347,8 @@ impl WorkerSessionStore {
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
let mut entry: LogEntry = serde_json::from_slice(&record)?;
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
Ok((
Some(RetainedLogRecord {
start_offset,
@@ -353,6 +367,7 @@ impl WorkerSessionStore {
end_offset: u64,
max_bytes: u64,
) -> Result<RetainedLogRecord, StoreError> {
let record_start = start_offset;
self.validate_retained_session(session_id)?;
let record_len =
end_offset
@@ -387,7 +402,8 @@ impl WorkerSessionStore {
});
}
record.pop();
let entry = serde_json::from_slice(&record)?;
let mut entry: LogEntry = serde_json::from_slice(&record)?;
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
Ok(RetainedLogRecord {
start_offset,
end_offset,
@@ -429,6 +445,8 @@ impl WorkerSessionStore {
}
Ok(RetainedSegmentReader {
file,
session_id,
segment_id,
read_end: before_offset,
buffer_start: before_offset,
buffer: Vec::new(),
@@ -450,6 +468,39 @@ impl WorkerSessionStore {
Ok(())
}
/// Enumerate canonical Segment logs in ascending identity order without
/// creating or migrating state. Unlike the general [`Store`] listing, this
/// observation boundary rejects malformed names and non-regular files so a
/// persisted Segment cannot be silently omitted from a public index.
pub(crate) fn list_segments_read_only(
&self,
session_id: SessionId,
) -> Result<Vec<SegmentId>, StoreError> {
self.validate_retained_session(session_id)?;
segment_log_paths(&self.root).map(|paths| {
paths
.into_iter()
.map(|(segment_id, _)| segment_id)
.collect()
})
}
pub(crate) fn segment_log_observation(
&self,
segment_id: SegmentId,
) -> Result<(u64, u128), StoreError> {
let metadata = fs::metadata(self.log_path(segment_id))?;
let modified = metadata
.modified()?
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| StoreError::Corrupt {
line: 0,
message: "retained Segment modification time predates the Unix epoch".to_string(),
})?
.as_nanos();
Ok((metadata.len(), modified))
}
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
Ok(fs::metadata(self.log_path(segment_id))?.len())
}
@@ -645,7 +696,7 @@ impl Store for WorkerSessionStore {
if !path.exists() {
return Err(StoreError::NotFound(segment_id));
}
parse_jsonl(&fs::read(path)?)
parse_session_log_jsonl(&fs::read(path)?, session_id, segment_id)
}
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
@@ -1154,6 +1205,38 @@ fn complete_jsonl_prefix(content: &[u8]) -> &[u8] {
.unwrap_or(&[])
}
fn parse_session_log_jsonl(
content: &[u8],
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> {
let complete = complete_jsonl_prefix(content);
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
line: complete[..error.valid_up_to()]
.iter()
.filter(|byte| **byte == b'\n')
.count()
+ 1,
message: error.to_string(),
})?;
let mut entries = Vec::new();
let mut record_start = 0_u64;
for (index, record) in content.split_inclusive('\n').enumerate() {
let line = record.strip_suffix('\n').unwrap_or(record);
if !line.trim().is_empty() {
let mut entry: LogEntry =
serde_json::from_str(line).map_err(|error| StoreError::Corrupt {
line: index + 1,
message: error.to_string(),
})?;
entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start);
entries.push(entry);
}
record_start = record_start.saturating_add(record.len() as u64);
}
Ok(entries)
}
fn parse_jsonl<T: serde::de::DeserializeOwned>(content: &[u8]) -> Result<Vec<T>, StoreError> {
let complete = complete_jsonl_prefix(content);
let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt {
+187 -10
View File
@@ -247,6 +247,9 @@ pub struct App {
/// Latest session context tokens reported by the Worker. This is the raw
/// `input_tokens` value and is independent from per-run upload totals.
pub session_context_tokens: u64,
/// Availability and provenance for `session_context_tokens`. `None` means
/// the current value is unknown and clients must not render a fabricated 0%.
pub session_context_source: Option<protocol::ContextTokenSource>,
pub context_window: u64,
pub turn_index: usize,
pub current_tool: Option<String>,
@@ -352,6 +355,7 @@ impl App {
run_upload_tokens: 0,
run_output_tokens: 0,
session_context_tokens: 0,
session_context_source: None,
context_window: 0,
turn_index: 0,
current_tool: None,
@@ -1403,7 +1407,12 @@ impl App {
output_tokens,
cache_read_input_tokens,
} => {
self.session_context_tokens = input_tokens.unwrap_or(0);
if let Some(input_tokens) = input_tokens {
self.session_context_tokens = input_tokens;
self.session_context_source = Some(protocol::ContextTokenSource::Measured);
} else {
self.session_context_source = None;
}
// Subtract the cache-hit portion so a tool loop that
// re-sends the same prefix on every request doesn't
// re-count it. cache_creation stays in (it is full
@@ -1414,6 +1423,14 @@ impl App {
self.run_upload_tokens += net_input;
self.run_output_tokens += output_tokens.unwrap_or(0);
}
Event::ContextUsage { usage } => {
if let Some(usage) = usage {
self.session_context_tokens = usage.tokens;
self.session_context_source = Some(usage.source);
} else {
self.session_context_source = None;
}
}
Event::Error { code, message } => {
self.handle_error(code, message);
}
@@ -1475,7 +1492,6 @@ impl App {
return None;
}
self.active_compaction = None;
self.session_context_tokens = 0;
let new_segment_id = lifecycle
.new_segment_id
.as_deref()
@@ -2337,8 +2353,12 @@ impl App {
) -> InternalWorkerView {
let mut app = App::new(snapshot.worker.name.clone());
app.mode = mode;
app.restore_session(&snapshot.session, None);
app.apply_in_flight_snapshot(snapshot.in_flight);
if let Some(greeting) = snapshot.greeting {
app.restore_snapshot(&snapshot.session, greeting, snapshot.in_flight);
} else {
app.restore_session(&snapshot.session, None);
app.apply_in_flight_snapshot(snapshot.in_flight);
}
app.set_worker_status(snapshot.status);
if let Some(error) = snapshot.error {
let _ = app.handle_worker_event(Event::Error {
@@ -2424,7 +2444,14 @@ impl App {
) {
self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens;
let context_usage = greeting.context_usage.or_else(|| {
(greeting.context_window > 0).then_some(protocol::ContextUsage {
tokens: greeting.context_tokens,
source: protocol::ContextTokenSource::Estimated,
})
});
self.session_context_tokens = context_usage.map(|usage| usage.tokens).unwrap_or_default();
self.session_context_source = context_usage.map(|usage| usage.source);
self.restore_session(session, Some(greeting));
self.apply_in_flight_snapshot(in_flight);
}
@@ -2441,10 +2468,16 @@ impl App {
_ => None,
})
});
let greeting = greeting.map(|mut greeting| {
greeting.context_tokens = 0;
greeting.context_usage = None;
greeting
});
if let Some(greeting) = greeting.clone() {
self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens;
self.session_context_tokens = 0;
self.session_context_source = None;
}
let missing_greeting = greeting.is_none();
self.restore_session(session, greeting);
@@ -2584,6 +2617,7 @@ impl App {
}
session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
| session_store::SystemItem::Interrupt { body, .. } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
@@ -2835,6 +2869,9 @@ mod rewind_refresh_tests {
assert!(!blocks_contain(&app, "old post-target output"));
assert_eq!(composer_text(&app), "selected rewind input");
assert!(blocks_contain(&app, "Rewound session"));
assert_eq!(app.session_context_tokens, 0);
assert_eq!(app.session_context_source, None);
assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None);
}
#[test]
@@ -2945,6 +2982,8 @@ mod rewind_refresh_tests {
tools: vec![],
context_window: 100,
context_tokens: 10,
reasoning: None,
context_usage: None,
}
}
@@ -3800,6 +3839,7 @@ mod completion_flow_tests {
let run_errored = session_store::LogEntry::RunErrored {
ts: 3,
entry_id: None,
interrupted: false,
message: "provider unavailable".into(),
failure: None,
@@ -4019,6 +4059,7 @@ mod completion_flow_tests {
},
revision,
status: WorkerStatus::Idle,
greeting: None,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
@@ -4029,6 +4070,76 @@ mod completion_flow_tests {
}
}
#[test]
fn internal_worker_snapshot_uses_child_greeting_metadata() {
let mut app = App::new("parent".into());
let mut child = test_internal_worker_snapshot("child", "research", 1);
let mut greeting = test_greeting();
greeting.worker_name = "research".into();
greeting.model = "child-model".into();
greeting.context_window = 64_000;
greeting.context_usage = Some(protocol::ContextUsage {
tokens: 12_000,
source: protocol::ContextTokenSource::Measured,
});
child.greeting = Some(greeting);
app.replace_internal_worker_snapshots(vec![child]);
let child_app = &app.internal_workers[0].app;
assert_eq!(child_app.greeting.as_ref().unwrap().model, "child-model");
assert_eq!(child_app.context_window, 64_000);
assert_eq!(child_app.session_context_tokens, 12_000);
}
#[test]
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
let mut app = App::new("parent".into());
let worker = test_internal_worker_snapshot("child", "research", 1).worker;
let mut greeting = test_greeting();
greeting.worker_name = "research".into();
greeting.model = "child-model".into();
greeting.context_window = 64_000;
greeting.context_usage = Some(protocol::ContextUsage {
tokens: 12_000,
source: protocol::ContextTokenSource::Measured,
});
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 1,
event: Box::new(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting,
state: WorkerStatus::Running.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
}),
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 2,
event: Box::new(Event::ContextUsage {
usage: Some(protocol::ContextUsage {
tokens: 14_000,
source: protocol::ContextTokenSource::Estimated,
}),
}),
});
let child = &app.internal_workers[0].app;
assert_eq!(child.greeting.as_ref().unwrap().model, "child-model");
assert_eq!(child.context_window, 64_000);
assert_eq!(child.session_context_tokens, 14_000);
assert_eq!(
child.session_context_source,
Some(protocol::ContextTokenSource::Estimated)
);
}
#[test]
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
let mut app = App::new("parent".into());
@@ -4307,6 +4418,7 @@ mod completion_flow_tests {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: None,
status: WorkerStatus::Running,
error: None,
in_flight: Default::default(),
@@ -4503,15 +4615,21 @@ mod completion_flow_tests {
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
}
}
#[test]
fn snapshot_initializes_context_usage() {
fn snapshot_prefers_typed_context_usage_over_legacy_mirror() {
let mut app = App::new("test".into());
let mut greeting = test_greeting();
greeting.context_window = 123_000;
greeting.context_tokens = 45_000;
greeting.context_tokens = 1;
greeting.context_usage = Some(protocol::ContextUsage {
tokens: 45_000,
source: protocol::ContextTokenSource::Measured,
});
app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
@@ -4526,6 +4644,36 @@ mod completion_flow_tests {
assert_eq!(app.context_window, 123_000);
assert_eq!(app.session_context_tokens, 45_000);
assert_eq!(
app.session_context_source,
Some(protocol::ContextTokenSource::Measured)
);
}
#[test]
fn snapshot_falls_back_to_legacy_context_tokens() {
let mut app = App::new("test".into());
let mut greeting = test_greeting();
greeting.context_window = 100_000;
greeting.context_tokens = 25_000;
app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting,
state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
assert_eq!(app.context_window, 100_000);
assert_eq!(app.session_context_tokens, 25_000);
assert_eq!(
app.session_context_source,
Some(protocol::ContextTokenSource::Estimated)
);
}
#[test]
@@ -4539,10 +4687,39 @@ mod completion_flow_tests {
});
assert_eq!(app.session_context_tokens, 42_000);
assert_eq!(
app.session_context_source,
Some(protocol::ContextTokenSource::Measured)
);
assert_eq!(app.run_upload_tokens, 2_000);
assert_eq!(app.run_output_tokens, 9);
}
#[test]
fn context_usage_event_converges_live_state_without_changing_run_traffic() {
let mut app = App::new("test".into());
app.run_upload_tokens = 2_000;
app.run_output_tokens = 9;
app.handle_worker_event(Event::ContextUsage {
usage: Some(protocol::ContextUsage {
tokens: 45_000,
source: protocol::ContextTokenSource::Estimated,
}),
});
assert_eq!(app.session_context_tokens, 45_000);
assert_eq!(
app.session_context_source,
Some(protocol::ContextTokenSource::Estimated)
);
assert_eq!(app.run_upload_tokens, 2_000);
assert_eq!(app.run_output_tokens, 9);
app.handle_worker_event(Event::ContextUsage { usage: None });
assert_eq!(app.session_context_source, None);
}
#[test]
fn memory_worker_event_updates_actionbar_state() {
let mut app = App::new("test".into());
@@ -4564,7 +4741,7 @@ mod completion_flow_tests {
}
#[test]
fn compact_done_resets_session_context_tokens() {
fn legacy_compact_done_does_not_fabricate_zero_context_usage() {
let mut app = App::new("test".into());
app.session_context_tokens = 42_000;
@@ -4572,7 +4749,7 @@ mod completion_flow_tests {
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
app.handle_worker_event(Event::CompactDone { lifecycle });
assert_eq!(app.session_context_tokens, 0);
assert_eq!(app.session_context_tokens, 42_000);
}
#[test]
+119 -17
View File
@@ -11,7 +11,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use std::error::Error;
use std::io::{self, IsTerminal, Write};
use std::io::{self, BufRead, IsTerminal, Write};
use std::time::{SystemTime, UNIX_EPOCH};
type PickerResult<T> = Result<T, Box<dyn Error>>;
@@ -179,18 +179,50 @@ fn prompt_create_request() -> PickerResult<Option<CreateBackendWorkspaceRequest>
}
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
println!("Create Workspace (leave display name empty to cancel)");
let display_name = prompt_line("Workspace display name: ")?;
let stdin = io::stdin();
let stdout = io::stdout();
prompt_create_request_with_io(&mut stdin.lock(), &mut stdout.lock())
}
fn prompt_create_request_with_io(
input: &mut impl BufRead,
output: &mut impl Write,
) -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
writeln!(
output,
"Create Workspace (leave display name empty to cancel)"
)?;
let Some(display_name) = prompt_line(input, output, "Workspace display name: ")? else {
return Ok(None);
};
let display_name = display_name.trim().to_string();
if display_name.is_empty() {
return Ok(None);
}
let uri = prompt_line("Initial repository absolute path/URI: ")?;
let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else {
return Ok(None);
};
let uri = uri.trim().to_string();
if uri.is_empty() {
println!("Repository path/URI is required.");
writeln!(output, "Repository path/URI is required.")?;
return Ok(None);
}
let repository_key = prompt_line("Repository key [main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?;
let repository_key = loop {
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
else {
return Ok(None);
};
if repository_key.trim().is_empty() {
writeln!(output, "Repository key is required.")?;
continue;
}
break repository_key;
};
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
else {
return Ok(None);
};
let default_ref = default_ref.trim().to_string();
let operation_key = format!(
"tui-workspace-create-{}-{}",
std::process::id(),
@@ -204,27 +236,43 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
display_name,
repository: CreateBackendWorkspaceRepository {
uri,
repository_key: if repository_key.is_empty() {
"main".to_string()
} else {
repository_key
},
repository_key,
default_ref: (!default_ref.is_empty()).then_some(default_ref),
},
}))
}
fn prompt_line(prompt: &str) -> PickerResult<String> {
print!("{prompt}");
io::stdout().flush()?;
fn prompt_line(
input: &mut impl BufRead,
output: &mut impl Write,
prompt: &str,
) -> PickerResult<Option<String>> {
write!(output, "{prompt}")?;
output.flush()?;
let mut value = String::new();
io::stdin().read_line(&mut value)?;
Ok(value.trim().to_string())
if input.read_line(&mut value)? == 0 {
return Ok(None);
}
if value.ends_with('\n') {
value.pop();
if value.ends_with('\r') {
value.pop();
}
}
Ok(Some(value))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn prompt_create(input: &str) -> (Option<CreateBackendWorkspaceRequest>, String) {
let mut input = Cursor::new(input.as_bytes());
let mut output = Vec::new();
let request = prompt_create_request_with_io(&mut input, &mut output).unwrap();
(request, String::from_utf8(output).unwrap())
}
#[test]
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
@@ -237,4 +285,58 @@ mod tests {
WorkspacePickerAction::Cancel
);
}
#[test]
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
let request = request.expect("complete input should create a request");
assert_eq!(request.display_name, "Platform");
assert_eq!(request.repository.uri, "/srv/platform");
assert_eq!(request.repository.repository_key, "platform");
assert_eq!(request.repository.default_ref.as_deref(), Some("develop"));
assert!(output.contains("Repository key (required): "));
assert!(!output.contains("Repository key [main]"));
}
#[test]
fn workspace_creation_reprompts_for_blank_repository_keys() {
let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n");
let request = request.expect("a later explicit key should create a request");
assert_eq!(request.repository.repository_key, "platform");
assert_eq!(output.matches("Repository key is required.").count(), 2);
assert_eq!(output.matches("Repository key (required): ").count(), 3);
}
#[test]
fn workspace_creation_does_not_normalize_a_nonblank_repository_key() {
let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n");
assert_eq!(
request.unwrap().repository.repository_key,
" platform ",
"the Backend must validate the creator's exact Repository key"
);
}
#[test]
fn workspace_creation_accepts_explicit_main_repository_key() {
let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n");
assert_eq!(
request.unwrap().repository.repository_key,
"main",
"main remains valid when the creator enters it explicitly"
);
}
#[test]
fn workspace_creation_cancels_on_eof_instead_of_retrying_forever() {
let (request, output) = prompt_create("Workspace\n/srv/repository\n \n");
assert!(request.is_none());
assert_eq!(output.matches("Repository key is required.").count(), 1);
assert_eq!(output.matches("Repository key (required): ").count(), 2);
}
}
+6
View File
@@ -499,10 +499,12 @@ impl RetainedSnapshotSocket {
cwd: String::new(),
provider: "retained session".to_string(),
model: String::new(),
reasoning: None,
scope_summary: "read-only retained session".to_string(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
context_usage: None,
},
state: protocol::WorkerStateSnapshot {
last_command_id: 0,
@@ -654,10 +656,12 @@ async fn run_e2e_rewind_fixture(
cwd: workspace_root.display().to_string(),
provider: "e2e-fixture".to_string(),
model: "canned".to_string(),
reasoning: None,
scope_summary: "isolated e2e rewind fixture".to_string(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
context_usage: None,
},
});
@@ -2784,10 +2788,12 @@ mod tests {
cwd: "/tmp".into(),
provider: "test".into(),
model: "test".into(),
reasoning: None,
scope_summary: "".into(),
tools: vec![],
context_window: 0,
context_tokens: 0,
context_usage: None,
}
}
+29 -5
View File
@@ -1830,13 +1830,20 @@ fn draw_separator(frame: &mut Frame, area: Rect) {
}
fn context_usage_text(app: &App) -> String {
let pct = if app.context_window == 0 {
0
} else {
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64
let Some(source) = app.session_context_source else {
return "Context unavailable".to_string();
};
if app.context_window == 0 {
return "Context unavailable".to_string();
}
let pct =
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64;
let estimate = matches!(source, protocol::ContextTokenSource::Estimated)
.then_some("~")
.unwrap_or_default();
format!(
"{} / {} ({}%)",
"{}{} / {} ({}%)",
estimate,
fmt_tokens(app.session_context_tokens),
fmt_tokens(app.context_window),
pct
@@ -2194,6 +2201,23 @@ mod tests {
);
}
#[test]
fn context_usage_distinguishes_unknown_measured_and_estimated_values() {
let mut app = App::new("worker".into());
app.context_window = 100_000;
app.session_context_tokens = 0;
app.session_context_source = None;
assert_eq!(context_usage_text(&app), "Context unavailable");
app.session_context_tokens = 25_000;
app.session_context_source = Some(protocol::ContextTokenSource::Measured);
assert_eq!(context_usage_text(&app), "25.0k / 100.0k (25%)");
app.session_context_tokens = 20_000;
app.session_context_source = Some(protocol::ContextTokenSource::Estimated);
assert_eq!(context_usage_text(&app), "~20.0k / 100.0k (20%)");
}
#[test]
fn run_elapsed_uses_console_style_units() {
assert_eq!(fmt_run_elapsed(9), "9s");
+4
View File
@@ -298,6 +298,10 @@ pub struct CreateWorkerRequest {
/// Backend-authored immutable Workspace Memory settings snapshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
/// Backend-attested subject or consolidation attachment. A reusable Profile
/// may enable subjektiv policy, but only this host-owned bit activates it.
#[serde(default, skip_serializing_if = "is_false")]
pub subjektiv_attached: bool,
}
/// Last persisted Worker lifecycle status.
+7 -2
View File
@@ -1334,7 +1334,6 @@ async fn runtime_protocol_ws_session(
request_id,
subscription_id: subscription_id.clone(),
selector: selector.clone(),
snapshot_revision: subscription.snapshot_revision(),
snapshot: subscription.snapshot().clone(),
},
));
@@ -1354,7 +1353,6 @@ async fn runtime_protocol_ws_session(
SubscriptionFramePayload::Event(
SubscriptionEvent::Event {
subscription_id: event_subscription_id.clone(),
subject_revision: update.subject_revision,
payload: update.payload,
},
),
@@ -2314,6 +2312,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
return Some("workers:delete");
}
if path.starts_with("/v1/session-public/") && *method == Method::POST {
return Some("workers:read");
}
if path.starts_with("/v1/workers/") && *method == Method::GET {
return Some("workers:read");
}
@@ -3101,6 +3102,7 @@ mod tests {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
@@ -3994,6 +3996,8 @@ mod ws_tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: protocol::WorkerStateSnapshot::initial(),
in_flight: protocol::InFlightSnapshot {
@@ -4078,6 +4082,7 @@ mod ws_tests {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
@@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.map_err(api_error)
}
async fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, runtime_api::RuntimeApiError> {
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
.ok_or_else(|| {
runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_session_public_scope_required",
"Session public search requires a Workspace-scoped capability",
)
})?;
if scope.workspace_id != request.workspace_id {
return Err(runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_session_public_workspace_scope_mismatch",
"Session public search Workspace scope does not match the authenticated capability",
));
}
#[cfg(feature = "fs-store")]
{
self.state
.runtime
.session_public_search_scoped(&scope, &request)
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)
}
#[cfg(not(feature = "fs-store"))]
{
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
message: "Session public storage is unavailable".to_string(),
})
}
}
async fn session_public_read(
&self,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, runtime_api::RuntimeApiError> {
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
.ok_or_else(|| {
runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_session_public_scope_required",
"Session public read requires a Workspace-scoped capability",
)
})?;
if scope.workspace_id != request.workspace_id {
return Err(runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_session_public_workspace_scope_mismatch",
"Session public read Workspace scope does not match the authenticated capability",
));
}
#[cfg(feature = "fs-store")]
{
self.state
.runtime
.session_public_read_scoped(&scope, &request)
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)
}
#[cfg(not(feature = "fs-store"))]
{
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
message: "Session public storage is unavailable".to_string(),
})
}
}
async fn create_worker(
&self,
value: runtime_api::CreateWorkerRequest,
+6 -1
View File
@@ -25,6 +25,8 @@ pub mod resource;
#[cfg(feature = "fs-store")]
pub mod retention;
mod runtime;
#[cfg(feature = "fs-store")]
mod session_public;
pub mod ssh_host_key_probe;
pub mod worker_backend;
pub mod worker_source;
@@ -36,5 +38,8 @@ pub mod workspace_request;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
pub use management::RuntimeOptions;
pub use runtime::{Runtime, RuntimeWorkspaceScope};
pub use runtime::{
Runtime, RuntimeEventSelectorSubscription, RuntimeSubscriptionRecvError,
RuntimeSubscriptionUpdate, RuntimeWorkspaceScope,
};
pub use session_store::UploadedFileUploadContext;
+20
View File
@@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider {
.join(archive_id))
}
pub(crate) fn session_archive(
&self,
archive_id: &str,
) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> {
let archive_dir = self.archive_dir(archive_id)?;
let manifest: WorkerSessionArchiveManifest = read_json(
&archive_dir.join("manifest.json"),
"read Worker Session archive",
)?;
if manifest.schema_version != ARCHIVE_SCHEMA_VERSION || manifest.archive_id != archive_id {
return Err(RuntimeError::StoreCorrupt {
operation: "read Worker Session archive",
path: archive_dir.join("manifest.json"),
message: "archive manifest identity mismatch".to_string(),
});
}
let manifest = validate_existing_archive(&archive_dir, &manifest)?;
Ok((manifest, archive_dir.join("session")))
}
fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
validate_id("operation_id", operation_id)?;
Ok(self
+256 -60
View File
@@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope {
}
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256;
#[cfg(feature = "fs-store")]
fn session_public_source_unavailable(
source: &runtime_api::SessionPublicSource,
error: &RuntimeError,
) -> Option<(runtime_api::SessionPublicUnavailableReason, &'static str)> {
use runtime_api::SessionPublicUnavailableReason as Reason;
match (source, error) {
(
runtime_api::SessionPublicSource::Retained { .. },
RuntimeError::WorkerNotFound { .. },
) => Some((
Reason::RetentionMissing,
"retained Session storage is missing",
)),
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreMissing { .. }) => {
Some((
Reason::ArchiveIncomplete,
"committed Session archive is incomplete",
))
}
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreCorrupt { .. }) => {
Some((
Reason::ArchiveIncomplete,
"committed Session archive failed integrity validation",
))
}
(_, RuntimeError::StoreIo { .. } | RuntimeError::StoreCommitOutcomeUnknown { .. }) => {
Some((Reason::StorageUnavailable, "Session storage is unavailable"))
}
_ => None,
}
}
const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed";
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
@@ -151,7 +186,6 @@ impl RuntimeWorkerRestoreResult {
#[derive(Clone, Debug)]
pub struct RuntimeSubscriptionUpdate {
pub subject_revision: u64,
pub payload: SubscriptionEventPayload,
}
@@ -168,7 +202,6 @@ pub enum RuntimeSubscriptionRecvError {
pub struct RuntimeEventSelectorSubscription {
subscription_id: u64,
selector: EventSubscriptionSelector,
snapshot_revision: u64,
snapshot: SubscriptionSnapshot,
receiver: mpsc::Receiver<RuntimeSubscriptionUpdate>,
lagged: Arc<AtomicBool>,
@@ -180,10 +213,6 @@ impl RuntimeEventSelectorSubscription {
&self.selector
}
pub fn snapshot_revision(&self) -> u64 {
self.snapshot_revision
}
pub fn snapshot(&self) -> &SubscriptionSnapshot {
&self.snapshot
}
@@ -1280,7 +1309,6 @@ impl Runtime {
state.persist_runtime_snapshot()?;
}
let snapshot = state.subscription_snapshot(scope, &selector)?;
let snapshot_revision = state.subscription_revision;
let subscription_id = state.next_event_subscription_id;
state.next_event_subscription_id = state.next_event_subscription_id.saturating_add(1);
let (sender, receiver) = mpsc::channel(SUBSCRIPTION_QUEUE_CAPACITY);
@@ -1298,7 +1326,6 @@ impl Runtime {
Ok(RuntimeEventSelectorSubscription {
subscription_id,
selector,
snapshot_revision,
snapshot,
receiver,
lagged,
@@ -2217,9 +2244,9 @@ impl Runtime {
if let Some(payload) = input_protocol_event(&input) {
state.push_worker_observation_event(worker_ref.clone(), payload);
}
state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
state.publish_worker_upsert(worker_ref.worker_id)?;
Ok(WorkerInteractionAck {
worker_ref: worker_ref.clone(),
@@ -2500,9 +2527,9 @@ impl Runtime {
worker.workdir_attachments = workdir_attachments;
worker.detail()
};
state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
state.publish_worker_upsert(worker_ref.worker_id)?;
Ok(detail)
}
@@ -2541,8 +2568,8 @@ impl Runtime {
state
.observation_events
.retain(|event| event.worker_ref != *worker_ref);
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
state.persist_runtime_snapshot()?;
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
}
Ok(())
}
@@ -2564,9 +2591,9 @@ impl Runtime {
let mut state = self.lock()?;
let worker = state.worker_mut(worker_ref)?;
worker.apply_worker_state(&snapshot);
state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
state.publish_worker_upsert(worker_ref.worker_id)?;
Ok(())
}
@@ -2870,8 +2897,8 @@ impl Runtime {
state
.observation_events
.retain(|event| event.worker_ref != *worker_ref);
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
state.persist_runtime_snapshot()?;
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
Ok(WorkerDeleteResult {
worker_id: removed.worker_id,
deleted: true,
@@ -2959,13 +2986,13 @@ impl Runtime {
let worker_state_changed =
state.project_protocol_event_to_worker_state(worker_ref, &payload);
let activity_changed = state.project_internal_worker_activity(worker_ref, &payload);
if worker_state_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?;
}
if worker_state_changed {
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
}
if worker_state_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?;
}
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
Ok(event)
}
@@ -3021,13 +3048,13 @@ impl Runtime {
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
let activity_changed =
state.project_internal_worker_activity(&worker_ref, &payload);
if worker_state_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?;
}
if worker_state_changed {
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
}
if worker_state_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?;
}
Ok(state.push_worker_observation_event(worker_ref, payload))
}),
)
@@ -3054,13 +3081,13 @@ impl Runtime {
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
let activity_changed =
state.project_internal_worker_activity(&worker_ref, &payload);
if worker_state_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?;
}
if worker_state_changed {
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_ref.worker_id)?;
}
if worker_state_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?;
}
Ok(state.push_worker_observation_event(worker_ref, payload))
}),
)
@@ -3454,8 +3481,8 @@ impl Runtime {
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
candidate.workdir_attachments = workdir_attachments;
state.workers.insert(worker_ref.worker_id, candidate);
state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_worker(&worker_ref.worker_id)?;
state.publish_worker_upsert(worker_ref.worker_id)?;
Ok(())
}
@@ -3551,6 +3578,123 @@ impl Runtime {
)
}
/// Search the committed public projection of one Backend-selected retained
/// Session or verified archive without restoring a Worker.
#[cfg(feature = "fs-store")]
pub fn session_public_search_scoped(
&self,
scope: &RuntimeWorkspaceScope,
request: &runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeError> {
let (session_root, archive) =
match self.resolve_session_public_source(scope, &request.source) {
Ok(source) => source,
Err(error) => {
if let Some((reason, message)) =
session_public_source_unavailable(&request.source, &error)
{
return Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
reason,
message: message.to_string(),
});
}
return Err(error);
}
};
Ok(crate::session_public::search(
&session_root,
request,
archive.as_ref(),
))
}
/// Read one exact public entry from a Backend-selected retained Session or
/// verified archive. No fallback Worker, Segment, or entry is selected.
#[cfg(feature = "fs-store")]
pub fn session_public_read_scoped(
&self,
scope: &RuntimeWorkspaceScope,
request: &runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeError> {
let (session_root, archive) =
match self.resolve_session_public_source(scope, &request.source) {
Ok(source) => source,
Err(error) => {
if let Some((reason, message)) =
session_public_source_unavailable(&request.source, &error)
{
return Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
reason,
message: message.to_string(),
});
}
return Err(error);
}
};
Ok(crate::session_public::read(
&session_root,
request,
archive.as_ref(),
))
}
#[cfg(feature = "fs-store")]
fn resolve_session_public_source(
&self,
scope: &RuntimeWorkspaceScope,
source: &runtime_api::SessionPublicSource,
) -> Result<
(
std::path::PathBuf,
Option<crate::retention::WorkerSessionArchiveManifest>,
),
RuntimeError,
> {
let state = self.lock()?;
let store = state.fs_store().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Session public reads require an fs-backed Runtime".to_string(),
)
})?;
match source {
runtime_api::SessionPublicSource::Retained { worker_id } => {
let worker_ref = WorkerRef::new(*worker_id);
let worker = state.worker(&worker_ref)?;
if worker.workspace_id.as_deref() != Some(scope.workspace_id.as_str()) {
return Err(RuntimeError::WorkerNotFound {
worker_id: *worker_id,
});
}
Ok((
store
.runtime_dir()
.join("workers")
.join(worker_id.to_string())
.join("session"),
None,
))
}
runtime_api::SessionPublicSource::Archived { archive_id } => {
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Runtime identity is not bound for Session archive reads".to_string(),
)
})?;
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
let (manifest, session_root) = provider.session_archive(archive_id)?;
if manifest.workspace_id != scope.workspace_id
|| manifest.source_runtime_id != runtime_id
{
return Err(RuntimeError::InvalidRequest(
"Session archive scope does not match the authenticated Runtime capability"
.to_string(),
));
}
Ok((session_root, Some(manifest)))
}
}
}
/// Enumerate host-authoritative Runtime inventory for Backend orphan
/// reconciliation. Runtime identity and Workspace scope are derived here,
/// not accepted in a diagnostic payload.
@@ -3702,8 +3846,6 @@ struct RuntimeState {
workspace_config_latest: BTreeMap<String, ConfigBundleRef>,
workspace_config_fetch_gates: BTreeMap<String, Arc<Mutex<()>>>,
diagnostics: Vec<RuntimeDiagnostic>,
subscription_revision: u64,
worker_subject_revisions: BTreeMap<WorkerId, u64>,
next_event_subscription_id: u64,
subscriptions: BTreeMap<u64, SubscriptionSink>,
#[cfg(feature = "ws-server")]
@@ -3733,8 +3875,6 @@ impl RuntimeState {
workspace_config_latest: BTreeMap::new(),
workspace_config_fetch_gates: BTreeMap::new(),
diagnostics: Vec::new(),
subscription_revision: 0,
worker_subject_revisions: BTreeMap::new(),
next_event_subscription_id: 1,
subscriptions: BTreeMap::new(),
#[cfg(feature = "ws-server")]
@@ -3765,8 +3905,6 @@ impl RuntimeState {
workspace_config_latest: BTreeMap::new(),
workspace_config_fetch_gates: BTreeMap::new(),
diagnostics: Vec::new(),
subscription_revision: 0,
worker_subject_revisions: BTreeMap::new(),
next_event_subscription_id: 1,
subscriptions: BTreeMap::new(),
#[cfg(feature = "ws-server")]
@@ -3895,8 +4033,6 @@ impl RuntimeState {
workspace_config_fetch_gates: BTreeMap::new(),
workspace_owners: persisted.workspace_owners,
diagnostics,
subscription_revision: 0,
worker_subject_revisions: BTreeMap::new(),
next_event_subscription_id: 1,
subscriptions: BTreeMap::new(),
#[cfg(feature = "ws-server")]
@@ -4215,11 +4351,6 @@ impl RuntimeState {
runtime_id: None,
resource_key: None,
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
subject_revision: self
.worker_subject_revisions
.get(&worker.worker_id)
.copied()
.unwrap_or(0),
worker_state: worker.worker_state.clone(),
state: subscription_worker_state(worker.status),
has_running_internal_workers: worker.internal_workers.has_running_worker(),
@@ -4232,25 +4363,17 @@ impl RuntimeState {
}
fn publish_worker_upsert(&mut self, worker_id: WorkerId) -> Result<(), RuntimeError> {
self.subscription_revision = self.subscription_revision.saturating_add(1);
let subject_revision = {
let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0);
*revision = revision.saturating_add(1);
*revision
};
let worker = self
.workers
.get(&worker_id)
.ok_or(RuntimeError::WorkerNotFound { worker_id })?;
let workspace_id = worker.workspace_id.clone();
let mut projected = self.subscription_worker(worker)?;
projected.subject_revision = subject_revision;
let projected = self.subscription_worker(worker)?;
let projected_worker_id = projected.worker_id.clone();
self.deliver_worker_subscription_update(
&projected_worker_id,
workspace_id.as_deref(),
RuntimeSubscriptionUpdate {
subject_revision,
payload: SubscriptionEventPayload::WorkerUpserted { worker: projected },
},
);
@@ -4262,19 +4385,12 @@ impl RuntimeState {
worker_id: WorkerId,
workspace_id: Option<&str>,
) -> Result<(), RuntimeError> {
self.subscription_revision = self.subscription_revision.saturating_add(1);
let subject_revision = {
let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0);
*revision = revision.saturating_add(1);
*revision
};
let worker_id = SubscriptionWorkerId::new(worker_id.to_string())
.map_err(subscription_validation_error)?;
self.deliver_worker_subscription_update(
&worker_id,
workspace_id,
RuntimeSubscriptionUpdate {
subject_revision,
payload: SubscriptionEventPayload::WorkerRemoved {
worker_id: worker_id.clone(),
runtime_id: None,
@@ -4417,8 +4533,8 @@ impl RuntimeState {
worker_ref: Some(worker_ref.clone()),
});
self.workers.insert(worker_ref.worker_id, candidate);
self.publish_worker_upsert(worker_ref.worker_id)?;
self.persist_runtime_snapshot()?;
self.publish_worker_upsert(worker_ref.worker_id)?;
Ok(())
}
@@ -5520,6 +5636,7 @@ mod tests {
worker,
revision,
status,
greeting: None,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
@@ -5545,6 +5662,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: protocol::WorkerStatus::Idle.into(),
in_flight: protocol::InFlightSnapshot::default(),
@@ -5883,6 +6002,7 @@ mod tests {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
@@ -6647,7 +6767,6 @@ mod tests {
let mut subscription = runtime
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
.unwrap();
assert_eq!(subscription.snapshot_revision(), 0);
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
panic!("runtime_workers must return a Worker snapshot");
};
@@ -6655,11 +6774,9 @@ mod tests {
let created = runtime.create_worker(task_request("live")).unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 1);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
assert_eq!(worker.subject_revision, 1);
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
}
payload => panic!("unexpected subscription payload: {payload:?}"),
@@ -6676,7 +6793,6 @@ mod tests {
)
.unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 2);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
@@ -6695,7 +6811,6 @@ mod tests {
)
.unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 3);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
@@ -6737,7 +6852,6 @@ mod tests {
)
.unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 4);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
@@ -6748,7 +6862,6 @@ mod tests {
runtime.stop_worker(&created.worker_ref, None).unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 5);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
@@ -6760,6 +6873,87 @@ mod tests {
runtime.stop_runtime().unwrap();
}
#[test]
fn concurrent_snapshot_and_worker_creation_cross_the_boundary_exactly_once() {
let runtime = runtime_with_backend();
let barrier = Arc::new(Barrier::new(2));
let subscribing_runtime = runtime.clone();
let subscribing_barrier = barrier.clone();
let subscriber = std::thread::spawn(move || {
subscribing_barrier.wait();
subscribing_runtime
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
.unwrap()
});
let creating_runtime = runtime.clone();
let creating_barrier = barrier.clone();
let creator = std::thread::spawn(move || {
creating_barrier.wait();
creating_runtime
.create_worker(task_request("concurrent-boundary"))
.unwrap()
});
let mut subscription = subscriber.join().unwrap();
let created = creator.join().unwrap();
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
panic!("runtime_workers must return a Worker snapshot");
};
let in_snapshot = workers
.iter()
.any(|worker| worker.worker_id.as_str() == created.worker_id.to_string());
let in_event = match subscription.receiver.try_recv() {
Ok(RuntimeSubscriptionUpdate {
payload: SubscriptionEventPayload::WorkerUpserted { worker },
}) => worker.worker_id.as_str() == created.worker_id.to_string(),
Ok(update) => panic!("unexpected subscription update: {update:?}"),
Err(mpsc::error::TryRecvError::Empty) => false,
Err(error) => panic!("unexpected subscription receive error: {error}"),
};
assert_ne!(
in_snapshot, in_event,
"the concurrent creation must appear in the snapshot or its ordered suffix, never both"
);
}
#[cfg(feature = "fs-store")]
#[test]
fn restarted_runtime_subscription_starts_from_persisted_authoritative_snapshot() {
let root = tempfile::tempdir().unwrap();
let options = crate::fs_store::FsRuntimeStoreOptions {
root: root.path().join("runtime"),
runtime_id: "subscription-restart".to_string(),
display_name: None,
};
let runtime = Runtime::with_fs_store_and_execution_backend(
options.clone(),
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let worker = runtime
.create_worker(task_request("persisted-subscription-worker"))
.unwrap();
drop(runtime);
let restarted = Runtime::with_fs_store_and_execution_backend(
options,
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
let subscription = restarted
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
.unwrap();
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
panic!("runtime_workers must return a Worker snapshot");
};
assert!(workers.iter().any(|candidate| {
candidate.worker_id.as_str() == worker.worker_id.to_string()
&& candidate.availability
== protocol::subscription::SubscriptionWorkerAvailability::Observed
}));
}
#[test]
fn worker_lifecycle_subscription_delivers_only_selected_workers() {
let runtime = runtime_with_backend();
@@ -8108,6 +8302,8 @@ mod tests {
tools: Vec::new(),
context_window: 128,
context_tokens: 64,
reasoning: None,
context_usage: None,
},
state: protocol::WorkerStatus::Running.into(),
in_flight: protocol::InFlightSnapshot {
+622
View File
@@ -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);
}
}
+132 -17
View File
@@ -944,7 +944,7 @@ fn bind_workspace_memory_settings(
request: &CreateWorkerRequest,
) -> Result<(), String> {
let Some(snapshot) = request.memory_settings.as_ref() else {
if request.workspace_api.is_some() {
if request.workspace_api.is_some() || request.subjektiv_attached {
return Err(
"Workspace Worker request is missing its bound Memory settings snapshot"
.to_string(),
@@ -960,14 +960,33 @@ fn bind_workspace_memory_settings(
snapshot.workspace_id, workspace_api.workspace_id
));
}
if manifest.feature.memory.profile.enabled {
manifest
.feature
.memory
.bind_workspace_settings(snapshot.clone())
.map_err(str::to_string)?;
}
if request.subjektiv_attached {
if !manifest.feature.subjektiv.profile.enabled {
return Err(
"subject-attached Worker profile does not enable subjektiv policy".to_string(),
);
}
manifest
.feature
.subjektiv
.bind_workspace_settings(snapshot.clone())
.map_err(str::to_string)?;
}
manifest
.feature
.memory
.bind_workspace_settings(snapshot.clone())
.validate_execution()
.map_err(str::to_string)?;
manifest
.feature
.memory
.subjektiv
.validate_execution()
.map_err(str::to_string)?;
Ok(())
@@ -978,6 +997,12 @@ fn validate_worker_memory_settings(
request: &CreateWorkerRequest,
) -> Result<(), String> {
let Some(expected) = request.memory_settings.as_ref() else {
if request.subjektiv_attached {
return Err(
"subject-attached Worker restore is missing its trusted settings snapshot"
.to_string(),
);
}
return Ok(());
};
manifest
@@ -985,17 +1010,21 @@ fn validate_worker_memory_settings(
.memory
.validate_execution()
.map_err(str::to_string)?;
if !manifest.feature.memory.profile.enabled {
return Ok(());
}
let actual = manifest
manifest
.feature
.memory
.workspace_settings()
.ok_or_else(|| {
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?;
if &actual != expected {
.subjektiv
.validate_execution()
.map_err(str::to_string)?;
if manifest.feature.memory.profile.enabled
&& manifest.feature.memory.workspace_settings().as_ref() != Some(expected)
{
let actual = manifest
.feature
.memory
.workspace_settings()
.ok_or_else(|| {
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?;
return Err(format!(
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
expected.workspace_id,
@@ -1004,6 +1033,33 @@ fn validate_worker_memory_settings(
actual.settings_revision
));
}
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
if request.subjektiv_attached {
if !manifest.feature.subjektiv.profile.enabled {
return Err(
"subject-attached Worker restored with a Profile that disables subjektiv policy"
.to_string(),
);
}
if subjektiv_settings.as_ref() != Some(expected) {
let actual = subjektiv_settings.ok_or_else(|| {
"subject-attached Worker restored without its bound subjektiv settings snapshot"
.to_string()
})?;
return Err(format!(
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
expected.workspace_id,
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
));
}
} else if subjektiv_settings.is_some() {
return Err(
"ordinary Workspace Worker restored with an unauthorized subjektiv attachment"
.to_string(),
);
}
Ok(())
}
@@ -1241,10 +1297,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
}
}
worker
.materialize_durable_session_head()
.await
.map_err(|error| format!("materialize durable Worker session head: {error}"))?;
let workspace_client = worker.workspace_client_handle();
let started = prepared.start().await.map_err(|error| match error {
WorkerBootstrapError::Worker(source) => {
@@ -4391,9 +4443,70 @@ mod tests {
worker_observation_grants: Vec::new(),
workspace_api: None,
memory_settings: None,
subjektiv_attached: false,
}
}
#[test]
fn host_attachment_alone_activates_subjektiv_profile_policy() {
let manifest = || {
let mut manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "subjektiv-attachment-test"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
auth = { kind = "none" }
[engine]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "read"
"#,
)
.unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest
};
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-subject".to_string(),
settings_revision: 3,
language: "English".to_string(),
};
let mut ordinary_request = create_request("ordinary");
ordinary_request.workspace_api = Some(WorkspaceApiRef {
workspace_id: settings.workspace_id.clone(),
base_url: "http://workspace.invalid".to_string(),
});
ordinary_request.memory_settings = Some(settings.clone());
let mut ordinary = manifest();
bind_workspace_memory_settings(&mut ordinary, &ordinary_request).unwrap();
assert!(ordinary.feature.subjektiv.profile.enabled);
assert!(!ordinary.feature.subjektiv.execution_enabled());
validate_worker_memory_settings(&ordinary, &ordinary_request).unwrap();
let mut attached_request = ordinary_request.clone();
attached_request.subjektiv_attached = true;
let mut attached = manifest();
bind_workspace_memory_settings(&mut attached, &attached_request).unwrap();
assert!(attached.feature.subjektiv.execution_enabled());
assert_eq!(
attached.feature.subjektiv.workspace_settings(),
Some(settings.clone())
);
validate_worker_memory_settings(&attached, &attached_request).unwrap();
assert!(validate_worker_memory_settings(&attached, &ordinary_request).is_err());
attached_request.memory_settings = None;
assert!(bind_workspace_memory_settings(&mut manifest(), &attached_request).is_err());
}
fn git(path: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
@@ -4499,6 +4612,8 @@ mod tests {
tools: Vec::new(),
context_window: 1_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
));
hub.workers.lock().unwrap().insert(
@@ -564,6 +564,30 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
.await,
)
}
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
generated_workspace_response(
client
.subjektiv_stage_candidate(workspace_id, request)
.await,
)
}
WorkspaceServerOperation::SubjektivRecordSession(request) => {
generated_workspace_response(
client.subjektiv_record_session(workspace_id, request).await,
)
}
WorkspaceServerOperation::SubjektivMemory(request) => {
generated_workspace_response(
client.subjektiv_memory_backend(workspace_id, request).await,
)
}
WorkspaceServerOperation::SubjektivSession(request) => {
generated_workspace_response(
client
.subjektiv_session_backend(workspace_id, request)
.await,
)
}
}
})
})
+298 -13
View File
@@ -151,7 +151,7 @@ impl WorkerHandle {
.snapshot();
let event = Event::Snapshot {
session,
greeting: self.shared_state.greeting.clone(),
greeting: self.shared_state.greeting(),
state: self.shared_state.snapshot(),
in_flight,
internal_workers: self.spawned_registry.internal_worker_snapshots(),
@@ -269,6 +269,17 @@ async fn set_controller_state(
snapshot
}
fn publish_context_usage(
shared_state: &Arc<WorkerSharedState>,
working_event_tx: &broadcast::Sender<Event>,
tokens: Option<u64>,
source: protocol::ContextTokenSource,
) {
shared_state.update_context_usage(tokens, source);
let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
let _ = working_event_tx.send(Event::ContextUsage { usage });
}
async fn set_controller_status(
shared_state: &Arc<WorkerSharedState>,
runtime_dir: &RuntimeDir,
@@ -303,6 +314,12 @@ async fn finish_controller_run<C, St>(
// the terminal run boundary so reconnect snapshots cannot append stale
// partial text/tool arguments after newer entries.
worker.clear_in_flight_events();
publish_context_usage(
shared_state,
working_event_tx,
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
}
@@ -767,6 +784,19 @@ impl WorkerController {
// the actual registered set instead of a hand-maintained mirror.
worker.engine().tool_server_handle().flush_pending();
// Runtime-owned Workers persist their current model-visible head before
// controller exposure. Feature installation above must happen first so a
// fresh subject Worker captures the current resident surface in its durable
// prompt. Restored Workers defer their one-shot append-only resident refresh
// until the next model-visible context materialization, after the Runtime has
// committed restoration of the existing durable head.
if runtime_managed && worker.needs_initial_session_head_materialization() {
worker
.materialize_durable_session_head()
.await
.map_err(|error| std::io::Error::other(error.to_string()))?;
}
// === 4. Initial runtime files + WorkerSharedState + WorkerHandle +
// SocketServer ===
let manifest_toml = toml::to_string_pretty(worker.manifest()).unwrap_or_default();
@@ -781,6 +811,16 @@ impl WorkerController {
manifest_toml.clone(),
greeting,
));
let usage_state = shared_state.clone();
let usage_events = working_event_tx.clone();
worker.engine_mut().on_usage(move |event| {
publish_context_usage(
&usage_state,
&usage_events,
event.input_tokens,
protocol::ContextTokenSource::Measured,
);
});
if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
}
@@ -1141,6 +1181,22 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
// per-item commit channel is wired at the top of this function.
}
fn validate_memory_lifecycle_targets(
memory: &manifest::MemoryFeatureProfileConfig,
subjektiv: &manifest::SubjektivFeatureProfileConfig,
) -> std::io::Result<()> {
let legacy_lifecycle =
memory.enabled && (memory.extraction.enabled || memory.consolidation.request_enabled);
let subjektiv_lifecycle = subjektiv.enabled && subjektiv.extraction.enabled;
if legacy_lifecycle && subjektiv_lifecycle {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Workspace Memory and subjektiv lifecycles cannot both be enabled for one Worker",
));
}
Ok(())
}
/// Register the builtin file-manipulation tools, optional memory tools,
/// and the Worker-orchestration tools (SubWorkerSpawn + comm) on the Worker's
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
@@ -1250,28 +1306,95 @@ where
),
);
}
let memory_profile = &worker.manifest().feature.memory.profile;
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?;
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts().load_full(),
)?;
let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
(
plan.resident_summary_source.clone(),
plan.system_prompt_override.clone(),
)
});
let memory_lifecycle_config = memory_install_plan
.as_ref()
.map(|plan| plan.resolved_config.clone());
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
if let Some(plan) = memory_install_plan {
feature_registry.add_module(plan.module);
}
if let Some(memory_config) = memory_lifecycle_config
&& let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
let subjektiv_consolidation_plan =
crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts().load_full(),
)?;
if let Some(plan) = subjektiv_consolidation_plan {
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
feature_registry.add_module(plan.module);
}
let ordinary_subjektiv_features_enabled =
crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest());
if let Some(resident_summary_source) =
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(),
worker.workspace_client_handle(),
)?
{
feature_prompt_contribution = Some((Some(resident_summary_source), None));
}
if ordinary_subjektiv_features_enabled
&& let Some(subjektiv_memory) =
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.committed_session_capture_handle(),
worker.workspace_client_handle(),
)?
{
feature_registry.add_module(subjektiv_memory);
}
if ordinary_subjektiv_features_enabled
&& let Some(subjektiv_sessions) =
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.committed_session_capture_handle(),
worker.workspace_client_handle(),
)?
{
feature_registry.add_module(subjektiv_sessions);
}
if let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
worker.manifest_lifecycle_features_enabled(),
memory_lifecycle_config,
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
worker.workspace_client_handle(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
spawner_workspace_context.clone(),
worker.working_event_sender(),
)?
{
feature_registry.add_module(memory_lifecycle);
}
if let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
worker.manifest_lifecycle_features_enabled(),
worker.workspace_client_handle(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
spawner_workspace_context.clone(),
)? {
feature_registry.add_module(surface_lifecycle);
}
if ordinary_subjektiv_features_enabled
&& let Some(subjektiv_lifecycle) =
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
worker.manifest_lifecycle_features_enabled(),
memory_config,
worker.manifest().feature.subjektiv.clone(),
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
worker.workspace_client_handle(),
@@ -1282,7 +1405,7 @@ where
worker.working_event_sender(),
)?
{
feature_registry.add_module(memory_lifecycle);
feature_registry.add_module(subjektiv_lifecycle);
}
if sub_worker_enabled && !worker_enabled {
feature_registry.add_module(
@@ -1490,7 +1613,7 @@ where
),
));
}
if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution {
if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution {
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
}
worker.attach_tracker(tracker);
@@ -2443,6 +2566,12 @@ async fn controller_loop<C, St>(
}
}
};
publish_context_usage(
&shared_state,
&working_event_tx,
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
if !matches!(
result,
Err(WorkerError::Store(_))
@@ -2510,6 +2639,12 @@ async fn controller_loop<C, St>(
.await
{
worker.clear_in_flight_events();
publish_context_usage(
&shared_state,
&working_event_tx,
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
let snapshot = shared_state.transition(WorkerState::Idle);
let _ = working_event_tx.send(Event::WorkerState { snapshot });
}
@@ -3317,7 +3452,24 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
})
}
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
fn public_reasoning_config(
reasoning: &agen::llm_client::capability::ReasoningControl,
) -> protocol::ReasoningConfig {
match reasoning {
agen::llm_client::capability::ReasoningControl::Effort(effort) => {
protocol::ReasoningConfig::Effort {
effort: effort.as_str().to_owned(),
}
}
agen::llm_client::capability::ReasoningControl::BudgetTokens(budget_tokens) => {
protocol::ReasoningConfig::BudgetTokens {
budget_tokens: *budget_tokens,
}
}
}
}
pub(crate) fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where
C: LlmClient + 'static,
St: Store,
@@ -3360,6 +3512,7 @@ where
.into_iter()
.map(|def| def.name)
.collect();
let context_tokens = worker.total_tokens().tokens;
protocol::Greeting {
worker_name: manifest.worker.name.clone(),
cwd: worker
@@ -3368,10 +3521,19 @@ where
.unwrap_or_default(),
provider: provider_name,
model: model_id,
reasoning: manifest
.engine
.reasoning
.as_ref()
.map(public_reasoning_config),
scope_summary: worker.scope_snapshot().summary(),
tools: tool_names,
context_window,
context_tokens: worker.total_tokens().tokens,
context_tokens,
context_usage: Some(protocol::ContextUsage {
tokens: context_tokens,
source: protocol::ContextTokenSource::Estimated,
}),
}
}
@@ -3410,6 +3572,123 @@ mod tests {
use tempfile::TempDir;
use tokio::net::UnixListener;
#[test]
fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() {
let production = include_str!("controller.rs")
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("controller test module marker");
let startup = production
.split_once("async fn spawn_initialized")
.map(|(_, startup)| startup)
.expect("controller startup implementation");
let feature_install = startup
.find("let fs_for_view = register_worker_tools(")
.expect("feature installation call");
let materialize = startup
.find(".materialize_durable_session_head()")
.expect("runtime session-head materialization");
let exposure = startup
.find("let handle = WorkerHandle")
.expect("controller handle exposure");
let materialize_gate = startup[..materialize]
.rfind("if runtime_managed && worker.needs_initial_session_head_materialization() {")
.expect("fresh runtime-managed materialization gate");
assert!(feature_install < materialize_gate);
assert!(materialize_gate < materialize);
assert!(materialize < exposure);
assert!(
startup[materialize_gate..materialize]
.contains("needs_initial_session_head_materialization")
);
}
#[test]
fn public_reasoning_config_preserves_effort_and_signed_budget() {
let effort =
public_reasoning_config(&agen::llm_client::capability::ReasoningControl::Effort(
agen::llm_client::capability::ReasoningEffort::Other("provider-native".into()),
));
assert_eq!(
effort,
protocol::ReasoningConfig::Effort {
effort: "provider-native".into(),
}
);
let budget = public_reasoning_config(
&agen::llm_client::capability::ReasoningControl::BudgetTokens(-1),
);
assert_eq!(
budget,
protocol::ReasoningConfig::BudgetTokens { budget_tokens: -1 }
);
}
#[test]
fn context_usage_publication_updates_reconnect_state_and_live_clients() {
let shared_state = Arc::new(WorkerSharedState::new(
"worker".into(),
session_store::new_segment_id(),
String::new(),
protocol::Greeting {
worker_name: "worker".into(),
cwd: "/tmp".into(),
provider: "provider".into(),
model: "model".into(),
reasoning: None,
scope_summary: String::new(),
tools: Vec::new(),
context_window: 100_000,
context_tokens: 0,
context_usage: None,
},
));
let (events, mut receiver) = broadcast::channel(4);
publish_context_usage(
&shared_state,
&events,
Some(25_000),
protocol::ContextTokenSource::Estimated,
);
let expected = protocol::ContextUsage {
tokens: 25_000,
source: protocol::ContextTokenSource::Estimated,
};
assert_eq!(shared_state.greeting().context_usage, Some(expected));
assert!(matches!(
receiver.try_recv(),
Ok(Event::ContextUsage {
usage: Some(usage)
}) if usage == expected
));
}
#[test]
fn legacy_and_subjektiv_lifecycles_are_mutually_exclusive() {
let mut memory = manifest::MemoryFeatureProfileConfig::default();
memory.enabled = true;
memory.extraction.enabled = true;
let mut subjektiv = manifest::SubjektivFeatureProfileConfig::default();
subjektiv.enabled = true;
subjektiv.extraction.enabled = true;
let error = validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(error.to_string().contains("cannot both be enabled"));
memory.extraction.enabled = false;
assert!(
validate_memory_lifecycle_targets(&memory, &subjektiv).is_err(),
"legacy consolidation still installs the legacy lifecycle"
);
memory.consolidation.request_enabled = false;
validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap();
}
#[test]
fn no_controller_parent_notification_uses_durable_pending_authority() {
let temp = TempDir::new().unwrap();
@@ -3551,6 +3830,8 @@ mod tests {
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
));
let notify_buffer = NotifyBuffer::new();
@@ -3600,6 +3881,8 @@ mod tests {
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
@@ -4308,6 +4591,8 @@ mod tests {
tools: Vec::new(),
context_window: 1,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
);
assert!(
+12
View File
@@ -1507,6 +1507,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
@@ -1543,6 +1545,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
@@ -1634,6 +1638,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
@@ -1661,6 +1667,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
@@ -1765,6 +1773,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Paused.into(),
in_flight: Default::default(),
@@ -1820,6 +1830,8 @@ mod tests {
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
+4
View File
@@ -11,11 +11,15 @@ pub mod manage_worker;
pub mod memory;
pub(crate) mod memory_lifecycle;
pub mod memory_staging_output;
pub(crate) mod memory_surface_lifecycle;
pub(crate) mod memory_surface_output;
pub mod merge_request;
pub mod objective;
pub mod orchestration;
mod resource_projection;
pub mod session_explore;
pub(crate) mod subjektiv_memory;
pub(crate) mod subjektiv_session;
pub mod task;
pub mod ticket;
pub mod worker_observation;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -33,10 +33,17 @@ pub(crate) struct MemoryStagingOutputState {
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
extract_run_id: String,
destination: MemoryStagingDestination,
staged: Arc<Mutex<Vec<String>>>,
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
}
#[derive(Clone)]
enum MemoryStagingDestination {
WorkspaceMemory,
Subjektiv { session_id: String },
}
impl MemoryStagingOutputState {
pub(crate) fn new(
view: SessionCapture,
@@ -49,6 +56,25 @@ impl MemoryStagingOutputState {
workspace_client,
source,
extract_run_id,
destination: MemoryStagingDestination::WorkspaceMemory,
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn new_subjektiv(
view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
extract_run_id: String,
session_id: String,
) -> Self {
Self {
view: Arc::new(view),
workspace_client,
source,
extract_run_id,
destination: MemoryStagingDestination::Subjektiv { session_id },
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
@@ -201,34 +227,72 @@ impl Tool for StageMemoryCandidateTool {
staleness: params.staleness,
evidence_ids: params.entry_refs,
};
let result = self
.state
.workspace_client
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
MemoryStageCandidateOperation {
source: self.state.source.clone(),
extract_run_id: self.state.extract_run_id.clone(),
candidate,
evidence,
source_refs,
},
))
.await
.map_err(map_memory_stage_error)?;
let staging_ids = match result {
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
output.staging_ids
let operation = MemoryStageCandidateOperation {
source: self.state.source.clone(),
extract_run_id: self.state.extract_run_id.clone(),
candidate,
evidence,
source_refs,
};
let staging_ids = match &self.state.destination {
MemoryStagingDestination::WorkspaceMemory => {
let result = self
.state
.workspace_client
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
operation,
))
.await
.map_err(map_memory_stage_error)?;
match result {
MemoryBackendOperationResult::StagingWritten(output)
if output.staging_count == 1 =>
{
output.staging_ids
}
MemoryBackendOperationResult::StagingWritten(output) => {
return Err(ToolError::ExecutionFailed(format!(
"StageMemoryCandidate expected one staging record, backend wrote {}",
output.staging_count
)));
}
other => {
return Err(ToolError::ExecutionFailed(format!(
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
)));
}
}
}
MemoryBackendOperationResult::StagingWritten(output) => {
return Err(ToolError::ExecutionFailed(format!(
"StageMemoryCandidate expected one staging record, backend wrote {}",
output.staging_count
)));
}
other => {
return Err(ToolError::ExecutionFailed(format!(
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
)));
MemoryStagingDestination::Subjektiv { session_id } => {
let response = self
.state
.workspace_client
.execute_server_operation(
crate::worker::WorkspaceServerOperation::SubjektivStageCandidate(
server_api::SubjektivStageCandidateRequest {
session_id: session_id.clone(),
operation,
},
),
)
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"write subjektiv staging failed: {error}"
))
})?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"write subjektiv staging returned HTTP {}: {}",
response.status, response.body
)));
}
let output: server_api::SubjektivStageCandidateResponse =
serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!(
"decode subjektiv staging response: {error}"
))
})?;
vec![output.staging_id]
}
};
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
@@ -346,7 +410,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin
}
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
pub(crate) fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence {
id: entry.entry_ref.to_string(),
kind: evidence_kind(entry),
@@ -357,7 +421,7 @@ fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
}
}
fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
pub(crate) fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range),
@@ -425,10 +489,45 @@ fn truncate_line(text: &str, max_chars: usize) -> String {
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use agen::Item;
use super::*;
#[derive(Debug, Default)]
struct RecordingSubjektivClient {
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
}
impl WorkspaceClient for RecordingSubjektivClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-1")
}
fn kind(&self) -> &str {
"subjektiv-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(crate::worker::WorkspaceResponse {
status: 200,
body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse {
staging_id: "subject-candidate-1".to_string(),
})
.unwrap(),
})
}
}
fn state() -> MemoryStagingOutputState {
MemoryStagingOutputState::new(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
@@ -503,6 +602,39 @@ mod tests {
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
}
#[tokio::test]
async fn subjektiv_destination_sends_committed_session_without_subject_scope() {
let client = Arc::new(RecordingSubjektivClient::default());
let state = MemoryStagingOutputState::new_subjektiv(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
client.clone(),
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
"run-1".to_string(),
"session-committed".to_string(),
);
let tool = StageMemoryCandidateTool {
state: state.clone(),
};
tool.execute(
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(state.staged(), vec!["subject-candidate-1"]);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert!(requests[0].path.ends_with("/subjektiv/staging"));
let body = requests[0].body.as_deref().unwrap();
assert!(body.contains("session-committed"));
assert!(body.contains("why_useful"));
assert!(!body.contains("subject_id"));
}
#[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
@@ -0,0 +1,977 @@
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use agen::llm_client::event::Event as LlmEvent;
use agen::llm_client::{ClientError, LlmClient, Request};
use arc_swap::ArcSwap;
use async_trait::async_trait;
use futures_util::Stream;
use crate::PromptCatalog;
use crate::Scope;
use crate::feature::background::{
BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger,
FeatureBackgroundTask,
};
use crate::feature::builtin::memory::{
WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile,
};
use crate::feature::builtin::memory_surface_output::{
MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition,
};
use crate::feature::{
BackgroundTaskDeclaration, FeatureDescriptor, FeatureInstallContext, FeatureInstallError,
FeatureModule, FeatureRegistryBuilder,
};
use crate::hook::{HookError, HookErrorCategory};
use crate::internal_worker::{
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec,
run_internal_worker_with_cancel_sender,
};
use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext, WorkspaceClient};
use manifest::WorkerManifest;
const TASK_NAME: &str = "subjektiv-memory-surface";
const TASK_TIMEOUT: Duration = Duration::from_secs(300);
const MAX_GENERATION_ATTEMPTS: usize = 2;
const EDITOR_MAX_TURNS: u32 = 3;
const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。";
#[derive(Clone)]
pub(crate) struct SubjektivSurfaceLifecycleFeature {
task: SubjektivSurfaceLifecycleTask,
}
#[derive(Clone)]
struct SubjektivSurfaceLifecycleTask {
workspace_client: Arc<dyn WorkspaceClient>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_context: WorkerWorkspaceContext,
}
const INPUT_BUDGET_ERROR_MARKER: &str = "surface_editor_input_budget_exceeded";
struct InputBudgetLlmClient {
inner: Box<dyn LlmClient>,
input_token_budget: usize,
}
impl Clone for InputBudgetLlmClient {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone_boxed(),
input_token_budget: self.input_token_budget,
}
}
}
#[async_trait]
impl LlmClient for InputBudgetLlmClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let estimated = estimated_editor_request_tokens(&request)
.map_err(|error| ClientError::Config(error.to_string()))?;
if estimated > self.input_token_budget {
return Err(ClientError::Config(format!(
"{INPUT_BUDGET_ERROR_MARKER}: request estimate {estimated} exceeds {}",
self.input_token_budget
)));
}
self.inner.stream(request).await
}
}
impl SubjektivSurfaceLifecycleFeature {
pub(crate) fn from_manifest(
lifecycle_enabled: bool,
workspace_client: Arc<dyn WorkspaceClient>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_context: WorkerWorkspaceContext,
) -> std::io::Result<Option<Self>> {
let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest);
if !lifecycle_enabled || !dedicated {
return Ok(None);
}
manifest
.feature
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !manifest.feature.subjektiv.execution_enabled() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv surface generation requires trusted subject attachment",
));
}
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv surface generation requires Backend Workspace API authority",
));
}
Ok(Some(Self {
task: SubjektivSurfaceLifecycleTask {
workspace_client,
manifest,
client,
prompts,
workspace_context,
},
}))
}
}
impl FeatureModule for SubjektivSurfaceLifecycleFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle")
.with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.")
.with_background_task(BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let declaration = BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
);
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
spec.trigger = BackgroundTaskTrigger::RunCommitted;
context.background_tasks().register(spec, self.task.clone())
}
}
struct SurfaceEditorFailure {
error: HookError,
reason_code: &'static str,
}
impl SurfaceEditorFailure {
fn input_budget(error: HookError) -> Self {
Self {
error,
reason_code: "input_budget_exhausted",
}
}
fn editor(error: HookError) -> Self {
Self {
error,
reason_code: "editor_failed",
}
}
}
#[async_trait]
impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask {
async fn run(
&self,
context: BackgroundTaskContext,
cancellation: BackgroundTaskCancellation,
) -> Result<(), HookError> {
for attempt in 0..MAX_GENERATION_ATTEMPTS {
context.generation_fence.ensure_current()?;
if cancellation.is_cancelled() {
return Ok(());
}
let generation = self
.workspace_client
.prepare_subjektiv_memory_surface()
.await
.map_err(surface_hook_error)?;
if generation.materials.is_empty() && generation.active_memory_count > 0 {
self.record_failure(&generation.generation_id, "input_budget_exhausted")
.await;
tracing::warn!(
active_memory_count = generation.active_memory_count,
"subject Memory surface materials could not fit the bounded input"
);
return Ok(());
}
let points = if generation.materials.is_empty() {
Vec::new()
} else {
match self.edit_surface(&generation, cancellation.clone()).await {
Ok(points) => points,
Err(failure) => {
self.record_failure(&generation.generation_id, failure.reason_code)
.await;
tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "subject Memory surface editor failed");
return Ok(());
}
}
};
context.generation_fence.ensure_current()?;
if cancellation.is_cancelled() {
return Ok(());
}
let publish = self
.workspace_client
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
generation_id: generation.generation_id.clone(),
points,
})
.await;
match publish {
Ok(output) => {
tracing::debug!(
snapshot_id = output.snapshot_id,
store_revision = output.built_from_store_revision,
empty = output.empty,
"published subject Memory surface"
);
return Ok(());
}
Err(WorkspaceMemoryBackendError::Http { status, .. })
if status == reqwest::StatusCode::CONFLICT
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
{
// Confirmed Memory moved while editing. Discard the stale
// output and rebuild once from a fresh bounded generation.
continue;
}
Err(error) => {
self.record_failure(&generation.generation_id, "publish_failed")
.await;
tracing::warn!(%error, "subject Memory surface publication failed");
return Ok(());
}
}
}
Ok(())
}
}
impl SubjektivSurfaceLifecycleTask {
async fn edit_surface(
&self,
generation: &server_api::SubjektivSurfacePrepareResponse,
cancellation: BackgroundTaskCancellation,
) -> Result<Vec<server_api::SubjektivSurfacePoint>, SurfaceEditorFailure> {
let language = self
.manifest
.feature
.subjektiv
.workspace_settings()
.map(|settings| settings.language)
.ok_or_else(|| {
SurfaceEditorFailure::editor(HookError::new(
HookErrorCategory::Internal,
"surface editor requires bound subjektiv Workspace settings",
))
})?;
let system_prompt = self
.prompts
.load_full()
.subjektiv_memory_surface_system(&language)
.map_err(|error| {
SurfaceEditorFailure::editor(HookError::new(
HookErrorCategory::Internal,
error.to_string(),
))
})?;
let (input, editor_materials) = bounded_editor_input(system_prompt.as_str(), generation)
.map_err(SurfaceEditorFailure::input_budget)?;
let output_state =
MemorySurfaceOutputState::new(editor_materials, generation.body_token_budget);
let features = FeatureRegistryBuilder::new()
.with_module(MemorySurfaceOutputFeature::new(output_state.clone()));
let cancellation_observer = cancellation.clone();
let cancel_observer = move |sender: tokio::sync::mpsc::Sender<()>| {
tokio::spawn(async move {
cancellation_observer.cancelled().await;
let _ = sender.send(()).await;
});
};
let result = run_internal_worker_with_cancel_sender(
InternalWorkerSpec {
identity: InternalWorkerIdentity {
kind: "subjektiv-memory-surface",
run_id: uuid::Uuid::now_v7(),
},
manifest: self.manifest.clone(),
client: Box::new(InputBudgetLlmClient {
inner: self.client.clone_boxed(),
input_token_budget: generation.input_token_budget,
}),
system_prompt,
input,
cache_key: None,
max_turns: Some(EDITOR_MAX_TURNS),
engine_configurator: None,
features,
required_tools: &["SubmitMemorySurface"],
authority: InternalWorkerAuthority {
workspace: self.workspace_context.clone(),
filesystem: WorkerFilesystemAuthority::None,
scope: Scope::empty(),
workdir_session: None,
},
},
cancel_observer,
)
.await;
if let Err(error) = result {
let message = format!("surface editor Internal Worker failed: {}", error.source);
let error = HookError::new(HookErrorCategory::Internal, message.clone());
return Err(if message.contains(INPUT_BUDGET_ERROR_MARKER) {
SurfaceEditorFailure::input_budget(error)
} else {
SurfaceEditorFailure::editor(error)
});
}
output_state
.submitted()
.ok_or_else(|| {
HookError::new(
HookErrorCategory::Internal,
"surface editor finished without valid structured output",
)
})
.map_err(SurfaceEditorFailure::editor)
}
async fn record_failure(&self, generation_id: &str, reason_code: &str) {
if let Err(error) = self
.workspace_client
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
generation_id: generation_id.to_string(),
reason_code: reason_code.to_string(),
})
.await
{
tracing::debug!(%error, "could not record subject Memory surface failure");
}
}
}
fn bounded_editor_input(
system_prompt: &str,
generation: &server_api::SubjektivSurfacePrepareResponse,
) -> Result<(String, Vec<server_api::SubjektivSurfaceMaterial>), HookError> {
let empty_tokens = editor_request_tokens_for_materials(system_prompt, generation, &[])?;
if empty_tokens > generation.input_token_budget {
return Err(HookError::new(
HookErrorCategory::Internal,
format!(
"surface editor fixed prompt, JSON framing, and tool schema exceed the {} token input budget (estimated {empty_tokens})",
generation.input_token_budget
),
));
}
let mut populated_kinds = Vec::new();
for material in &generation.materials {
if !populated_kinds.contains(&material.kind) {
populated_kinds.push(material.kind.clone());
}
}
let material_token_budget = generation.input_token_budget - empty_tokens;
let per_kind_token_budget = material_token_budget / populated_kinds.len().max(1);
let mut accepted = vec![false; generation.materials.len()];
let mut deferred = Vec::new();
for (index, material) in generation.materials.iter().enumerate() {
let kind_materials = generation
.materials
.iter()
.zip(&accepted)
.filter(|(selected, accepted)| **accepted && selected.kind == material.kind)
.map(|(selected, _)| selected)
.chain(std::iter::once(material))
.collect::<Vec<_>>();
let all_materials = generation
.materials
.iter()
.zip(&accepted)
.filter(|(_, accepted)| **accepted)
.map(|(selected, _)| selected)
.chain(std::iter::once(material))
.collect::<Vec<_>>();
let kind_cost =
editor_request_tokens_for_materials(system_prompt, generation, &kind_materials)?
.saturating_sub(empty_tokens);
let all_tokens =
editor_request_tokens_for_materials(system_prompt, generation, &all_materials)?;
if kind_cost <= per_kind_token_budget && all_tokens <= generation.input_token_budget {
accepted[index] = true;
} else {
deferred.push(index);
}
}
// Equal per-kind shares reserve room in the final normalized request. Any
// unused share is reclaimed in the original canonical round-robin order.
for index in deferred {
let material = &generation.materials[index];
let all_materials = generation
.materials
.iter()
.zip(&accepted)
.filter(|(_, accepted)| **accepted)
.map(|(selected, _)| selected)
.chain(std::iter::once(material))
.collect::<Vec<_>>();
if editor_request_tokens_for_materials(system_prompt, generation, &all_materials)?
<= generation.input_token_budget
{
accepted[index] = true;
}
}
let materials = generation
.materials
.iter()
.zip(accepted)
.filter_map(|(material, accepted)| accepted.then_some(material.clone()))
.collect::<Vec<_>>();
if materials.is_empty() && !generation.materials.is_empty() {
return Err(HookError::new(
HookErrorCategory::Internal,
format!(
"surface editor fixed prompt and material overhead left no grounded material within the {} token input budget",
generation.input_token_budget
),
));
}
let material_refs = materials.iter().collect::<Vec<_>>();
let input = serialize_editor_input(generation, &material_refs)?;
Ok((input, materials))
}
fn editor_request_tokens_for_materials(
system_prompt: &str,
generation: &server_api::SubjektivSurfacePrepareResponse,
materials: &[&server_api::SubjektivSurfaceMaterial],
) -> Result<usize, HookError> {
let input = serialize_editor_input(generation, materials)?;
let request = Request::new()
.system(system_prompt)
.user(input)
.tool(submit_llm_tool_definition());
estimated_editor_request_tokens(&request)
}
fn serialize_editor_input(
generation: &server_api::SubjektivSurfacePrepareResponse,
materials: &[&server_api::SubjektivSurfaceMaterial],
) -> Result<String, HookError> {
serde_json::to_string(&serde_json::json!({
"question": EDITOR_QUESTION,
"body_token_budget": generation.body_token_budget,
"input_token_budget": generation.input_token_budget,
"materials": materials,
}))
.map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))
}
fn estimated_editor_request_tokens(request: &Request) -> Result<usize, HookError> {
// Serialize the actual prompt-bearing Agen request fields. This is the
// stable provider-independent boundary; provider-specific HTTP envelopes
// are intentionally outside the configured editor-input budget.
let request = serde_json::to_vec(&serde_json::json!({
"system_prompt": &request.system_prompt,
"items": &request.items,
"tools": &request.tools,
}))
.map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?;
Ok(request.len().saturating_add(3) / 4)
}
fn surface_hook_error(error: WorkspaceMemoryBackendError) -> HookError {
HookError::new(HookErrorCategory::Internal, error.to_string())
}
#[cfg(test)]
mod tests {
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, Request};
use futures::Stream;
use memory::extract::CandidateKind;
use super::*;
use crate::feature::FeatureId;
use crate::feature::background::FeatureBackgroundTaskRegistryBuilder;
use crate::hook::HookInvocationContext;
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
fn material(id: &str, body_bytes: usize) -> server_api::SubjektivSurfaceMaterial {
material_with_body(id, CandidateKind::Constraint, "x".repeat(body_bytes))
}
fn material_with_body(
id: &str,
kind: CandidateKind,
body_md: String,
) -> server_api::SubjektivSurfaceMaterial {
server_api::SubjektivSurfaceMaterial {
memory_id: id.into(),
revision: 1,
kind,
body_md,
why_useful: "budget fixture".into(),
staleness: None,
}
}
fn generation(
input_token_budget: usize,
materials: Vec<server_api::SubjektivSurfaceMaterial>,
) -> server_api::SubjektivSurfacePrepareResponse {
server_api::SubjektivSurfacePrepareResponse {
generation_id: "generation-1".into(),
store_revision: 1,
active_memory_count: materials.len(),
materials,
body_token_budget: 1_024,
input_token_budget,
per_kind_limit: 8,
total_material_limit: 24,
}
}
fn editor_request(system_prompt: &str, input: &str) -> Request {
Request::new()
.system(system_prompt)
.user(input)
.tool(submit_llm_tool_definition())
}
#[derive(Debug)]
struct SurfaceWorkspaceClient {
conflict_count: usize,
input_token_budget: usize,
prepare_calls: AtomicUsize,
publish_calls: AtomicUsize,
failure_reasons: Mutex<Vec<String>>,
}
impl SurfaceWorkspaceClient {
fn new(conflict_count: usize) -> Self {
Self {
conflict_count,
input_token_budget: 12_000,
prepare_calls: AtomicUsize::new(0),
publish_calls: AtomicUsize::new(0),
failure_reasons: Mutex::new(Vec::new()),
}
}
fn with_input_token_budget(input_token_budget: usize) -> Self {
Self {
input_token_budget,
..Self::new(0)
}
}
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
WorkspaceResponse {
status: 200,
body: serde_json::to_string(&value).unwrap(),
}
}
}
impl WorkspaceClient for SurfaceWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-1")
}
fn kind(&self) -> &str {
"surface-lifecycle-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let request: server_api::SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap_or_default())
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
match request.operation {
server_api::SubjektivMemoryBackendOperation::PrepareSurface(_) => {
let call = self.prepare_calls.fetch_add(1, Ordering::SeqCst) + 1;
let mut prepared =
generation(self.input_token_budget, vec![material("memory-1", 32)]);
prepared.generation_id = format!("generation-{call}");
prepared.store_revision = call as u64;
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
))
}
server_api::SubjektivMemoryBackendOperation::PublishSurface(input) => {
assert_eq!(input.points.len(), 1);
let call = self.publish_calls.fetch_add(1, Ordering::SeqCst) + 1;
if call <= self.conflict_count {
return Ok(WorkspaceResponse {
status: 409,
body: "store generation moved".into(),
});
}
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
server_api::SubjektivSurfacePublishResponse {
snapshot_id: format!("surface-{call}"),
built_from_store_revision: call as u64,
empty: false,
},
),
))
}
server_api::SubjektivMemoryBackendOperation::FailSurface(input) => {
self.failure_reasons.lock().unwrap().push(input.reason_code);
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
server_api::SubjektivSurfaceFailureResponse {
store_revision: self.prepare_calls.load(Ordering::SeqCst) as u64,
status: "failed".into(),
},
),
))
}
_ => Err(WorkspaceClientError::Unavailable(
"surface lifecycle test only accepts surface operations".into(),
)),
}
}
}
#[derive(Clone)]
struct SurfaceEditorClient {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl LlmClient for SurfaceEditorClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let call = self.calls.fetch_add(1, Ordering::SeqCst);
let events = if call % 2 == 0 {
vec![
LlmEvent::tool_use_start(0, format!("submit-{call}"), "SubmitMemorySurface"),
LlmEvent::tool_input_delta(
0,
serde_json::json!({
"points": [{
"body_md": "- Keep the confirmed constraint.",
"memory_refs": [{"memory_id": "memory-1", "revision": 1}]
}]
})
.to_string(),
),
LlmEvent::tool_use_stop(0),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
} else {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "submitted"),
LlmEvent::text_block_stop(0, None),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
};
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
fn test_manifest() -> WorkerManifest {
let mut manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "surface-lifecycle-test"
scope = "main"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
[[scope.allow]]
target = "/surface-lifecycle-test"
permission = "write"
"#,
)
.unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest
.feature
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
manifest
}
fn test_task(
workspace_client: Arc<dyn WorkspaceClient>,
calls: Arc<AtomicUsize>,
) -> SubjektivSurfaceLifecycleTask {
SubjektivSurfaceLifecycleTask {
workspace_client,
manifest: test_manifest(),
client: Box::new(SurfaceEditorClient { calls }),
prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
workspace_context: WorkerWorkspaceContext::no_workspace(),
}
}
async fn run_task(task: SubjektivSurfaceLifecycleTask) {
let declaration = BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"surface lifecycle integration test",
);
let mut spec = BackgroundTaskSpec::single_flight(declaration, Duration::from_secs(5));
spec.trigger = BackgroundTaskTrigger::RunCommitted;
let mut builder = FeatureBackgroundTaskRegistryBuilder::default();
builder
.register(FeatureId::builtin("surface-lifecycle-test"), spec, task)
.unwrap();
let registry = builder.build();
registry
.start_run_committed(HookInvocationContext {
worker_id: "worker-1".into(),
session_id: "session-1".into(),
session_revision: 1,
run_id: Some("run-1".into()),
..Default::default()
})
.unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if !registry.diagnostics().is_empty() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("surface lifecycle task should finish");
registry.shutdown().await.unwrap();
}
#[test]
fn editor_input_budget_counts_prompt_json_question_and_tool_schema() {
let generation = generation(
4_000,
vec![material("memory-1", 9_000), material("memory-2", 9_000)],
);
let (input, selected) = bounded_editor_input("system policy", &generation).unwrap();
assert_eq!(selected.len(), 1, "deterministically bounds one category");
assert_eq!(selected[0].memory_id, "memory-1");
assert!(input.contains(EDITOR_QUESTION));
assert!(
estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap()
<= generation.input_token_budget
);
let materials_only_tokens = (serde_json::to_vec(&selected)
.unwrap()
.len()
.saturating_add(3))
/ 4;
assert!(
estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap()
> materials_only_tokens,
"full accounting must include framing and the SubmitMemorySurface schema"
);
}
#[test]
fn final_editor_budget_preserves_feasible_nonempty_categories() {
let materials = vec![
material_with_body(
"preference-large",
CandidateKind::Preference,
"\\\"".repeat(8_000),
),
material_with_body(
"constraint-small",
CandidateKind::Constraint,
"constraint".into(),
),
material_with_body("decision-small", CandidateKind::Decision, "decision".into()),
material_with_body(
"assumption-small",
CandidateKind::WorkingAssumption,
"assumption".into(),
),
material_with_body(
"question-small",
CandidateKind::OpenQuestion,
"question".into(),
),
material_with_body("lesson-small", CandidateKind::Lesson, "lesson".into()),
];
let mut generation = generation(9_999, materials);
for _ in 0..3 {
let large = editor_request_tokens_for_materials(
"system policy",
&generation,
&[&generation.materials[0]],
)
.unwrap();
let small = editor_request_tokens_for_materials(
"system policy",
&generation,
&generation.materials[1..].iter().collect::<Vec<_>>(),
)
.unwrap();
generation.input_token_budget = large.max(small);
}
let all = editor_request_tokens_for_materials(
"system policy",
&generation,
&generation.materials.iter().collect::<Vec<_>>(),
)
.unwrap();
assert!(all > generation.input_token_budget);
let (input, selected) = bounded_editor_input("system policy", &generation).unwrap();
assert!(
!selected
.iter()
.any(|item| item.kind == CandidateKind::Preference)
);
for kind in [
CandidateKind::Constraint,
CandidateKind::Decision,
CandidateKind::WorkingAssumption,
CandidateKind::OpenQuestion,
CandidateKind::Lesson,
] {
assert!(selected.iter().any(|item| item.kind == kind));
}
assert!(
estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap()
<= generation.input_token_budget
);
}
#[tokio::test]
async fn actual_editor_request_is_rejected_before_provider_when_over_budget() {
let calls = Arc::new(AtomicUsize::new(0));
let client = InputBudgetLlmClient {
inner: Box::new(SurfaceEditorClient {
calls: calls.clone(),
}),
input_token_budget: 32,
};
let request = editor_request("system policy", &"x".repeat(1_000));
let error = match client.stream(request).await {
Err(error) => error,
Ok(_) => panic!("over-budget request reached the provider"),
};
assert!(error.to_string().contains(INPUT_BUDGET_ERROR_MARKER));
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn correction_turns_remain_subject_to_editor_input_budget() {
let calls = Arc::new(AtomicUsize::new(0));
let client = InputBudgetLlmClient {
inner: Box::new(SurfaceEditorClient {
calls: calls.clone(),
}),
input_token_budget: 2_000,
};
let _stream = client
.stream(editor_request("system policy", "small initial request"))
.await
.unwrap();
let request =
editor_request("system policy", "small initial request").assistant("x".repeat(12_000));
let error = match client.stream(request).await {
Err(error) => error,
Ok(_) => panic!("over-budget request reached the provider"),
};
assert!(error.to_string().contains(INPUT_BUDGET_ERROR_MARKER));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn lifecycle_rebuilds_once_after_a_store_generation_conflict() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(1));
let editor_calls = Arc::new(AtomicUsize::new(0));
run_task(test_task(workspace.clone(), editor_calls.clone())).await;
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
assert_eq!(editor_calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn lifecycle_bounds_conflict_retries_and_records_terminal_failure() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(MAX_GENERATION_ATTEMPTS));
let editor_calls = Arc::new(AtomicUsize::new(0));
run_task(test_task(workspace.clone(), editor_calls.clone())).await;
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
assert_eq!(editor_calls.load(Ordering::SeqCst), 4);
assert_eq!(
workspace.failure_reasons.lock().unwrap().as_slice(),
["publish_failed"]
);
}
#[tokio::test]
async fn lifecycle_records_input_budget_exhaustion_without_provider_or_publish() {
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
let editor_calls = Arc::new(AtomicUsize::new(0));
run_task(test_task(workspace.clone(), editor_calls.clone())).await;
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
assert_eq!(editor_calls.load(Ordering::SeqCst), 0);
assert_eq!(
workspace.failure_reasons.lock().unwrap().as_slice(),
["input_budget_exhausted"]
);
}
#[test]
fn editor_input_budget_never_relabels_trimmed_nonempty_materials_as_empty() {
let generation = generation(1, vec![material("memory-1", 32)]);
let error = bounded_editor_input("system policy", &generation).unwrap_err();
assert!(error.to_string().contains("input budget"));
}
}
@@ -0,0 +1,256 @@
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
const SUBMIT_TOOL: &str = "SubmitMemorySurface";
const SUBMIT_DESCRIPTION: &str = "Submit a bounded Markdown Memory surface whose every point cites exact refs from the supplied confirmed-Memory materials. Reference validation proves existence and scope, not semantic correctness.";
#[derive(Clone)]
pub(crate) struct MemorySurfaceOutputState {
materials: Arc<Vec<server_api::SubjektivSurfaceMaterial>>,
body_token_budget: usize,
submitted: Arc<Mutex<Option<Vec<server_api::SubjektivSurfacePoint>>>>,
}
impl MemorySurfaceOutputState {
pub(crate) fn new(
materials: Vec<server_api::SubjektivSurfaceMaterial>,
body_token_budget: usize,
) -> Self {
Self {
materials: Arc::new(materials),
body_token_budget,
submitted: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn submitted(&self) -> Option<Vec<server_api::SubjektivSurfacePoint>> {
self.submitted
.lock()
.expect("Memory surface output state poisoned")
.clone()
}
fn validate(&self, points: &[server_api::SubjektivSurfacePoint]) -> Result<(), ToolError> {
if self.materials.is_empty() {
return if points.is_empty() {
Ok(())
} else {
Err(ToolError::InvalidArgument(
"empty materials require an empty points array".into(),
))
};
}
if points.is_empty() {
return Err(ToolError::InvalidArgument(
"non-empty materials require at least one grounded point".into(),
));
}
if points.len() > 24 {
return Err(ToolError::InvalidArgument(format!(
"points count {} exceeds the configured limit 24",
points.len()
)));
}
let allowed = self
.materials
.iter()
.map(|material| (material.memory_id.as_str(), material.revision))
.collect::<HashSet<_>>();
let mut bodies = Vec::with_capacity(points.len());
for point in points {
let body = point.body_md.trim();
if body.is_empty() {
return Err(ToolError::InvalidArgument(
"surface point body_md must not be empty".into(),
));
}
if point.memory_refs.is_empty() {
return Err(ToolError::InvalidArgument(
"every surface point requires at least one memory_ref".into(),
));
}
let mut point_refs = HashSet::new();
for reference in &point.memory_refs {
if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) {
return Err(ToolError::InvalidArgument(format!(
"duplicate surface point ref {}@{}",
reference.memory_id, reference.revision
)));
}
if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) {
return Err(ToolError::InvalidArgument(format!(
"surface ref {}@{} is not in the generation materials",
reference.memory_id, reference.revision
)));
}
}
bodies.push(body);
}
let bytes = bodies
.iter()
.map(|body| body.len())
.sum::<usize>()
.saturating_add(points.len().saturating_sub(1) * 2);
let tokens = bytes.saturating_add(3) / 4;
if tokens > self.body_token_budget {
return Err(ToolError::InvalidArgument(format!(
"surface body token estimate {tokens} exceeds budget {}",
self.body_token_budget
)));
}
Ok(())
}
}
#[derive(Clone)]
pub(crate) struct MemorySurfaceOutputFeature {
state: MemorySurfaceOutputState,
}
impl MemorySurfaceOutputFeature {
pub(crate) fn new(state: MemorySurfaceOutputState) -> Self {
Self { state }
}
}
impl FeatureModule for MemorySurfaceOutputFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("memory-surface-output", "Memory Surface Output")
.with_description(
"Restricted structured output for one clean-context Memory surface editor.",
)
.with_tool(ToolDeclaration::new(SUBMIT_TOOL, SUBMIT_DESCRIPTION))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
SUBMIT_TOOL,
submit_definition(self.state.clone()),
))
}
}
fn submit_input_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(SubmitMemorySurfaceParams))
.unwrap_or_else(|_| serde_json::json!({}))
}
pub(crate) fn submit_llm_tool_definition() -> agen::llm_client::ToolDefinition {
agen::llm_client::ToolDefinition::new(SUBMIT_TOOL)
.description(SUBMIT_DESCRIPTION)
.input_schema(submit_input_schema())
}
fn submit_definition(state: MemorySurfaceOutputState) -> ToolDefinition {
Arc::new(move || {
let definition = submit_llm_tool_definition();
let meta = ToolMeta::new(definition.name)
.description(
definition
.description
.expect("SubmitMemorySurface description is fixed"),
)
.input_schema(definition.input_schema);
let tool: Arc<dyn Tool> = Arc::new(SubmitMemorySurfaceTool {
state: state.clone(),
});
(meta, tool)
})
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SubmitMemorySurfaceParams {
points: Vec<server_api::SubjektivSurfacePoint>,
}
struct SubmitMemorySurfaceTool {
state: MemorySurfaceOutputState,
}
#[async_trait]
impl Tool for SubmitMemorySurfaceTool {
async fn execute(
&self,
input_json: &str,
_context: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: SubmitMemorySurfaceParams =
serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid SubmitMemorySurface input: {error}"))
})?;
self.state.validate(&params.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);
}
}
+3
View File
@@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture {
/// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
/// Whether the durable capture contains a committed run boundary. This is
/// false for a newly materialized Session before its first Run commits.
pub(crate) has_committed_run: bool,
pub(crate) run_exit: CommittedRunExit,
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
pub(crate) usage_history: Vec<UsageRecord>,
+121 -40
View File
@@ -7,7 +7,7 @@
//! need durable domain audit must keep using their domain authority (for example Memory audit).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock};
use agen::timeline::event::UsageEvent;
use agen::{Engine, EngineError, llm_client::LlmClient};
@@ -17,7 +17,9 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr
use tokio::sync::broadcast;
use uuid::Uuid;
use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events};
use crate::controller::{
build_greeting, wire_event_bridges_on_engine, wire_workdir_command_events,
};
use crate::feature::FeatureRegistryBuilder;
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
use crate::ipc::alerter::Alerter;
@@ -409,6 +411,7 @@ enum InternalWorkerSessionCommand {
#[derive(Debug, Clone)]
pub(crate) struct InternalWorkerSessionSnapshot {
pub session: protocol::SessionSnapshot,
pub greeting: Option<protocol::Greeting>,
pub status: WorkerStatus,
pub error: Option<String>,
pub in_flight: InFlightSnapshot,
@@ -428,6 +431,7 @@ pub(crate) struct InternalWorkerSessionHandle {
visibility: InternalWorkerVisibility,
last_error: Arc<Mutex<Option<String>>>,
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
greeting: Option<Arc<RwLock<protocol::Greeting>>>,
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
sink: SegmentLogSink,
#[cfg(test)]
@@ -481,6 +485,12 @@ impl InternalWorkerSessionHandle {
};
InternalWorkerSessionSnapshot {
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
greeting: self.greeting.as_ref().map(|greeting| {
greeting
.read()
.expect("internal Worker greeting lock poisoned")
.clone()
}),
status: match self.status() {
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
@@ -824,6 +834,23 @@ fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Se
});
}
fn publish_internal_context_usage(
greeting: &RwLock<protocol::Greeting>,
event_tx: &broadcast::Sender<Event>,
tokens: Option<u64>,
source: protocol::ContextTokenSource,
) {
let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
{
let mut greeting = greeting
.write()
.expect("internal Worker greeting lock poisoned");
greeting.context_tokens = tokens.unwrap_or_default();
greeting.context_usage = usage;
}
let _ = event_tx.send(Event::ContextUsage { usage });
}
pub(crate) async fn prepare_internal_worker_session(
mut worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
store: EphemeralSessionStore,
@@ -851,8 +878,20 @@ pub(crate) async fn prepare_internal_worker_session(
worker.attach_alerter(alerter.clone());
worker.attach_working_event_tx(event_tx.clone());
worker.attach_in_flight_events(in_flight.clone());
let greeting = Arc::new(RwLock::new(build_greeting(&worker)));
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
let usage_greeting = greeting.clone();
let usage_events = event_tx.clone();
worker.engine_mut().on_usage(move |event| {
publish_internal_context_usage(
&usage_greeting,
&usage_events,
event.input_tokens,
protocol::ContextTokenSource::Measured,
);
});
let actor_greeting = greeting.clone();
let session_id = worker.session_id();
let segment_id = worker.segment_id();
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8);
@@ -874,6 +913,7 @@ pub(crate) async fn prepare_internal_worker_session(
visibility,
last_error: last_error.clone(),
last_outcome: last_outcome.clone(),
greeting: Some(greeting),
child_registry,
sink,
#[cfg(test)]
@@ -886,50 +926,56 @@ pub(crate) async fn prepare_internal_worker_session(
match command {
InternalWorkerSessionCommand::Run(input) => {
actor_in_flight.clear();
let cancel_sender = worker.engine_mut().cancel_sender();
let mut run = std::pin::pin!(worker.run_text(&input));
loop {
tokio::select! {
result = &mut run => {
let (turn_status, error, outcome) =
classify_internal_turn_result(result);
actor_in_flight.clear();
*last_outcome.lock().unwrap() = Some(outcome);
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error {
*last_error.lock().unwrap() = Some(message.clone());
let _ = event_tx.send(Event::Error {
code: protocol::ErrorCode::Internal,
message,
});
let (turn_status, error, outcome) = {
let cancel_sender = worker.engine_mut().cancel_sender();
let mut run = std::pin::pin!(worker.run_text(&input));
loop {
tokio::select! {
result = &mut run => {
break classify_internal_turn_result(result);
}
send_internal_worker_state(&event_tx, turn_status);
if let Some(callback) = &on_turn_end {
callback(turn_status);
}
state_changed.notify_waiters();
break;
}
command = command_rx.recv() => {
match command {
Some(InternalWorkerSessionCommand::Stop(done)) => {
let _ = cancel_sender.send(()).await;
let _ = (&mut run).await;
stop_done = Some(done);
break 'actor;
}
Some(InternalWorkerSessionCommand::Run(_)) => {
// `send` reserves Running atomically, so a second Run cannot be enqueued.
}
None => {
let _ = cancel_sender.send(()).await;
let _ = (&mut run).await;
break 'actor;
command = command_rx.recv() => {
match command {
Some(InternalWorkerSessionCommand::Stop(done)) => {
let _ = cancel_sender.send(()).await;
let _ = (&mut run).await;
stop_done = Some(done);
break 'actor;
}
Some(InternalWorkerSessionCommand::Run(_)) => {
// `send` reserves Running atomically, so a second Run cannot be enqueued.
}
None => {
let _ = cancel_sender.send(()).await;
let _ = (&mut run).await;
break 'actor;
}
}
}
}
}
};
publish_internal_context_usage(
&actor_greeting,
&event_tx,
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
actor_in_flight.clear();
*last_outcome.lock().unwrap() = Some(outcome);
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error {
*last_error.lock().unwrap() = Some(message.clone());
let _ = event_tx.send(Event::Error {
code: protocol::ErrorCode::Internal,
message,
});
}
send_internal_worker_state(&event_tx, turn_status);
if let Some(callback) = &on_turn_end {
callback(turn_status);
}
state_changed.notify_waiters();
}
InternalWorkerSessionCommand::Stop(done) => {
stop_done = Some(done);
@@ -1174,6 +1220,14 @@ impl session_store::WorkerMetadataStore for EphemeralSessionStore {
#[cfg(test)]
pub(crate) fn test_internal_worker_session(
visibility: InternalWorkerVisibility,
) -> (InternalWorkerSessionHandle, broadcast::Sender<Event>) {
test_internal_worker_session_with_greeting(visibility, None)
}
#[cfg(test)]
pub(crate) fn test_internal_worker_session_with_greeting(
visibility: InternalWorkerVisibility,
greeting: Option<protocol::Greeting>,
) -> (InternalWorkerSessionHandle, broadcast::Sender<Event>) {
let store = EphemeralSessionStore::default();
let session_id = session_store::new_session_id();
@@ -1206,6 +1260,7 @@ pub(crate) fn test_internal_worker_session(
visibility,
last_error: Arc::new(Mutex::new(None)),
last_outcome: Arc::new(Mutex::new(None)),
greeting: greeting.map(|greeting| Arc::new(RwLock::new(greeting))),
child_registry: None,
sink,
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
@@ -1485,11 +1540,37 @@ permission = "write"
assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1);
let entries_after_first = handle.entries().len();
assert!(entries_after_first >= 4);
assert!(matches!(
handle
.protocol_snapshot()
.greeting
.and_then(|greeting| greeting.context_usage),
Some(protocol::ContextUsage {
source: protocol::ContextTokenSource::Estimated,
..
})
));
let mut events = handle.subscribe_events();
handle.send("follow-up").await.expect("send follow-up turn");
assert_eq!(
handle.wait_until_idle().await,
InternalWorkerSessionStatus::Idle
);
let mut saw_estimated_context = false;
while let Ok(event) = events.try_recv() {
if matches!(
event,
Event::ContextUsage {
usage: Some(protocol::ContextUsage {
source: protocol::ContextTokenSource::Estimated,
..
})
}
) {
saw_estimated_context = true;
}
}
assert!(saw_estimated_context);
assert!(handle.protocol_snapshot().in_flight.blocks.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(handle.entries().len() > entries_after_first);
+163 -2
View File
@@ -16,9 +16,23 @@ pub struct WorkerProtocolSessionStreams {
pub events: broadcast::Receiver<Event>,
}
fn subscribe_before_snapshot<Subscription, Snapshot>(
subscribe: impl FnOnce() -> Subscription,
snapshot: impl FnOnce() -> Snapshot,
) -> (Snapshot, Subscription) {
// Subscription must exist before any snapshot field is read. A concurrent
// state update can then appear in both lanes, but it can never be absent
// from both the snapshot and the queued live stream.
let subscription = subscribe();
let snapshot = snapshot();
(snapshot, subscription)
}
pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams {
let (snapshot_event, log_entries) = handle.snapshot_event_with_entry_subscription();
let (alert_snapshot, events) = handle.alerter.subscribe_with_snapshot();
let ((snapshot_event, log_entries), (alert_snapshot, events)) = subscribe_before_snapshot(
|| handle.alerter.subscribe_with_snapshot(),
|| handle.snapshot_event_with_entry_subscription(),
);
WorkerProtocolSessionStreams {
snapshot_event,
alert_snapshot,
@@ -117,11 +131,158 @@ pub async fn dispatch_worker_protocol_method(
#[cfg(test)]
mod tests {
use super::*;
use session_store::Store;
use crate::segment_log_sink::SegmentLogSink;
use crate::worker::{LogWriterHandle, SegmentState};
fn committed_entry(event: Event) -> protocol::SessionSnapshotEntry {
match event {
Event::SessionEntryCommitted { entry } => entry,
other => panic!("expected SessionEntryCommitted, got {other:?}"),
}
}
#[test]
fn context_publication_between_subscription_and_snapshot_cannot_be_missed() {
let (events, _) = broadcast::channel(4);
let context_tokens = std::cell::Cell::new(20_u64);
let (snapshot_tokens, mut receiver) = subscribe_before_snapshot(
|| events.subscribe(),
|| {
context_tokens.set(25);
events
.send(Event::ContextUsage {
usage: Some(protocol::ContextUsage {
tokens: 25,
source: protocol::ContextTokenSource::Estimated,
}),
})
.unwrap();
context_tokens.get()
},
);
assert_eq!(snapshot_tokens, 25);
assert!(matches!(
receiver.try_recv(),
Ok(Event::ContextUsage {
usage: Some(protocol::ContextUsage { tokens: 25, .. })
})
));
}
#[test]
fn persisted_run_transitions_keep_identity_across_live_and_snapshot_projection() {
let temp = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(temp.path()).unwrap();
let session_id = session_store::new_session_id();
let segment_id = session_store::new_segment_id();
let start = LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
};
store
.create_segment(session_id, segment_id, std::slice::from_ref(&start))
.unwrap();
let sink = SegmentLogSink::with_initial(vec![start]);
let writer = LogWriterHandle {
store: store.clone(),
state: SegmentState::new(session_id, segment_id, 1),
sink: sink.clone(),
in_flight: None,
};
let (_, mut receiver) = sink.subscribe_with_snapshot();
let transitions = [
LogEntry::RunYielded {
ts: 10,
entry_id: None,
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunResumed {
ts: 10,
entry_id: None,
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunErrored {
ts: 10,
entry_id: None,
interrupted: false,
message: "same compaction failure".into(),
failure: Some(protocol::RunFailureKind::Compaction),
},
LogEntry::RunErrored {
ts: 10,
entry_id: None,
interrupted: false,
message: "same compaction failure".into(),
failure: Some(protocol::RunFailureKind::Compaction),
},
LogEntry::RunCancelled {
ts: 10,
entry_id: None,
},
];
let mut live_ids = Vec::new();
for transition in transitions {
writer.append_entry(transition).unwrap();
let committed = receiver.try_recv().expect("committed transition broadcast");
live_ids.push(
committed_entry(live_log_entry_event(committed).expect("live projection")).entry_id,
);
}
assert_eq!(
live_ids
.iter()
.collect::<std::collections::HashSet<_>>()
.len(),
5
);
let (mirror, _) = sink.subscribe_with_snapshot();
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&mirror);
let snapshot_ids: Vec<_> = snapshot
.entries
.iter()
.filter(|entry| {
matches!(
entry.data,
protocol::SessionSnapshotEntryData::RunYielded { .. }
| protocol::SessionSnapshotEntryData::RunResumed { .. }
| protocol::SessionSnapshotEntryData::RunError { .. }
| protocol::SessionSnapshotEntryData::RunCancelled
)
})
.map(|entry| entry.entry_id.clone())
.collect();
assert_eq!(snapshot_ids, live_ids);
let persisted = store.read_all(session_id, segment_id).unwrap();
let persisted_snapshot =
session_store::public_snapshot::project_current_session_snapshot(&persisted);
let persisted_ids: Vec<_> = persisted_snapshot
.entries
.iter()
.filter(|entry| snapshot_ids.contains(&entry.entry_id))
.map(|entry| entry.entry_id.clone())
.collect();
assert_eq!(persisted_ids, live_ids);
}
#[test]
fn durable_run_transition_maps_to_session_entry_committed() {
let event = live_log_entry_event(LogEntry::RunYielded {
ts: 42,
entry_id: Some(session_store::LoggedSessionHistoryEntryId::new()),
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 3,
})
+56
View File
@@ -94,6 +94,8 @@ pub enum WorkerPrompt {
CompactSystem,
MemoryExtractSystem,
MemoryConsolidationSystem,
SubjektivMemoryConsolidationSystem,
SubjektivMemorySurfaceSystem,
FlowVerifierSystem,
BackendJobSystem,
NotifyWrapper,
@@ -102,6 +104,7 @@ pub enum WorkerPrompt {
WorkingBoundariesSection,
AgentsMdSection,
ResidentMemorySummarySection,
ResidentMemoryRestoreSection,
WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription,
}
@@ -112,6 +115,10 @@ impl WorkerPrompt {
Self::CompactSystem => "internal.compact_system",
Self::MemoryExtractSystem => "internal.memory_extract_system",
Self::MemoryConsolidationSystem => "internal.memory_consolidation_system",
Self::SubjektivMemoryConsolidationSystem => {
"internal.subjektiv_memory_consolidation_system"
}
Self::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system",
Self::FlowVerifierSystem => "internal.flow_verifier_system",
Self::BackendJobSystem => "internal.backend_job_system",
Self::NotifyWrapper => "internal.notify_wrapper",
@@ -120,6 +127,7 @@ impl WorkerPrompt {
Self::WorkingBoundariesSection => "internal.working_boundaries_section",
Self::AgentsMdSection => "internal.agents_md_section",
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
}
@@ -131,6 +139,8 @@ impl WorkerPrompt {
WorkerPrompt::CompactSystem,
WorkerPrompt::MemoryExtractSystem,
WorkerPrompt::MemoryConsolidationSystem,
WorkerPrompt::SubjektivMemoryConsolidationSystem,
WorkerPrompt::SubjektivMemorySurfaceSystem,
WorkerPrompt::FlowVerifierSystem,
WorkerPrompt::BackendJobSystem,
WorkerPrompt::NotifyWrapper,
@@ -139,6 +149,7 @@ impl WorkerPrompt {
WorkerPrompt::WorkingBoundariesSection,
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
@@ -449,6 +460,21 @@ impl PromptCatalog {
single("language", language),
)
}
pub fn subjektiv_memory_consolidation_system(
&self,
language: &str,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivMemoryConsolidationSystem,
single("language", language),
)
}
pub fn subjektiv_memory_surface_system(&self, language: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivMemorySurfaceSystem,
single("language", language),
)
}
pub fn flow_verifier_system(&self) -> Result<String, CatalogError> {
self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED)
}
@@ -479,6 +505,19 @@ impl PromptCatalog {
single("summary", summary),
)
}
pub fn resident_memory_restore_section(
&self,
surface_ready: bool,
summary: Option<&str>,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::ResidentMemoryRestoreSection,
minijinja::context! {
surface_ready => surface_ready,
summary => summary.unwrap_or_default(),
},
)
}
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::WorkerOrchestrationGuidanceSection,
@@ -913,6 +952,23 @@ mod tests {
.unwrap()
.contains("LANGUAGE_MARKER")
);
let surface = catalog
.subjektiv_memory_surface_system("SURFACE_LANGUAGE")
.unwrap();
assert!(surface.contains("SURFACE_LANGUAGE"));
for required in [
"conditions",
"negation",
"contradictions",
"staleness",
"SubmitMemorySurface",
"not semantic verification",
] {
assert!(
surface.contains(required),
"missing surface rule {required}"
);
}
assert!(
catalog
.notify_wrapper("NOTIFICATION_MARKER")
+2
View File
@@ -212,6 +212,8 @@ mod tests {
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
)
}
+1
View File
@@ -330,6 +330,7 @@ mod tests {
// Durable logical-Run transitions share the committed-entry lane.
sink.publish(LogEntry::RunResumed {
ts: now_millis(),
entry_id: None,
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 1,
});
+95
View File
@@ -4,6 +4,7 @@
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
//! range/search, read, and generic evidence projections without granting mutation authority.
use std::collections::HashSet;
use std::sync::Arc;
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
@@ -207,6 +208,14 @@ pub(crate) struct ReadResult {
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct CommittedToolCall {
pub call_id: String,
pub name: String,
pub arguments: String,
pub evidence: SessionEntryEvidence,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
@@ -600,6 +609,76 @@ impl SessionCapture {
ReadResult { entries, truncated }
}
/// Resolves the committed tool-call input for a provider call id. This is
/// used by explicit-memory receipts only after the containing run is durable,
/// so a model-authored claim can cite its own committed tool invocation
/// without inventing a SessionEntryRef.
pub(crate) fn evidence_for_tool_call(&self, call_id: &str) -> Option<SessionEntryEvidence> {
let entry_index = self.entries.iter().position(|entry| {
matches!(
&entry.item,
Item::ToolCall {
call_id: stored_call_id,
..
} if stored_call_id == call_id
)
})?;
let reference = self.index.iter().find(|entry| {
entry.entry_range[0] == entry_index as u64
&& entry.kind == ReferenceKind::Tool
&& entry.tool_part == Some(ToolPart::Input)
})?;
self.evidence_for(reference.id.as_str())
}
pub(crate) fn committed_pending_tool_calls(&self, names: &[&str]) -> Vec<CommittedToolCall> {
let pending_call_ids = self
.entries
.iter()
.filter_map(|entry| {
let Item::ToolResult {
call_id,
content: Some(content),
is_error: false,
..
} = &entry.item
else {
return None;
};
(serde_json::from_str::<serde_json::Value>(content)
.ok()
.and_then(|value| value.get("status").cloned())
.as_ref()
.and_then(serde_json::Value::as_str)
== Some("pending_commit"))
.then_some(call_id.as_str())
})
.collect::<HashSet<_>>();
self.entries
.iter()
.filter_map(|entry| {
let Item::ToolCall {
call_id,
name,
arguments,
..
} = &entry.item
else {
return None;
};
if !names.contains(&name.as_str()) || !pending_call_ids.contains(call_id.as_str()) {
return None;
}
Some(CommittedToolCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
evidence: self.evidence_for_tool_call(call_id)?,
})
})
.collect()
}
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
let excerpt = self
@@ -1028,4 +1107,20 @@ mod tests {
assert_eq!(source.entry_range, [0, 0]);
assert_eq!(source.entry_ref.as_str(), "E00000000");
}
#[test]
fn committed_tool_call_can_be_resolved_by_provider_call_id() {
let view = SessionCapture::new(
"segment-1",
vec![Item::tool_call(
"call-remember",
"SubjektivMemoryRemember",
r#"{"claim":"remember this"}"#,
)],
);
let evidence = view.evidence_for_tool_call("call-remember").unwrap();
assert_eq!(evidence.entry_ref.as_str(), "E00000000");
assert_eq!(evidence.tool_part, Some(ToolPart::Input));
assert!(view.evidence_for_tool_call("missing").is_none());
}
}
+41 -2
View File
@@ -39,7 +39,7 @@ pub struct WorkerSharedState {
pub worker_name: String,
pub segment_id: SegmentId,
pub manifest_toml: String,
pub greeting: protocol::Greeting,
greeting: RwLock<protocol::Greeting>,
state: RwLock<WorkerStateSnapshot>,
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
/// Worker-from-the-inside view of the filesystem. Set once in
@@ -62,7 +62,7 @@ impl WorkerSharedState {
worker_name,
segment_id,
manifest_toml,
greeting,
greeting: RwLock::new(greeting),
state: RwLock::new(WorkerStateSnapshot::initial()),
accepted_commands: RwLock::new(VecDeque::new()),
fs_view: OnceLock::new(),
@@ -90,6 +90,22 @@ impl WorkerSharedState {
self.flow_transition_enabled.load(Ordering::Acquire)
}
pub fn greeting(&self) -> protocol::Greeting {
self.greeting
.read()
.expect("worker greeting lock poisoned")
.clone()
}
pub fn update_context_usage(&self, tokens: Option<u64>, source: protocol::ContextTokenSource) {
let mut greeting = self
.greeting
.write()
.expect("worker greeting lock poisoned");
greeting.context_tokens = tokens.unwrap_or_default();
greeting.context_usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
}
pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot {
let mut snapshot = self
.state
@@ -245,9 +261,32 @@ mod tests {
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
}
}
#[test]
fn current_context_usage_is_reflected_in_reconnect_greeting() {
let state = test_state();
state.update_context_usage(Some(42_000), protocol::ContextTokenSource::Measured);
let greeting = state.greeting();
assert_eq!(greeting.context_tokens, 42_000);
assert_eq!(
greeting.context_usage,
Some(protocol::ContextUsage {
tokens: 42_000,
source: protocol::ContextTokenSource::Measured,
})
);
state.update_context_usage(None, protocol::ContextTokenSource::Measured);
let greeting = state.greeting();
assert_eq!(greeting.context_tokens, 0);
assert_eq!(greeting.context_usage, None);
}
#[test]
fn initial_snapshot_is_idle() {
let state = test_state();
+2
View File
@@ -97,6 +97,8 @@ mod tests {
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
reasoning: None,
context_usage: None,
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
+157 -3
View File
@@ -29,7 +29,9 @@ use tokio::sync::{Notify, broadcast};
use tracing::warn;
use workdir::WorkdirScopeLeaseSet;
use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility};
use crate::internal_worker::{
InternalWorkerSessionHandle, InternalWorkerSessionSnapshot, InternalWorkerVisibility,
};
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use crate::runtime::worker_allocation;
@@ -648,6 +650,14 @@ impl SpawnedWorkerRegistry {
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events();
publish_initial_internal_snapshot(
&parent_tx,
&worker,
&protocol_revision,
&protocol_emit_lock,
&protocol_terminal,
&record.session,
);
tokio::spawn(async move {
loop {
match child_rx.recv().await {
@@ -710,6 +720,14 @@ impl SpawnedWorkerRegistry {
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events();
publish_initial_internal_snapshot(
&parent_tx,
&worker,
&protocol_revision,
&protocol_emit_lock,
&protocol_terminal,
&record.session,
);
tokio::spawn(async move {
loop {
match child_rx.recv().await {
@@ -985,6 +1003,41 @@ impl SpawnedWorkerRegistry {
}
}
fn publish_initial_internal_snapshot(
parent_tx: &broadcast::Sender<Event>,
worker: &InternalWorkerRef,
protocol_revision: &AtomicU64,
protocol_emit_lock: &Mutex<()>,
protocol_terminal: &AtomicBool,
session: &InternalWorkerSessionHandle,
) {
let Some(event) = internal_worker_live_snapshot(session.protocol_snapshot()) else {
return;
};
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
return;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(event),
});
}
fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Option<Event> {
Some(Event::Snapshot {
session: snapshot.session,
greeting: snapshot.greeting?,
state: snapshot.status.into(),
in_flight: snapshot.in_flight,
internal_workers: snapshot.internal_workers,
})
}
fn internal_worker_snapshot(
worker: InternalWorkerRef,
revision: u64,
@@ -995,6 +1048,7 @@ fn internal_worker_snapshot(
worker,
revision,
session: snapshot.session,
greeting: snapshot.greeting,
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
@@ -1121,7 +1175,10 @@ mod tests {
use session_store::LogEntry;
use super::*;
use crate::internal_worker::{InternalWorkerSessionStatus, test_internal_worker_session};
use crate::internal_worker::{
InternalWorkerSessionStatus, test_internal_worker_session,
test_internal_worker_session_with_greeting,
};
fn registry() -> Arc<SpawnedWorkerRegistry> {
let scope = Scope::from_config(&ScopeConfig {
@@ -1141,7 +1198,15 @@ mod tests {
name: &str,
visibility: InternalWorkerVisibility,
) -> (InternalSpawnedWorkerRecord, broadcast::Sender<Event>) {
let (session, sender) = test_internal_worker_session(visibility);
record_with_greeting(name, visibility, None).await
}
async fn record_with_greeting(
name: &str,
visibility: InternalWorkerVisibility,
greeting: Option<protocol::Greeting>,
) -> (InternalSpawnedWorkerRecord, broadcast::Sender<Event>) {
let (session, sender) = test_internal_worker_session_with_greeting(visibility, greeting);
let root = std::path::PathBuf::from("/tmp");
let scope = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
@@ -1193,6 +1258,95 @@ mod tests {
)
}
#[tokio::test]
async fn visible_internal_worker_starts_with_own_live_snapshot_metadata() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(16);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let greeting = protocol::Greeting {
worker_name: "research".into(),
cwd: "/child".into(),
provider: "provider".into(),
model: "child-model".into(),
reasoning: Some(protocol::ReasoningConfig::Effort {
effort: "high".into(),
}),
scope_summary: "child scope".into(),
tools: Vec::new(),
context_window: 64_000,
context_tokens: 12_000,
context_usage: Some(protocol::ContextUsage {
tokens: 12_000,
source: protocol::ContextTokenSource::Measured,
}),
};
let (record, child_tx) = record_with_greeting(
"research",
InternalWorkerVisibility::ParentClient,
Some(greeting),
)
.await;
registry
.internal_records
.lock()
.unwrap()
.push(record.clone());
registry.start_protocol_forwarding(record);
let initial = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap();
assert!(matches!(
initial,
Event::InternalWorker {
revision: 1,
event,
..
} if matches!(
*event,
Event::Snapshot {
greeting: protocol::Greeting {
ref model,
context_window: 64_000,
..
},
..
} if model == "child-model"
)
));
child_tx
.send(Event::ContextUsage {
usage: Some(protocol::ContextUsage {
tokens: 14_000,
source: protocol::ContextTokenSource::Estimated,
}),
})
.unwrap();
let update = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap();
assert!(matches!(
update,
Event::InternalWorker {
revision: 2,
event,
..
} if matches!(
*event,
Event::ContextUsage {
usage: Some(protocol::ContextUsage {
tokens: 14_000,
source: protocol::ContextTokenSource::Estimated,
})
}
)
));
}
#[tokio::test]
async fn visible_internal_output_is_wrapped_after_registry_insertion() {
let registry = registry();
+58 -17
View File
@@ -925,25 +925,30 @@ fn bind_child_memory_settings(
parent: &manifest::WorkerManifest,
child: &mut manifest::WorkerManifest,
) -> Result<(), String> {
if !child.feature.memory.profile.enabled {
return child
if child.feature.memory.profile.enabled {
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
.to_string()
})?;
child
.feature
.memory
.validate_execution()
.map_err(str::to_string);
.bind_workspace_settings(workspace_settings)
.map_err(str::to_string)?;
}
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
.to_string()
})?;
child
.feature
.memory
.bind_workspace_settings(workspace_settings)
.validate_execution()
.map_err(str::to_string)?;
// An Internal SubWorker has no Backend keyed-singleton lease of its own.
// Preserve reusable subjektiv profile policy but never copy the parent's
// trusted activation snapshot into the child.
child.feature.subjektiv.workspace_settings = None;
child
.feature
.memory
.subjektiv
.validate_execution()
.map_err(str::to_string)
}
@@ -1292,7 +1297,7 @@ enabled = false
}
#[tokio::test]
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
async fn builtin_reviewer_spawn_from_subject_parent_is_inert_and_notifies_parent() {
let runtime = TempDir::new().unwrap();
let workspace_root = runtime.path().join("project");
let bash_output_dir = runtime.path().join("bash-output");
@@ -1310,6 +1315,17 @@ enabled = false
allow: vec![abs_rule(&workspace_root, Permission::Write)],
deny: Vec::new(),
};
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest
.feature
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
@@ -1359,7 +1375,7 @@ enabled = false
}));
let input = serde_json::json!({
"name": "reviewer-child",
"profile": "project:reviewer",
"profile": "builtin:reviewer",
"instruction": "role.reviewer",
"task": "review immutable commit",
"scope": [{
@@ -1409,6 +1425,14 @@ enabled = false
record.installed_tools
);
}
assert!(
record
.installed_tools
.iter()
.all(|name| !name.starts_with("Subjektiv")),
"Reviewer child must not install independently scoped subjektiv tools: {:?}",
record.installed_tools
);
assert!(
!record
.installed_tools
@@ -1881,14 +1905,22 @@ enabled = false
let temp = tempfile::tempdir().unwrap();
let mut parent = parent_manifest(temp.path(), None);
parent.feature.memory.profile.enabled = true;
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 4,
language: "日本語".to_string(),
};
parent
.feature
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 4,
language: "日本語".to_string(),
})
.bind_workspace_settings(settings.clone())
.unwrap();
parent.feature.subjektiv.profile.enabled = true;
parent.feature.subjektiv.profile.extraction.enabled = true;
parent
.feature
.subjektiv
.bind_workspace_settings(settings)
.unwrap();
let mut child = parent.clone();
child.feature.memory.workspace_settings = None;
@@ -1898,6 +1930,15 @@ enabled = false
child.feature.memory.workspace_settings(),
parent.feature.memory.workspace_settings()
);
assert!(child.feature.subjektiv.profile.enabled);
assert!(!child.feature.subjektiv.execution_enabled());
let mut ordinary_parent = parent.clone();
ordinary_parent.feature.subjektiv.workspace_settings = None;
let mut ordinary_child = child.clone();
ordinary_child.feature.memory.workspace_settings = None;
bind_child_memory_settings(&ordinary_parent, &mut ordinary_child).unwrap();
assert!(!ordinary_child.feature.subjektiv.execution_enabled());
child.feature.memory.profile.enabled = false;
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
+321 -10
View File
@@ -411,7 +411,9 @@ use crate::ipc::alerter::Alerter;
use crate::ipc::interceptor::WorkerInterceptor;
use crate::ipc::notify_buffer::NotifyBuffer;
use crate::prompt::agents_md::read_agents_md;
use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection};
use crate::prompt::catalog::{
CatalogError, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
};
use crate::prompt::source::PromptCatalogSource;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
@@ -624,6 +626,10 @@ pub enum WorkspaceServerOperation {
},
WorkerObservationSessions,
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
SubjektivSession(server_api::SubjektivSessionBackendRequest),
}
fn workspace_server_json_request<T: serde::Serialize>(
@@ -702,6 +708,18 @@ fn workspace_server_operation_request(
WorkspaceServerOperation::WorkerObservationCapture(request) => {
workspace_server_json_request(format!("{base}/worker-observation/session"), &request)
}
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
workspace_server_json_request(format!("{base}/subjektiv/staging"), &request)
}
WorkspaceServerOperation::SubjektivRecordSession(request) => {
workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request)
}
WorkspaceServerOperation::SubjektivMemory(request) => {
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
}
WorkspaceServerOperation::SubjektivSession(request) => {
workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request)
}
}
}
@@ -1414,7 +1432,8 @@ where
/// Append `entry` to the log: disk write → counter bump → in-memory
/// mirror push → broadcast. The Store owns physical write ordering and
/// partial-write recovery; publication happens only after it returns Ok.
pub fn append_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
pub fn append_entry(&self, mut entry: LogEntry) -> Result<(), StoreError> {
entry.ensure_session_entry_id();
let _append_guard = self
.state
.append_lock
@@ -2322,9 +2341,15 @@ impl WorkerSession {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SystemPromptContribution {
Ready(String),
Unavailable,
}
#[async_trait::async_trait]
pub(crate) trait SystemPromptContributionSource: Send + Sync {
async fn load(&self) -> Option<String>;
async fn load(&self) -> SystemPromptContribution;
}
#[derive(Debug, Clone)]
@@ -2488,6 +2513,8 @@ pub struct Worker<C: LlmClient, St: Store> {
inject_resident_summary: bool,
/// Deferred resident prompt source installed by an enabled Feature.
feature_resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
/// One-shot append-only resident refresh required at a restore boundary.
restore_resident_summary_refresh_pending: bool,
/// Complete system prompt replacement installed by an enabled Feature.
feature_system_prompt_override: Option<String>,
/// Typed user submissions in submit order. K-th entry corresponds to
@@ -2741,6 +2768,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
restore_resident_summary_refresh_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
sink: SegmentLogSink::new(),
@@ -3043,7 +3071,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Append `entry` to the session log AND publish it through the
/// broadcast sink. The Store is the commit boundary: a failed write is
/// never counted or published.
pub(crate) fn commit_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
pub(crate) fn commit_entry(&self, mut entry: LogEntry) -> Result<(), StoreError> {
entry.ensure_session_entry_id();
let loc = self.segment_state.location();
self.store.append(loc.session_id, loc.segment_id, &entry)?;
self.segment_state.increment_entries();
@@ -3463,6 +3492,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let entries = store
.read_all(location.session_id, location.segment_id)
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
let has_committed_run = entries.iter().any(|entry| {
matches!(
entry,
LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. }
)
});
let run_exit = entries
.iter()
.rev()
@@ -3489,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
segment_id: location.segment_id.to_string(),
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
entry_count: entries.len(),
has_committed_run,
run_exit,
history,
usage_history: restored.usage_history,
@@ -3807,6 +3843,66 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}
}
async fn ensure_restore_resident_summary_refreshed(&mut self) -> Result<(), WorkerError>
where
St: Clone + 'static,
{
if !self.restore_resident_summary_refresh_pending {
return Ok(());
}
let Some(source) = self.feature_resident_summary_source.clone() else {
self.restore_resident_summary_refresh_pending = false;
return Ok(());
};
let contribution = if self.inject_resident_summary {
source.load().await
} else {
SystemPromptContribution::Unavailable
};
let (surface_ready, summary) = match &contribution {
SystemPromptContribution::Ready(summary) => (true, Some(summary.as_str())),
SystemPromptContribution::Unavailable => (false, None),
};
let prompt_catalog = self.prompts.load_full();
let body = prompt_catalog.resident_memory_restore_section(surface_ready, summary)?;
let prompt_provenance =
Some(self.prompt_render_provenance(WorkerPrompt::ResidentMemoryRestoreSection.key()));
let item = SystemItem::ResidentSummaryRefresh {
body: body.clone(),
prompt_provenance,
};
let metadata = new_history_metadata(
WorkerHistoryProvenance::BackendInstruction { operation_id: None },
None,
);
self.commit_entry(LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(),
entry: session_store::LoggedSystemHistoryEntry {
item,
metadata: metadata.clone(),
},
extensions: Vec::new(),
})?;
let history_entry = HistoryEntry::new(Item::system_message(body), metadata);
let mut annotate = history_annotator(
self.log_writer_handle(),
vec![history_entry.clone()],
self.pending_committed_history.clone(),
);
let (engine, session) = (
self.engine.as_mut().expect("worker present"),
&mut self.session,
);
engine.append_history_with(
session.history_mut(),
std::iter::once(history_entry.item),
&mut annotate,
)?;
session.note_mutation();
self.restore_resident_summary_refresh_pending = false;
Ok(())
}
/// Render the manifest-supplied instruction template exactly once,
/// just before the first LLM turn, append the fixed trailing
/// section (scope summary + optional AGENTS.md), and hand the
@@ -3814,9 +3910,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Subsequent invocations are no-ops: the template field is
/// consumed with `Option::take()`, so the materialised value
/// persists across all later turns and compaction.
async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> {
async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError>
where
St: Clone + 'static,
{
let Some(template) = self.system_prompt_template.take() else {
return Ok(());
return self.ensure_restore_resident_summary_refreshed().await;
};
if let Some(rendered) = self.feature_system_prompt_override.take() {
self.engine
@@ -3849,7 +3948,14 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}
let resident_summary = if self.inject_resident_summary {
match &self.feature_resident_summary_source {
Some(source) => source.load().await,
Some(source) => match source.load().await {
SystemPromptContribution::Ready(summary) if !summary.is_empty() => {
Some(summary)
}
SystemPromptContribution::Ready(_) | SystemPromptContribution::Unavailable => {
None
}
},
None => None,
}
} else {
@@ -3905,6 +4011,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.is_some_and(|state| state.pre_run_eligible(self.total_tokens().tokens))
}
pub(crate) fn needs_initial_session_head_materialization(&self) -> bool {
self.system_prompt_template.is_some()
}
/// Materialize the initial durable session head without running the model.
///
/// Runtime-managed Workers call this before creation is accepted so even a
@@ -4695,6 +4805,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.expect("pause/yield resume must retain the logical-run turn budget");
self.commit_entry(LogEntry::RunResumed {
ts: segment_log::now_millis(),
entry_id: None,
source,
active_run_turn_count,
})?;
@@ -5012,6 +5123,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// marked as already delivered to clients through the session-log sink.
self.commit_entry(LogEntry::RunErrored {
ts: segment_log::now_millis(),
entry_id: None,
interrupted: false,
message,
failure: Some(RunFailureKind::Compaction),
@@ -5423,6 +5535,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
{
self.commit_entry(LogEntry::RunErrored {
ts: segment_log::now_millis(),
entry_id: None,
interrupted: false,
message: message.to_string(),
failure: Some(RunFailureKind::Compaction),
@@ -5446,6 +5559,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
EngineRunExit::Yielded => {
self.commit_entry(LogEntry::RunYielded {
ts: segment_log::now_millis(),
entry_id: None,
reason: RunYieldReason::Compaction,
active_run_turn_count: active_run_turn_count
.expect("yielded logical run must retain its turn budget"),
@@ -5462,11 +5576,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => {
self.commit_entry(LogEntry::RunCancelled {
ts: segment_log::now_millis(),
entry_id: None,
})?;
}
EngineRunExit::Interrupted(reason) => {
self.commit_entry(LogEntry::RunErrored {
ts: segment_log::now_millis(),
entry_id: None,
interrupted,
message: run_interruption_reason_message(reason),
failure: Some(RunFailureKind::Engine),
@@ -6537,6 +6653,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
restore_resident_summary_refresh_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
sink: SegmentLogSink::new(),
@@ -6626,6 +6743,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
restore_resident_summary_refresh_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
sink: SegmentLogSink::new(),
@@ -6750,6 +6868,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
restore_resident_summary_refresh_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
sink: SegmentLogSink::new(),
@@ -7131,6 +7250,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
restore_resident_summary_refresh_pending: true,
feature_system_prompt_override: None,
user_segments: state.user_segments,
// Seed the mirror with the entries we just replayed so a
@@ -8690,13 +8810,13 @@ mod build_summary_prompt_tests {
use super::*;
struct TestSystemPromptContributionSource {
value: Option<String>,
value: SystemPromptContribution,
load_count: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl SystemPromptContributionSource for TestSystemPromptContributionSource {
async fn load(&self) -> Option<String> {
async fn load(&self) -> SystemPromptContribution {
self.load_count.fetch_add(1, Ordering::SeqCst);
self.value.clone()
}
@@ -8708,7 +8828,10 @@ mod build_summary_prompt_tests {
let load_count = Arc::new(AtomicUsize::new(0));
(
Arc::new(TestSystemPromptContributionSource {
value,
value: value.map_or(
SystemPromptContribution::Unavailable,
SystemPromptContribution::Ready,
),
load_count: Arc::clone(&load_count),
}),
load_count,
@@ -11063,6 +11186,191 @@ mod build_summary_prompt_tests {
assert!(worker.history().is_empty());
}
#[tokio::test]
async fn restored_worker_appends_latest_resident_surface_without_rewriting_history() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().join("workspace");
std::fs::create_dir_all(&cwd).unwrap();
let runtime = dir.path().join("runtime");
std::fs::create_dir_all(&runtime).unwrap();
let _runtime =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(&runtime);
let store = session_store::CombinedStore::new(
session_store::FsStore::new(dir.path().join("sessions")).unwrap(),
session_store::FsWorkerStore::new(dir.path().join("workers")).unwrap(),
);
let manifest = WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "restore-resident-{}"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
instruction = "default"
[[scope.allow]]
target = "{}"
permission = "write"
"#,
uuid::Uuid::now_v7(),
cwd.display()
))
.unwrap();
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
let scope = Scope::writable(&cwd).unwrap();
let mut original = Worker::new(
manifest.clone(),
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
store.clone(),
WorkerWorkspaceContext::no_workspace(),
authority.clone(),
scope,
)
.await
.unwrap();
original.set_system_prompt_template(
SystemPromptTemplate::parse(
"default",
crate::prompt::source::PromptCatalogSource::builtins_only(),
)
.unwrap(),
);
let (old_source, _) =
test_system_prompt_contribution_source(Some("old subject surface".into()));
original.install_system_prompt_contribution(Some(old_source), None);
original.materialize_durable_session_head().await.unwrap();
let session_id = original.session_id();
let segment_id = original.segment_id();
assert!(
original
.engine()
.get_system_prompt()
.unwrap()
.contains("old subject surface")
);
drop(original);
let mut restored =
Worker::<Box<dyn LlmClient>, _>::restore_from_manifest_with_context_and_model_client(
session_id,
segment_id,
manifest.clone(),
store.clone(),
crate::prompt::source::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::no_workspace(),
authority.clone(),
Some(Box::new(NoopClient)),
)
.await
.unwrap();
let history_before = restored.history().to_vec();
let (current_source, current_loads) =
test_system_prompt_contribution_source(Some("latest subject surface".into()));
restored.install_system_prompt_contribution(Some(current_source), None);
restored.materialize_durable_session_head().await.unwrap();
assert_eq!(current_loads.load(Ordering::SeqCst), 1);
assert_eq!(&restored.history()[..history_before.len()], history_before);
let refresh = restored
.history()
.last()
.unwrap()
.as_text()
.unwrap()
.to_string();
assert!(refresh.contains("latest subject surface"));
assert!(refresh.contains("supersedes every resident memory summary"));
assert!(!refresh.contains("old subject surface"));
let refreshed_len = restored.history().len();
restored.materialize_durable_session_head().await.unwrap();
assert_eq!(restored.history().len(), refreshed_len);
drop(restored);
let mut empty_restore =
Worker::<Box<dyn LlmClient>, _>::restore_from_manifest_with_context_and_model_client(
session_id,
segment_id,
manifest.clone(),
store.clone(),
crate::prompt::source::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::no_workspace(),
authority.clone(),
Some(Box::new(NoopClient)),
)
.await
.unwrap();
let (empty_source, empty_loads) =
test_system_prompt_contribution_source(Some(String::new()));
empty_restore.install_system_prompt_contribution(Some(empty_source), None);
empty_restore
.materialize_durable_session_head()
.await
.unwrap();
assert_eq!(empty_loads.load(Ordering::SeqCst), 1);
let empty_boundary = empty_restore
.history()
.last()
.unwrap()
.as_text()
.unwrap()
.to_string();
assert!(
empty_boundary.contains("current ready resident memory surface is intentionally empty")
);
assert!(!empty_boundary.contains("No current ready resident memory surface"));
drop(empty_restore);
let mut stale_restore =
Worker::<Box<dyn LlmClient>, _>::restore_from_manifest_with_context_and_model_client(
session_id,
segment_id,
manifest,
store.clone(),
crate::prompt::source::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::no_workspace(),
authority,
Some(Box::new(NoopClient)),
)
.await
.unwrap();
let stale_history_before = stale_restore.history().to_vec();
let (stale_source, stale_loads) = test_system_prompt_contribution_source(None);
stale_restore.install_system_prompt_contribution(Some(stale_source), None);
stale_restore
.materialize_durable_session_head()
.await
.unwrap();
assert_eq!(stale_loads.load(Ordering::SeqCst), 1);
assert_eq!(
&stale_restore.history()[..stale_history_before.len()],
stale_history_before
);
let tombstone = stale_restore
.history()
.last()
.unwrap()
.as_text()
.unwrap()
.to_string();
assert!(tombstone.contains("No current ready resident memory surface"));
assert!(!tombstone.contains("latest subject surface"));
let entries = store.read_all(session_id, segment_id).unwrap();
assert!(matches!(
entries.last(),
Some(LogEntry::AnnotatedSystemItem {
entry: session_store::LoggedSystemHistoryEntry {
item: SystemItem::ResidentSummaryRefresh { .. },
..
},
..
})
));
}
#[tokio::test]
async fn memory_consolidation_prompt_uses_bound_workspace_language() {
let dir = tempfile::tempdir().unwrap();
@@ -11430,6 +11738,7 @@ mod build_summary_prompt_tests {
worker
.commit_entry(LogEntry::RunErrored {
ts: segment_log::now_millis(),
entry_id: None,
interrupted: true,
message: "provider failed".to_string(),
failure: Some(RunFailureKind::Engine),
@@ -11460,6 +11769,7 @@ mod build_summary_prompt_tests {
worker
.commit_entry(LogEntry::RunCancelled {
ts: segment_log::now_millis(),
entry_id: None,
})
.unwrap();
assert_eq!(
@@ -11470,6 +11780,7 @@ mod build_summary_prompt_tests {
worker
.commit_entry(LogEntry::RunResumed {
ts: segment_log::now_millis(),
entry_id: None,
source: RunResumeSource::Compaction,
active_run_turn_count: 1,
})
+44 -13
View File
@@ -1037,25 +1037,15 @@ permission = "write"
#[tokio::test]
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
let workspace = tempfile::tempdir().unwrap();
let mut resolved = ProfileResolver::new()
let resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
if resolved.manifest.feature.memory.enabled() {
resolved
.manifest
.feature
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
}
assert!(resolved.manifest.feature.subjektiv.enabled());
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
let workspace_context =
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
let client = MockClient::new(simple_text_events());
@@ -1081,6 +1071,47 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
assert!(installed.iter().any(|name| name == "WorkerRemove"));
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
assert!(
installed.iter().all(|name| !name.starts_with("Subjektiv")),
"ordinary Orchestrator must not install subject-scoped tools: {installed:?}"
);
}
#[tokio::test]
async fn builtin_coder_commits_without_unattached_subjektiv_lifecycle() {
let workspace = tempfile::tempdir().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
ProfileResolveOptions::with_worker_name("coder-worker"),
)
.unwrap();
assert!(resolved.manifest.feature.subjektiv.enabled());
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
let client = MockClient::new(simple_text_events());
let client_for_assert = client.clone();
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
resolved.manifest,
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)),
)
.await;
let handle = spawn_controller(worker).await;
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Hello",
))
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
let installed = request_tool_names(&wait_for_captured_request(&client_for_assert).await);
assert!(
installed.iter().all(|name| !name.starts_with("Subjektiv")),
"ordinary Coder must not install subject-scoped tools: {installed:?}"
);
}
#[tokio::test]
+1
View File
@@ -51,6 +51,7 @@ webauthn-rs = { workspace = true }
[dev-dependencies]
agen.workspace = true
session-store.workspace = true
tempfile.workspace = true
tools.workspace = true
yoi = { path = "../yoi" }
+3 -5
View File
@@ -820,11 +820,9 @@ impl SqliteWorkspaceAuthority {
.map(|worker| (assignment, worker))
})
.map(|(assignment, worker)| {
let worker_resource_key = self.store.resource_key(
&self.workspace_id,
WorkspaceResourceKind::Worker,
&worker.worker_id,
)?;
let worker_resource_key = self
.store
.worker_resource_key(&self.workspace_id, &worker)?;
Ok::<_, Error>(TicketAssignmentSummary {
assignment_id: assignment.assignment_id.clone(),
runtime_id: worker.runtime_id,
+117 -1
View File
@@ -534,6 +534,9 @@ pub struct WorkerSpawnRequest {
/// Backend-authored immutable Workspace Memory settings snapshot.
#[serde(skip, default)]
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
/// Backend-attested subjektiv singleton attachment. Browser/model input cannot set it.
#[serde(skip, default)]
pub resolved_subjektiv_attached: bool,
/// Backend-owned feature enablement; client input cannot set it.
#[serde(skip, default)]
pub resolved_worker_observation_enabled: bool,
@@ -1217,6 +1220,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
})
}
fn session_public_search(
&self,
_request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
message: "Session public search is not supported by this Runtime".to_string(),
})
}
fn session_public_read(
&self,
_request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
message: "Session public read is not supported by this Runtime".to_string(),
})
}
fn observation_source(
&self,
_worker_id: &str,
@@ -2073,6 +2096,36 @@ impl RuntimeRegistry {
})
}
pub fn session_public_search(
&self,
runtime_id: &str,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
self.runtime(runtime_id)?
.session_public_search(request)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "session_public_search_failed".to_string(),
message,
})
}
pub fn session_public_read(
&self,
runtime_id: &str,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
self.runtime(runtime_id)?
.session_public_read(request)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "session_public_read_failed".to_string(),
message,
})
}
pub fn observation_source(
&self,
worker: &RuntimeWorkerRef,
@@ -2964,6 +3017,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.map_err(|error| error.to_string())
}
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
if request.workspace_id != self.workspace_id {
return Err(
"Session public search Workspace does not match embedded Runtime".to_string(),
);
}
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
self.runtime
.session_public_search_scoped(&scope, &request)
.map_err(|error| error.to_string())
}
fn session_public_read(
&self,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
if request.workspace_id != self.workspace_id {
return Err(
"Session public read Workspace does not match embedded Runtime".to_string(),
);
}
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
self.runtime
.session_public_read_scoped(&scope, &request)
.map_err(|error| error.to_string())
}
fn observation_source(
&self,
worker_id: &str,
@@ -3669,6 +3752,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
return "workers:delete";
}
if path.starts_with("/v1/session-public/") && method == "POST" {
return "workers:read";
}
if path.starts_with("/v1/workers/") && method == "GET" {
return "workers:read";
}
@@ -5007,6 +5093,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| diagnostic.message)
}
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.session_public_search(request).await },
)
.map_err(|diagnostic| diagnostic.message)
}
fn session_public_read(
&self,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.session_public_read(request).await },
)
.map_err(|diagnostic| diagnostic.message)
}
fn observation_source(
&self,
worker_id: &str,
@@ -5246,6 +5356,7 @@ fn runtime_create_worker_request(
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
workspace_api: Some(workspace_api),
memory_settings: request.resolved_memory_settings.clone(),
subjektiv_attached: request.resolved_subjektiv_attached,
}
}
@@ -5370,6 +5481,7 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
pub(crate) const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
pub(crate) const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:";
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-orchestrator";
@@ -5377,7 +5489,7 @@ pub(crate) fn is_reserved_internal_worker_singleton_key(key: &str) -> bool {
matches!(
key,
MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
)
) || key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX)
}
struct WorkerDisplayMetadata {
@@ -7030,6 +7142,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
}
}
@@ -7318,6 +7431,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
},
)
.unwrap();
@@ -7420,6 +7534,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
},
)
.unwrap();
@@ -7460,6 +7575,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
},
)
.unwrap();
+25 -30
View File
@@ -753,11 +753,11 @@ CREATE TABLE "worker_control_grants" (
created_at TEXT NOT NULL,
revoked_at TEXT,
PRIMARY KEY (workspace_id, grant_id),
UNIQUE (workspace_id, controller_worker_id, operation_id),
FOREIGN KEY (workspace_id, controller_worker_id)
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, subject_worker_id)
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE
UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id),
FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id)
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id)
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE
);
CREATE TABLE worker_create_reservations (
workspace_id TEXT NOT NULL,
@@ -832,7 +832,7 @@ CREATE TABLE "worker_registry" (
diagnostics_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, worker_id),
PRIMARY KEY (workspace_id, runtime_id, worker_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE backend_jobs (
@@ -914,31 +914,15 @@ CREATE TABLE worker_registry_observations (
worker_id TEXT NOT NULL,
availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')),
worker_json TEXT,
connection_generation INTEGER NOT NULL,
subject_revision INTEGER NOT NULL,
snapshot_revision INTEGER NOT NULL,
projection_revision INTEGER NOT NULL,
observed_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, worker_id),
FOREIGN KEY (workspace_id, worker_id)
REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE
);
CREATE TABLE worker_registry_projection_cursors (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
connection_generation INTEGER NOT NULL,
snapshot_revision INTEGER NOT NULL,
PRIMARY KEY (workspace_id, runtime_id)
);
CREATE TABLE worker_registry_projection_revisions (
workspace_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL
FOREIGN KEY (workspace_id, runtime_id, worker_id)
REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE
);
CREATE TABLE worker_registry_projection_removals (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
projection_revision INTEGER NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, worker_id)
);
CREATE TABLE worker_registry_projection_diagnostics (
@@ -973,6 +957,17 @@ CREATE TABLE worker_session_archives (
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_session_archive_observe_grants (
workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL,
controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL,
subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL,
source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, revoked_at TEXT,
PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE);
CREATE INDEX worker_session_archive_observe_grants_controller
ON worker_session_archive_observe_grants(
workspace_id,controller_runtime_id,controller_worker_id,archive_id);
CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
@@ -998,9 +993,9 @@ CREATE TABLE "worker_workdir_links" (
alias TEXT NOT NULL,
linked_at TEXT NOT NULL,
unlinked_at TEXT,
PRIMARY KEY (workspace_id, worker_id, workdir_id, alias),
FOREIGN KEY (workspace_id, worker_id)
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias),
FOREIGN KEY (workspace_id, runtime_id, worker_id)
REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id)
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
);
@@ -1217,11 +1212,11 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
ON worker_workdir_attachment_reservations(workspace_id, reservation_id);
CREATE INDEX worker_control_grants_controller
ON worker_control_grants(
workspace_id, controller_worker_id, revoked_at
workspace_id, controller_runtime_id, controller_worker_id, revoked_at
);
CREATE INDEX worker_control_grants_subject
ON worker_control_grants(
workspace_id, subject_worker_id, revoked_at
workspace_id, subject_runtime_id, subject_worker_id, revoked_at
);
CREATE INDEX worker_create_reservations_worker
ON worker_create_reservations(workspace_id, worker_id);
@@ -1232,7 +1227,7 @@ CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique
ON worker_workdir_links(workspace_id, workdir_id)
WHERE unlinked_at IS NULL;
CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique
ON worker_workdir_links(workspace_id, worker_id, alias)
ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias)
WHERE unlinked_at IS NULL;
CREATE INDEX worker_workdir_links_workdir
ON worker_workdir_links(workspace_id, workdir_id);
+2
View File
@@ -112,6 +112,8 @@ pub enum Error {
},
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error("subjektiv subject `{0}` was not found in this Workspace")]
SubjektivSubjectNotFound(String),
#[error(
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
)]
+11 -8
View File
@@ -1,8 +1,9 @@
use memory::backend::{
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
MemoryStagingCloseAction, MemoryStagingCloseOperation, MemoryStagingListOperation,
MemoryStagingReadOperation, MemoryStagingWriteOutput, MemoryToolOutput,
MemoryResidentSummaryAvailability, MemoryResidentSummaryOutput, MemoryStagingCloseAction,
MemoryStagingCloseOperation, MemoryStagingListOperation, MemoryStagingReadOperation,
MemoryStagingWriteOutput, MemoryToolOutput,
};
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
use memory::schema::{SourceEvidenceRef, SourceRef};
@@ -32,7 +33,7 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
.map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
MemoryBackendOperationResult::ResidentSummary(execute_resident_summary(authority)?),
),
MemoryBackendOperation::AppendAudit(_operation) => Ok(
MemoryBackendOperationResult::Acknowledged(MemoryBackendAckOutput {
@@ -88,16 +89,18 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
}
}
fn execute_resident_summary<A: MemoryAuthority>(authority: &A) -> Result<MemoryToolOutput> {
fn execute_resident_summary<A: MemoryAuthority>(
authority: &A,
) -> Result<MemoryResidentSummaryOutput> {
let document = authority.ensure_memory_document()?;
if document.body_md.trim().is_empty() {
Ok(MemoryToolOutput {
summary: "resident memory summary unavailable".to_string(),
Ok(MemoryResidentSummaryOutput {
availability: MemoryResidentSummaryAvailability::Unavailable,
content: None,
})
} else {
Ok(MemoryToolOutput {
summary: "resident memory document collected".to_string(),
Ok(MemoryResidentSummaryOutput {
availability: MemoryResidentSummaryAvailability::Ready,
content: Some(document.body_md),
})
}
+183
View File
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
pub operation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSessionArchiveRecord {
pub archive_id: String,
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub session_id: String,
pub checksum_sha256: String,
pub content_bytes: u64,
pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String,
pub committed_at: String,
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerOrphanDiagnostic {
pub diagnostic_id: String,
@@ -454,6 +469,51 @@ impl SqliteWorkspaceStore {
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
let observe_grants = {
let mut statement = tx.prepare(
"SELECT grant_id, controller_runtime_id, controller_worker_id,
permissions_json, created_at
FROM worker_control_grants
WHERE workspace_id = ?1 AND subject_runtime_id = ?2
AND subject_worker_id = ?3 AND revoked_at IS NULL",
)?;
statement
.query_map(
params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
},
)?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants {
let permissions: Vec<String> = serde_json::from_str(&permissions)
.map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?;
if permissions.iter().any(|permission| permission == "observe") {
tx.execute(
"INSERT OR IGNORE INTO worker_session_archive_observe_grants
(workspace_id,archive_id,controller_runtime_id,controller_worker_id,
subject_runtime_id,subject_worker_id,source_grant_id,granted_at)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
params![
workspace_id,
a.archive_id,
controller_runtime_id,
controller_worker_id,
plan.worker.runtime_id,
plan.worker.worker_id,
grant_id,
created_at
],
)?;
}
}
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
match plan.diagnostics_disposition {
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
@@ -592,6 +652,78 @@ impl SqliteWorkspaceStore {
) -> crate::Result<Option<WorkerTombstone>> {
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
}
pub fn worker_session_archive(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
session_id: &str,
) -> crate::Result<Option<WorkerSessionArchiveRecord>> {
self.with_conn(|connection| {
connection
.query_row(
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
policy_revision, operation_id, committed_at, expires_at
FROM worker_session_archives
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND session_id = ?4",
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
session_id
],
|row| {
Ok(WorkerSessionArchiveRecord {
archive_id: row.get(0)?,
workspace_id: workspace_id.to_string(),
worker: worker.clone(),
session_id: session_id.to_string(),
checksum_sha256: row.get(1)?,
content_bytes: row.get::<_, i64>(2)? as u64,
policy_id: row.get(3)?,
policy_revision: row.get::<_, i64>(4)? as u64,
operation_id: row.get(5)?,
committed_at: row.get(6)?,
expires_at: row.get(7)?,
})
},
)
.optional()
.map_err(StoreError::from)
})
}
pub fn worker_session_archive_observable_by(
&self,
workspace_id: &str,
archive_id: &str,
controller: &RuntimeWorkerRef,
subject: &RuntimeWorkerRef,
) -> crate::Result<bool> {
self.with_conn(|connection| {
connection
.query_row(
"SELECT EXISTS(
SELECT 1 FROM worker_session_archive_observe_grants
WHERE workspace_id = ?1 AND archive_id = ?2
AND controller_runtime_id = ?3 AND controller_worker_id = ?4
AND subject_runtime_id = ?5 AND subject_worker_id = ?6
AND revoked_at IS NULL
)",
params![
workspace_id,
archive_id,
controller.runtime_id,
controller.worker_id,
subject.runtime_id,
subject.worker_id
],
|row| row.get(0),
)
.map_err(StoreError::from)
})
}
}
fn orphan_diagnostic(
@@ -1170,6 +1302,32 @@ mod tests {
#[test]
fn purge_tombstone_commit_is_idempotent() {
let s = setup();
let archived_grant_id = "archive-observe-grant";
let archived_controller =
RuntimeWorkerRef::new("r", WorkerId::from_legacy_u64(2).to_string());
s.with_conn(|conn| {
conn.execute(
"INSERT INTO worker_registry(\
workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\
) VALUES('w',?1,'r','controller','builtin:companion','normal','created','controller-rev1')",
[&archived_controller.worker_id],
)?;
Ok(())
})
.unwrap();
s.create_worker_control_grant(&crate::store::WorkerControlGrantRecord {
workspace_id: "w".into(),
grant_id: archived_grant_id.into(),
controller: archived_controller.clone(),
subject: req().worker,
relation: "subject_session".into(),
origin: "test".into(),
permissions: vec!["observe".into()],
operation_id: archived_grant_id.into(),
created_at: "2026-10-02T00:00:00Z".into(),
revoked_at: None,
})
.unwrap();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
@@ -1220,6 +1378,31 @@ mod tests {
.state,
WorkerRemovalPlanState::Succeeded
);
assert!(
s.worker_session_archive_observable_by(
"w",
p.archive_id.as_deref().unwrap(),
&archived_controller,
&p.worker,
)
.unwrap(),
"the explicit observe grant survives Worker removal for the committed archive"
);
assert!(
s.revoke_worker_control_grant("w", archived_grant_id, "2026-10-02T00:01:00Z")
.unwrap(),
"the existing grant revocation authority must also revoke its archive projection"
);
assert!(
!s.worker_session_archive_observable_by(
"w",
p.archive_id.as_deref().unwrap(),
&archived_controller,
&p.worker,
)
.unwrap(),
"every later cursor page and direct Read must observe archive grant revocation"
);
assert!(s.worker_tombstone("w", &p.worker).unwrap().is_some());
let reservation_state: String = s.with_conn(|conn| {
conn.query_row(
@@ -33,26 +33,19 @@ pub enum RuntimeSubscriptionBrokerError {
#[derive(Clone, Debug)]
pub enum BrokerSubscriptionEvent {
Snapshot {
connection_generation: u64,
snapshot_revision: u64,
snapshot: SubscriptionSnapshot,
},
Event {
connection_generation: u64,
subject_revision: u64,
payload: SubscriptionEventPayload,
},
Disconnected {
connection_generation: u64,
message: String,
},
Rejected {
connection_generation: u64,
code: SubscriptionRejectionCode,
message: String,
},
Closed {
connection_generation: u64,
code: SubscriptionTerminationCode,
message: String,
},
@@ -60,7 +53,6 @@ pub enum BrokerSubscriptionEvent {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RuntimeSubscriptionBrokerStatus {
pub connection_generation: u64,
pub connected: bool,
pub desired_selectors: usize,
pub upstream_subscriptions: usize,
@@ -82,7 +74,30 @@ impl BrokerSubscription {
&self.selector
}
pub async fn recv(&mut self) -> Option<BrokerSubscriptionEvent> {
self.receiver.recv().await
loop {
if let Some(event) = self.receiver.recv().await {
return Some(event);
}
if self.restart().is_err() {
return None;
}
}
}
pub(crate) fn restart(&mut self) -> Result<(), RuntimeSubscriptionBrokerError> {
self.commands
.send(Command::Unsubscribe(self.downstream_id))
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
let (events, receiver) = mpsc::channel(DOWNSTREAM_QUEUE_CAPACITY);
self.commands
.send(Command::Subscribe {
downstream_id: self.downstream_id,
selector: self.selector.clone(),
events,
})
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
self.receiver = receiver;
Ok(())
}
}
@@ -120,10 +135,7 @@ impl RuntimeSubscriptionBroker {
pub fn register_remote_runtime(&self, config: RemoteRuntimeConfig) -> u64 {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (commands, receiver) = mpsc::unbounded_channel();
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
connection_generation: generation,
..Default::default()
}));
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus::default()));
let registration = Registration {
generation,
commands: commands.clone(),
@@ -156,7 +168,6 @@ impl RuntimeSubscriptionBroker {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (commands, receiver) = mpsc::unbounded_channel();
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
connection_generation: generation,
connected: true,
..Default::default()
}));
@@ -257,7 +268,6 @@ impl RuntimeSubscriptionBroker {
.clone();
if !initial_status.connected {
let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected {
connection_generation: initial_status.connection_generation,
message: "Runtime subscription connection is not currently available".to_string(),
});
}
@@ -286,8 +296,7 @@ struct SelectorState {
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
upstream_id: Option<SubscriptionId>,
pending: bool,
snapshot: Option<(u64, SubscriptionSnapshot)>,
revisions: HashMap<String, u64>,
snapshot: Option<SubscriptionSnapshot>,
}
impl SelectorState {
fn new() -> Self {
@@ -296,7 +305,6 @@ impl SelectorState {
upstream_id: None,
pending: false,
snapshot: None,
revisions: HashMap::new(),
}
}
}
@@ -334,11 +342,9 @@ impl State {
selector.upstream_id = None;
selector.pending = false;
selector.snapshot = None;
selector.revisions.clear();
broadcast(
&mut selector.downstreams,
BrokerSubscriptionEvent::Disconnected {
connection_generation: self.generation,
message: message.clone(),
},
);
@@ -347,12 +353,77 @@ impl State {
}
struct EmbeddedEntry {
lifetime: u64,
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
snapshot_revision: u64,
snapshot: SubscriptionSnapshot,
task: tokio::task::JoinHandle<()>,
}
enum EmbeddedUpdate {
Event {
selector: EventSubscriptionSelector,
lifetime: u64,
payload: SubscriptionEventPayload,
},
Closed {
selector: EventSubscriptionSelector,
lifetime: u64,
code: SubscriptionTerminationCode,
message: String,
},
}
fn start_embedded_subscription(
runtime: &worker_runtime::Runtime,
runtime_id: &str,
selector: EventSubscriptionSelector,
lifetime: u64,
updates: mpsc::UnboundedSender<EmbeddedUpdate>,
) -> Result<(SubscriptionSnapshot, tokio::task::JoinHandle<()>), worker_runtime::error::RuntimeError>
{
let mut subscription = runtime.subscribe_event_selector(selector.clone())?;
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), runtime_id);
let task_runtime_id = runtime_id.to_string();
let task = tokio::spawn(async move {
loop {
match subscription.recv().await {
Ok(update) => {
let payload = project_payload_runtime(update.payload, &task_runtime_id);
if updates
.send(EmbeddedUpdate::Event {
selector: selector.clone(),
lifetime,
payload,
})
.is_err()
{
return;
}
}
Err(worker_runtime::RuntimeSubscriptionRecvError::Lagged) => {
let _ = updates.send(EmbeddedUpdate::Closed {
selector,
lifetime,
code: SubscriptionTerminationCode::Lagged,
message: "embedded Runtime subscription lagged; resubscribing from a fresh snapshot".to_string(),
});
return;
}
Err(worker_runtime::RuntimeSubscriptionRecvError::Closed) => {
let _ = updates.send(EmbeddedUpdate::Closed {
selector,
lifetime,
code: SubscriptionTerminationCode::ServerShutdown,
message: "embedded Runtime subscription closed".to_string(),
});
return;
}
}
}
});
Ok((snapshot, task))
}
async fn run_embedded_connection(
runtime_id: String,
runtime: worker_runtime::Runtime,
@@ -364,6 +435,7 @@ async fn run_embedded_connection(
let (updates, mut update_receiver) = mpsc::unbounded_channel();
let mut entries = HashMap::<EventSubscriptionSelector, EmbeddedEntry>::new();
let mut downstream_index = HashMap::<u64, EventSubscriptionSelector>::new();
let mut next_lifetime = 1_u64;
loop {
tokio::select! {
command = commands.recv() => match command {
@@ -371,30 +443,38 @@ async fn run_embedded_connection(
downstream_index.insert(downstream_id, selector.clone());
if let Some(entry) = entries.get_mut(&selector) {
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
connection_generation: generation,
snapshot_revision: entry.snapshot_revision,
snapshot: entry.snapshot.clone(),
});
entry.downstreams.insert(downstream_id, events);
} else {
match runtime.subscribe_event_selector(selector.clone()) {
Ok(mut subscription) => {
let snapshot_revision = subscription.snapshot_revision();
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), &runtime_id);
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { connection_generation: generation, snapshot_revision, snapshot: snapshot.clone() });
let sender = updates.clone();
let task_selector = selector.clone();
let task_runtime_id = runtime_id.clone();
let task = tokio::spawn(async move {
while let Ok(update) = subscription.recv().await {
let payload = project_payload_runtime(update.payload, &task_runtime_id);
if sender.send((task_selector.clone(), update.subject_revision, payload)).is_err() { break; }
}
let lifetime = next_lifetime;
next_lifetime = next_lifetime.saturating_add(1);
match start_embedded_subscription(
&runtime,
&runtime_id,
selector.clone(),
lifetime,
updates.clone(),
) {
Ok((snapshot, task)) => {
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
snapshot: snapshot.clone(),
});
entries.insert(selector, EmbeddedEntry { downstreams: HashMap::from([(downstream_id, events)]), snapshot_revision, snapshot, task });
entries.insert(
selector,
EmbeddedEntry {
lifetime,
downstreams: HashMap::from([(downstream_id, events)]),
snapshot,
task,
},
);
}
Err(error) => {
let _ = events.try_send(BrokerSubscriptionEvent::Rejected { connection_generation: generation, code: SubscriptionRejectionCode::UnsupportedSelector, message: error.to_string() });
let _ = events.try_send(BrokerSubscriptionEvent::Rejected {
code: SubscriptionRejectionCode::UnsupportedSelector,
message: error.to_string(),
});
}
}
}
@@ -407,7 +487,7 @@ async fn run_embedded_connection(
}
Some(Command::Shutdown(replacement)) => {
for entry in entries.values_mut() {
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { connection_generation: generation, code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime generation {generation} was fenced by {replacement}") });
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime registration {generation} was replaced by {replacement}") });
entry.task.abort();
}
return;
@@ -415,16 +495,68 @@ async fn run_embedded_connection(
None => return,
},
update = update_receiver.recv() => {
let Some((selector, subject_revision, payload)) = update else { return; };
if let Some(entry) = entries.get_mut(&selector) {
entry.snapshot_revision = entry.snapshot_revision.saturating_add(1);
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { connection_generation: generation, subject_revision, payload });
let Some(update) = update else { return; };
match update {
EmbeddedUpdate::Event { selector, lifetime, payload } => {
if let Some(entry) = entries
.get_mut(&selector)
.filter(|entry| entry.lifetime == lifetime)
{
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { payload });
}
}
EmbeddedUpdate::Closed { selector, lifetime, code, message } => {
if !entries
.get(&selector)
.is_some_and(|entry| entry.lifetime == lifetime)
{
continue;
}
let Some(mut entry) = entries.remove(&selector) else { continue; };
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Closed { code, message },
);
if entry.downstreams.is_empty() {
continue;
}
let new_lifetime = next_lifetime;
next_lifetime = next_lifetime.saturating_add(1);
match start_embedded_subscription(
&runtime,
&runtime_id,
selector.clone(),
new_lifetime,
updates.clone(),
) {
Ok((snapshot, task)) => {
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Snapshot {
snapshot: snapshot.clone(),
},
);
entry.snapshot = snapshot;
entry.task = task;
entry.lifetime = new_lifetime;
entries.insert(selector, entry);
}
Err(error) => {
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Rejected {
code: SubscriptionRejectionCode::Internal,
message: error.to_string(),
},
);
}
}
}
}
}
}
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
connection_generation: generation,
connected: true,
desired_selectors: entries.len(),
upstream_subscriptions: entries.len(),
@@ -597,10 +729,8 @@ async fn apply_online(
.selectors
.entry(selector.clone())
.or_insert_with(SelectorState::new);
if let Some((revision, snapshot)) = &entry.snapshot {
if let Some(snapshot) = &entry.snapshot {
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
connection_generation: state.generation,
snapshot_revision: *revision,
snapshot: snapshot.clone(),
});
}
@@ -696,7 +826,6 @@ async fn handle_frame(
request_id,
subscription_id,
selector,
snapshot_revision,
snapshot,
}) => {
if state.pending.remove(&request_id) != Some(selector.clone()) {
@@ -706,18 +835,13 @@ async fn handle_frame(
let entry = state.selectors.get_mut(&selector).ok_or(())?;
entry.pending = false;
entry.upstream_id = Some(subscription_id.clone());
entry.snapshot = Some((snapshot_revision, snapshot.clone()));
entry.revisions = snapshot_revisions(&snapshot);
entry.snapshot = Some(snapshot.clone());
state
.upstream_index
.insert(subscription_id, selector.clone());
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Snapshot {
connection_generation: state.generation,
snapshot_revision,
snapshot,
},
BrokerSubscriptionEvent::Snapshot { snapshot },
);
if entry.downstreams.is_empty() {
maybe_unsubscribe(socket, state, selector).await?;
@@ -734,18 +858,13 @@ async fn handle_frame(
if let Some(mut entry) = state.selectors.remove(&selector) {
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Rejected {
connection_generation: state.generation,
code,
message,
},
BrokerSubscriptionEvent::Rejected { code, message },
);
}
}
}
SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id,
subject_revision,
payload,
}) => {
let selector = state
@@ -756,24 +875,12 @@ async fn handle_frame(
payload.validate_for_selector(&selector).map_err(|_| ())?;
let payload = project_payload_runtime(payload, &state.runtime_id);
let entry = state.selectors.get_mut(&selector).ok_or(())?;
if let Some(subject) = event_subject(&payload) {
let revision = entry.revisions.entry(subject).or_insert(0);
if subject_revision <= *revision {
return Ok(());
}
*revision = subject_revision;
}
if let Some((snapshot_revision, snapshot)) = entry.snapshot.as_mut() {
*snapshot_revision = snapshot_revision.saturating_add(1);
if let Some(snapshot) = entry.snapshot.as_mut() {
apply_event_to_cached_snapshot(snapshot, &payload);
}
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Event {
connection_generation: state.generation,
subject_revision,
payload,
},
BrokerSubscriptionEvent::Event { payload },
);
}
SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed {
@@ -785,14 +892,9 @@ async fn handle_frame(
let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) {
entry.upstream_id = None;
entry.snapshot = None;
entry.revisions.clear();
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Closed {
connection_generation: state.generation,
code,
message,
},
BrokerSubscriptionEvent::Closed { code, message },
);
!entry.downstreams.is_empty()
} else {
@@ -822,10 +924,9 @@ fn close_all(state: &mut State, replacement: u64) {
broadcast(
&mut entry.downstreams,
BrokerSubscriptionEvent::Closed {
connection_generation: state.generation,
code: SubscriptionTerminationCode::ServerShutdown,
message: format!(
"Runtime connection generation {} was fenced by generation {replacement}",
"Runtime registration {} was replaced by {replacement}",
state.generation
),
},
@@ -846,51 +947,6 @@ fn broadcast(
downstreams.retain(|id, _| !closed.contains(id));
}
fn snapshot_revisions(snapshot: &SubscriptionSnapshot) -> HashMap<String, u64> {
match snapshot {
SubscriptionSnapshot::Workers { workers } => workers
.iter()
.map(|worker| {
(
format!(
"{}:{}",
worker.runtime_id.as_deref().unwrap_or_default(),
worker.worker_id
),
worker.subject_revision,
)
})
.collect(),
SubscriptionSnapshot::WorkerProtocol { worker_id, .. } => {
HashMap::from([(worker_id.to_string(), 0)])
}
SubscriptionSnapshot::WorkspaceWorkdirs { .. } => HashMap::new(),
}
}
fn event_subject(payload: &SubscriptionEventPayload) -> Option<String> {
Some(match payload {
SubscriptionEventPayload::WorkerUpserted { worker } => format!(
"{}:{}",
worker.runtime_id.as_deref().unwrap_or_default(),
worker.worker_id
),
SubscriptionEventPayload::WorkerRemoved {
worker_id,
runtime_id,
} => format!(
"{}:{}",
runtime_id.as_deref().unwrap_or_default(),
worker_id
),
SubscriptionEventPayload::WorkerProtocol { worker_id, .. } => worker_id.to_string(),
SubscriptionEventPayload::WorkdirUpserted { workdir } => {
workdir.working_directory_id.to_string()
}
SubscriptionEventPayload::WorkdirRemoved {
working_directory_id,
} => working_directory_id.to_string(),
})
}
async fn send_frame(socket: &mut RuntimeSocket, frame: SubscriptionFrame) -> Result<(), ()> {
frame.validate().map_err(|_| ())?;
socket
@@ -979,7 +1035,6 @@ fn runtime_token(
}
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
connection_generation: state.generation,
connected,
desired_selectors: state
.selectors
@@ -74,6 +74,7 @@ fn create_request(name: &str) -> CreateWorkerRequest {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
@@ -264,16 +265,9 @@ async fn replacing_runtime_registration_fences_the_old_generation() {
assert!(second_generation > first_generation);
assert!(matches!(
next_event(&mut old).await,
BrokerSubscriptionEvent::Closed {
connection_generation,
..
} if connection_generation == first_generation
BrokerSubscriptionEvent::Closed { .. }
));
let status = wait_for_status(&broker, "runtime-test", |status| {
status.connection_generation == second_generation && status.connected
})
.await;
assert_eq!(status.connection_generation, second_generation);
wait_for_status(&broker, "runtime-test", |status| status.connected).await;
server.abort();
}
@@ -430,6 +424,75 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
server.abort();
}
#[tokio::test]
async fn embedded_downstream_overflow_recovers_through_a_fresh_snapshot() {
let (runtime, _config, server) = fixture().await;
let worker = runtime.list_workers().unwrap().remove(0);
let broker = RuntimeSubscriptionBroker::new("local");
broker.register_embedded_runtime("embedded-worker-runtime", runtime.clone());
let mut subscription = broker
.subscribe(
"embedded-worker-runtime",
EventSubscriptionSelector::RuntimeWorkers,
)
.unwrap();
assert!(matches!(
next_snapshot(&mut subscription).await,
BrokerSubscriptionEvent::Snapshot { .. }
));
let final_command_id = (DOWNSTREAM_QUEUE_CAPACITY as u64) * 3;
for command_id in 1..=final_command_id {
let mut snapshot = worker.worker_state.clone().unwrap();
snapshot.last_command_id = command_id;
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::WorkerState { snapshot },
)
.unwrap();
}
let mut saw_recovery_snapshot = false;
let mut converged = false;
tokio::time::timeout(Duration::from_secs(5), async {
while !converged {
match subscription
.recv()
.await
.expect("broker subscription closed")
{
BrokerSubscriptionEvent::Snapshot {
snapshot: SubscriptionSnapshot::Workers { workers },
} => {
saw_recovery_snapshot = true;
converged = workers.iter().any(|candidate| {
candidate.worker_id.as_str() == worker.worker_id.to_string()
&& candidate
.worker_state
.as_ref()
.is_some_and(|state| state.last_command_id == final_command_id)
});
}
BrokerSubscriptionEvent::Event {
payload: SubscriptionEventPayload::WorkerUpserted { worker: candidate },
} if saw_recovery_snapshot => {
converged = candidate.worker_id.as_str() == worker.worker_id.to_string()
&& candidate
.worker_state
.as_ref()
.is_some_and(|state| state.last_command_id == final_command_id);
}
_ => {}
}
}
})
.await
.expect("overflow recovery did not converge");
assert!(saw_recovery_snapshot);
server.abort();
}
#[tokio::test]
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
let config = RemoteRuntimeConfig::new(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+318 -110
View File
@@ -11,7 +11,10 @@ use tokio::task::JoinHandle;
use crate::runtime_subscription::{
BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError,
};
use crate::store::{ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit};
use crate::store::{
ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit,
WorkerRegistryProjectionRecord,
};
use worker_runtime::identity::RuntimeWorkerRef;
const PROJECTION_EVENT_CAPACITY: usize = 256;
@@ -20,21 +23,42 @@ pub type WorkerProjectionChange = WorkerCatalogChange;
#[derive(Debug, Clone)]
pub struct WorkerProjectionEvent {
pub revision: u64,
pub changes: Vec<WorkerProjectionChange>,
}
/// An authoritative durable snapshot followed by changes committed after that snapshot.
pub struct WorkerProjectionSubscription {
snapshot: Vec<WorkerRegistryProjectionRecord>,
receiver: broadcast::Receiver<WorkerProjectionEvent>,
}
impl WorkerProjectionSubscription {
pub fn take_snapshot(&mut self) -> Vec<WorkerRegistryProjectionRecord> {
std::mem::take(&mut self.snapshot)
}
pub async fn recv(&mut self) -> Result<WorkerProjectionEvent, broadcast::error::RecvError> {
self.receiver.recv().await
}
#[cfg(test)]
pub fn try_recv(&mut self) -> Result<WorkerProjectionEvent, broadcast::error::TryRecvError> {
self.receiver.try_recv()
}
}
/// Owns the durable Runtime-to-Workspace Worker projection and its independent
/// low-frequency publication stream.
///
/// One Runtime subscription is kept alive per registered Runtime even when no
/// browser is connected. Browser subscriptions therefore never become the
/// authority for populating `worker_registry` observations.
/// The ordering gate covers both durable commits and subscription snapshot creation.
/// Every subscription therefore starts with one authoritative snapshot and receives
/// only commits that completed after it, in the same order in which they were stored.
pub struct WorkerProjectionService {
workspace_id: String,
store: Arc<dyn ControlPlaneStore>,
events: broadcast::Sender<WorkerProjectionEvent>,
connection_generation_epoch: u64,
ordering: Mutex<()>,
attach_gate: tokio::sync::Mutex<()>,
tasks: Mutex<HashMap<String, JoinHandle<()>>>,
}
@@ -45,8 +69,8 @@ impl WorkerProjectionService {
workspace_id: workspace_id.into(),
store,
events,
connection_generation_epoch: Utc::now().timestamp_nanos_opt().unwrap_or_default().max(0)
as u64,
ordering: Mutex::new(()),
attach_gate: tokio::sync::Mutex::new(()),
tasks: Mutex::new(HashMap::new()),
})
}
@@ -62,8 +86,38 @@ impl WorkerProjectionService {
}
}
pub fn subscribe(&self) -> broadcast::Receiver<WorkerProjectionEvent> {
self.events.subscribe()
pub fn subscribe_ordered(&self, limit: usize) -> crate::Result<WorkerProjectionSubscription> {
let _ordering = self
.ordering
.lock()
.expect("Worker projection ordering lock poisoned");
let receiver = self.events.subscribe();
let snapshot_limit = limit.saturating_add(1);
let mut snapshot = self
.store
.worker_registry_projection_snapshot(self.workspace_id.as_str(), snapshot_limit)?;
if snapshot.len() > limit {
return Err(crate::Error::Store(format!(
"authoritative Worker projection exceeds the configured snapshot limit of {limit} records"
)));
}
snapshot.shrink_to_fit();
Ok(WorkerProjectionSubscription { snapshot, receiver })
}
/// Serialize one durable projection mutation with its publication. The closure
/// must return the exact projection payload captured by the same transaction.
pub(crate) fn publish_ordered<T, E>(
&self,
mutation: impl FnOnce() -> std::result::Result<(T, WorkerRegistryProjectionCommit), E>,
) -> std::result::Result<T, E> {
let _ordering = self
.ordering
.lock()
.expect("Worker projection ordering lock poisoned");
let (result, commit) = mutation()?;
self.publish_commit(commit);
Ok(result)
}
pub async fn attach_runtime(
@@ -71,6 +125,20 @@ impl WorkerProjectionService {
broker: &RuntimeSubscriptionBroker,
runtime_id: &str,
) -> Result<(), RuntimeSubscriptionBrokerError> {
// Serialize replacement and wait for the old consumer to stop before the new
// lifetime can apply its snapshot. Late notifications can therefore never
// cross a Runtime ownership boundary.
let _attach = self.attach_gate.lock().await;
let previous = self
.tasks
.lock()
.expect("Worker projection task lock poisoned")
.remove(runtime_id);
if let Some(previous) = previous {
previous.abort();
let _ = previous.await;
}
let mut subscription =
broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
@@ -80,12 +148,14 @@ impl WorkerProjectionService {
break;
};
let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. });
if let Err(error) = self.apply_broker_event(runtime_id, event).await {
if let Err(error) = self.apply_broker_event(runtime_id, event) {
tracing::warn!(
runtime_id,
error = %error,
"failed to initialize durable Worker projection"
"failed to initialize durable Worker projection; restarting from a fresh snapshot"
);
subscription.restart()?;
continue;
}
if initialized {
break;
@@ -99,26 +169,22 @@ impl WorkerProjectionService {
let Some(service) = service.upgrade() else {
return;
};
if let Err(error) = service
.apply_broker_event(task_runtime_id.as_str(), event)
.await
{
if let Err(error) = service.apply_broker_event(task_runtime_id.as_str(), event) {
tracing::warn!(
runtime_id = %task_runtime_id,
error = %error,
"failed to update durable Worker projection"
"failed to update durable Worker projection; restarting from a fresh snapshot"
);
if subscription.restart().is_err() {
return;
}
}
}
});
if let Some(previous) = self
.tasks
self.tasks
.lock()
.expect("Worker projection task lock poisoned")
.insert(runtime_id, task)
{
previous.abort();
}
.insert(runtime_id, task);
Ok(())
}
@@ -128,110 +194,96 @@ impl WorkerProjectionService {
worker: &protocol::subscription::SubscriptionWorker,
observed_at: &str,
) -> crate::Result<()> {
let commit = self.store.apply_worker_registry_observation(
self.workspace_id.as_str(),
runtime_id,
0,
worker,
observed_at,
)?;
self.publish_commit(commit)
self.publish_ordered(|| {
self.store
.apply_worker_registry_observation(
self.workspace_id.as_str(),
runtime_id,
worker,
observed_at,
)
.map(|commit| ((), commit))
})
}
pub fn refresh(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> {
let commit = self
.store
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)?;
self.publish_commit(commit)
self.publish_ordered(|| {
self.store
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)
.map(|commit| ((), commit))
})
}
async fn apply_broker_event(
fn apply_broker_event(
&self,
runtime_id: &str,
event: BrokerSubscriptionEvent,
) -> crate::Result<()> {
let observed_at = Utc::now().to_rfc3339();
let projection_generation = |connection_generation: u64| {
self.connection_generation_epoch
.saturating_add(connection_generation)
};
let commit = match event {
BrokerSubscriptionEvent::Snapshot {
connection_generation,
snapshot_revision,
snapshot: SubscriptionSnapshot::Workers { workers },
} => self.store.reconcile_worker_registry_snapshot(
self.workspace_id.as_str(),
runtime_id,
projection_generation(connection_generation),
snapshot_revision,
workers.as_slice(),
observed_at.as_str(),
)?,
BrokerSubscriptionEvent::Snapshot { .. } => return Ok(()),
BrokerSubscriptionEvent::Event {
connection_generation,
payload: SubscriptionEventPayload::WorkerUpserted { worker },
..
} => self.store.apply_worker_registry_observation(
self.workspace_id.as_str(),
runtime_id,
projection_generation(connection_generation),
&worker,
observed_at.as_str(),
)?,
BrokerSubscriptionEvent::Event {
connection_generation,
subject_revision,
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
} => self.store.mark_worker_registry_observation_unavailable(
self.workspace_id.as_str(),
runtime_id,
projection_generation(connection_generation),
Some(worker_id.as_str()),
Some(subject_revision),
observed_at.as_str(),
)?,
BrokerSubscriptionEvent::Event { .. } => return Ok(()),
BrokerSubscriptionEvent::Disconnected {
connection_generation,
..
}
| BrokerSubscriptionEvent::Rejected {
connection_generation,
..
}
| BrokerSubscriptionEvent::Closed {
connection_generation,
..
} => self.store.mark_worker_registry_observation_unavailable(
self.workspace_id.as_str(),
runtime_id,
projection_generation(connection_generation),
None,
None,
observed_at.as_str(),
)?,
};
self.publish_commit(commit)
self.publish_ordered(|| {
let commit = match event {
BrokerSubscriptionEvent::Snapshot {
snapshot: SubscriptionSnapshot::Workers { workers },
} => self.store.reconcile_worker_registry_snapshot(
self.workspace_id.as_str(),
runtime_id,
workers.as_slice(),
observed_at.as_str(),
)?,
BrokerSubscriptionEvent::Snapshot { .. } => {
return Ok((
(),
WorkerRegistryProjectionCommit {
changes: Vec::new(),
},
));
}
BrokerSubscriptionEvent::Event {
payload: SubscriptionEventPayload::WorkerUpserted { worker },
} => self.store.apply_worker_registry_observation(
self.workspace_id.as_str(),
runtime_id,
&worker,
observed_at.as_str(),
)?,
BrokerSubscriptionEvent::Event {
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
} => self.store.mark_worker_registry_observation_unavailable(
self.workspace_id.as_str(),
runtime_id,
Some(worker_id.as_str()),
observed_at.as_str(),
)?,
BrokerSubscriptionEvent::Event { .. } => {
return Ok((
(),
WorkerRegistryProjectionCommit {
changes: Vec::new(),
},
));
}
BrokerSubscriptionEvent::Disconnected { .. }
| BrokerSubscriptionEvent::Rejected { .. }
| BrokerSubscriptionEvent::Closed { .. } => {
self.store.mark_worker_registry_observation_unavailable(
self.workspace_id.as_str(),
runtime_id,
None,
observed_at.as_str(),
)?
}
};
Ok(((), commit))
})
}
/// Broadcast an already committed catalog payload. Persistence always happens
/// before this call; a process crash therefore closes subscribers, whose next
/// subscription starts from the durable snapshot. Retrying a removal returns
/// the same durable change and can heal an in-process missed broadcast.
pub(crate) fn publish_commit(
&self,
commit: WorkerRegistryProjectionCommit,
) -> crate::Result<()> {
fn publish_commit(&self, commit: WorkerRegistryProjectionCommit) {
if commit.changes.is_empty() {
return Ok(());
return;
}
let _ = self.events.send(WorkerProjectionEvent {
revision: commit.revision,
changes: commit.changes,
});
Ok(())
}
}
@@ -240,3 +292,159 @@ impl Drop for WorkerProjectionService {
self.shutdown();
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Barrier, Mutex};
use crate::store::{
AccountRecord, ControlPlaneStore, SqliteWorkspaceStore, WorkerCatalogChange,
WorkerRegistryRecord, WorkspaceRecord,
};
use worker_runtime::identity::RuntimeWorkerRef;
use super::WorkerProjectionService;
const WORKSPACE_ID: &str = "projection-ordering-test";
async fn fixture() -> (
Arc<SqliteWorkspaceStore>,
Arc<WorkerProjectionService>,
tempfile::TempDir,
) {
let temp = tempfile::tempdir().unwrap();
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "owner".to_string(),
kind: "user".to_string(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: WORKSPACE_ID.to_string(),
owner_account_id: "owner".to_string(),
display_name: "Projection ordering".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let service = WorkerProjectionService::new(WORKSPACE_ID, store.clone());
(store, service, temp)
}
fn worker(runtime_id: &str, worker_id: &str) -> WorkerRegistryRecord {
WorkerRegistryRecord {
workspace_id: WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
display_name: format!("{runtime_id}-{worker_id}"),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_registration_is_atomic_with_concurrent_publication() {
let (store, service, _temp) = fixture().await;
let barrier = Arc::new(Barrier::new(2));
let subscriber_service = service.clone();
let subscriber_barrier = barrier.clone();
let subscriber = std::thread::spawn(move || {
subscriber_barrier.wait();
subscriber_service.subscribe_ordered(10).unwrap()
});
let publisher_service = service.clone();
let publisher_barrier = barrier.clone();
let publisher_store = store.clone();
let record = worker("runtime-a", "worker-a");
let publisher = std::thread::spawn(move || {
publisher_barrier.wait();
publisher_service
.publish_ordered(|| {
publisher_store
.upsert_worker_registry(&record)
.map(|commit| ((), commit))
})
.unwrap();
});
let mut subscription = subscriber.join().unwrap();
publisher.join().unwrap();
let in_snapshot = subscription
.take_snapshot()
.iter()
.any(|record| record.registry.worker.worker_id == "worker-a");
let in_event = match subscription.try_recv() {
Ok(event) => matches!(
event.changes.as_slice(),
[WorkerCatalogChange::Upsert(record)]
if record.registry.worker.worker_id == "worker-a"
),
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => false,
Err(error) => panic!("unexpected projection receive error: {error}"),
};
assert_ne!(
in_snapshot, in_event,
"the commit must cross the boundary exactly once"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_commits_are_published_in_durable_execution_order() {
let (store, service, _temp) = fixture().await;
let mut subscription = service.subscribe_ordered(10).unwrap();
assert!(subscription.take_snapshot().is_empty());
let barrier = Arc::new(Barrier::new(3));
let durable_order = Arc::new(Mutex::new(Vec::new()));
let mut publishers = Vec::new();
for worker_id in ["worker-a", "worker-b"] {
let service = service.clone();
let store = store.clone();
let barrier = barrier.clone();
let durable_order = durable_order.clone();
let record = worker("runtime-a", worker_id);
publishers.push(std::thread::spawn(move || {
barrier.wait();
service
.publish_ordered(|| {
durable_order
.lock()
.unwrap()
.push(record.worker.worker_id.clone());
store
.upsert_worker_registry(&record)
.map(|commit| ((), commit))
})
.unwrap();
}));
}
barrier.wait();
for publisher in publishers {
publisher.join().unwrap();
}
let published_order = [
subscription.try_recv().unwrap(),
subscription.try_recv().unwrap(),
]
.into_iter()
.map(|event| match event.changes.as_slice() {
[WorkerCatalogChange::Upsert(record)] => record.registry.worker.worker_id.clone(),
changes => panic!("unexpected projection changes: {changes:?}"),
})
.collect::<Vec<_>>();
assert_eq!(published_order, *durable_order.lock().unwrap());
}
}
@@ -296,7 +296,7 @@ mod tests {
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
repository_key: "platform".to_string(),
default_ref: None,
},
};
@@ -322,13 +322,11 @@ mod tests {
assert_eq!(signing_identity.algorithm, "ed25519");
assert!(signing_identity.public_key.is_some());
assert!(signing_identity.public_key_fingerprint.is_some());
assert_eq!(
store
.list_repositories(&created.workspace.workspace_id)
.unwrap()
.len(),
1
);
let repositories = store
.list_repositories(&created.workspace.workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_key, "platform");
assert!(
store
.load_workspace_config(&created.workspace.workspace_id)
@@ -337,6 +335,42 @@ mod tests {
);
}
#[test]
fn create_rejects_noncanonical_repository_keys_without_persisting_a_workspace() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository();
let owner_account_id = owner_account(store.as_ref());
for (operation_key, repository_key) in [
("blank-repository-key", " "),
("spaced-repository-key", " platform "),
] {
let request = WorkspaceCreateRequest {
operation_key: operation_key.to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: repository_key.to_string(),
default_ref: None,
},
};
let error = service
.create(request, owner_account_id.clone())
.unwrap_err()
.to_string();
assert!(error.contains("invalid Repository key"), "{error}");
assert!(store.list_workspaces().unwrap().is_empty());
}
}
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
@@ -73,12 +73,11 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"worker_orphan_diagnostics",
"worker_registry",
"worker_registry_observations",
"worker_registry_projection_cursors",
"worker_registry_projection_diagnostics",
"worker_registry_projection_removals",
"worker_registry_projection_revisions",
"worker_removal_operations",
"worker_retention_audit_events",
"worker_session_archive_observe_grants",
"worker_session_archives",
"worker_singleton_owners",
"worker_tombstones",
@@ -271,7 +271,6 @@ async fn run_worker_protocol(
worker_id: worker_id.clone(),
runtime_id: Some(runtime_id),
},
snapshot_revision: 0,
snapshot: SubscriptionSnapshot::WorkerProtocol {
worker_id: worker_id.clone(),
events: vec![initial_event],
@@ -284,13 +283,10 @@ async fn run_worker_protocol(
{
return;
}
let mut subject_revision = 0_u64;
while let Some(event) = events.recv().await {
subject_revision = subject_revision.saturating_add(1);
let frame =
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id: subscription_id.clone(),
subject_revision,
payload: SubscriptionEventPayload::WorkerProtocol {
worker_id: worker_id.clone(),
event,
@@ -333,12 +329,10 @@ async fn run_workspace_workers(
subscription_id: SubscriptionId,
outbound: mpsc::Sender<WsMessage>,
) {
// Subscribe before reading so commits racing with the snapshot are replayed.
let mut events = api.worker_projection.subscribe();
let Ok((snapshot_revision, records)) = api.store.worker_registry_projection_snapshot(
&api.config.workspace_id,
api.config.max_records.max(1),
) else {
let Ok(mut projection) = api
.worker_projection
.subscribe_ordered(api.config.max_records.max(1))
else {
let _ = send_rejected(
&outbound,
request_id,
@@ -348,7 +342,8 @@ async fn run_workspace_workers(
.await;
return;
};
let mut workers = records
let mut workers = projection
.take_snapshot()
.into_iter()
.filter_map(|record| project_registry_worker(&api, record))
.collect::<Vec<_>>();
@@ -360,7 +355,6 @@ async fn run_workspace_workers(
request_id,
subscription_id: subscription_id.clone(),
selector: EventSubscriptionSelector::WorkspaceWorkers,
snapshot_revision,
snapshot: SubscriptionSnapshot::Workers { workers },
},
)),
@@ -372,16 +366,14 @@ async fn run_workspace_workers(
}
loop {
match events.recv().await {
Ok(event) if event.revision <= snapshot_revision => continue,
match projection.recv().await {
Ok(event) => {
for change in event.changes {
let payload = match change {
crate::worker_projection::WorkerProjectionChange::Upsert(record) => {
let Some(mut worker) = project_registry_worker(&api, record) else {
let Some(worker) = project_registry_worker(&api, record) else {
continue;
};
worker.subject_revision = event.revision;
SubscriptionEventPayload::WorkerUpserted { worker }
}
crate::worker_projection::WorkerProjectionChange::Removed(worker) => {
@@ -396,7 +388,7 @@ async fn run_workspace_workers(
}
}
};
if send_event(&outbound, &subscription_id, event.revision, payload)
if send_event(&outbound, &subscription_id, payload)
.await
.is_err()
{
@@ -431,7 +423,6 @@ fn project_registry_worker(
let mut worker = if let Some(observation) = record.observation {
let mut worker = observation.worker;
worker.availability = observation.availability;
worker.subject_revision = observation.projection_revision;
worker
} else {
SubscriptionWorker {
@@ -442,7 +433,6 @@ fn project_registry_worker(
runtime_id: Some(runtime_id.clone()),
resource_key: record.resource_key.clone(),
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
subject_revision: 0,
worker_state: None,
state: protocol::subscription::SubscriptionWorkerState::Stopped,
has_running_internal_workers: false,
@@ -559,14 +549,12 @@ fn sort_workers(workers: &mut [SubscriptionWorker]) {
async fn send_event(
outbound: &mpsc::Sender<WsMessage>,
subscription_id: &SubscriptionId,
subject_revision: u64,
payload: SubscriptionEventPayload,
) -> Result<(), ()> {
send_frame(
outbound,
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id: subscription_id.clone(),
subject_revision,
payload,
})),
)
@@ -617,7 +605,6 @@ mod tests {
runtime_id: Some("runtime-1".to_string()),
resource_key: Some("W-1".to_string()),
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
subject_revision: 1,
worker_state: None,
state: protocol::subscription::SubscriptionWorkerState::Idle,
has_running_internal_workers: false,
+31 -2
View File
@@ -2986,7 +2986,7 @@ backend = "shared"
"--display-name".to_string(),
"Workspace A".to_string(),
"--repository-key".to_string(),
"main".to_string(),
"platform".to_string(),
"--repository".to_string(),
repository.path().display().to_string(),
"--default-ref".to_string(),
@@ -3000,7 +3000,7 @@ backend = "shared"
};
assert_eq!(options.backend_url, "http://backend.example");
assert_eq!(options.display_name, "Workspace A");
assert_eq!(options.repository_key, "main");
assert_eq!(options.repository_key, "platform");
assert_eq!(options.default_ref.as_deref(), Some("develop"));
assert_eq!(
options.repository_root,
@@ -3009,6 +3009,35 @@ backend = "shared"
assert!(!repository.path().join(".yoi/workspace.toml").exists());
}
#[test]
fn init_parsing_rejects_an_omitted_repository_key() {
let repository = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repository.path())
.status()
.unwrap();
let resolver = FixedCliConnectionResolver {
backend_url: "http://unused.example",
};
let args = vec![
"--backend".to_string(),
"http://backend.example".to_string(),
"init".to_string(),
"--display-name".to_string(),
"Workspace A".to_string(),
"--repository".to_string(),
repository.path().display().to_string(),
];
let error = parse_args_slice_with_connection_resolver(&args, &resolver)
.unwrap_err()
.to_string();
assert_eq!(error, "yoi init requires --repository-key KEY");
assert!(!repository.path().join(".yoi/workspace.toml").exists());
}
#[test]
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
let workspace = tempfile::tempdir().unwrap();