fix: harden subject memory consolidation

This commit is contained in:
2026-10-02 11:13:11 +09:00
parent 046e606a38
commit ba98a6afe6
6 changed files with 387 additions and 37 deletions
+49
View File
@@ -1325,6 +1325,55 @@ mod tests {
}
}
#[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.memory.profile.resident.inject_summary);
assert!(!feature.memory.profile.extraction.enabled);
assert!(!feature.memory.profile.consolidation.request_enabled);
assert!(!feature.subjektiv.profile.enabled);
assert!(!feature.task.enabled);
assert!(!feature.web.enabled);
assert!(!feature.image.enabled);
assert!(!feature.sub_worker.enabled);
assert!(!feature.flow.enabled);
assert!(!feature.worker.enabled);
assert!(!feature.worker.direct_spawn);
assert!(!feature.workspace_worker_discovery.enabled);
assert!(!feature.objective.enabled);
assert!(!feature.manage_workdir.enabled);
assert!(!feature.ticket.enabled);
assert!(!feature.ticket.authoring);
assert!(!feature.ticket.thread);
assert!(!feature.ticket.intake);
assert!(!feature.ticket.workflow);
assert!(!feature.merge_request.show);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
assert!(!feature.merge_request.readiness_check);
assert!(!feature.merge_request.complete);
assert!(!feature.orchestration.enabled);
assert_eq!(
manifest.web.as_ref().and_then(|web| web.enabled),
Some(false)
);
}
#[test]
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
let tmp = TempDir::new().unwrap();
@@ -781,6 +781,39 @@ mod tests {
))
}
fn repository_error_client(status: u16, code: &str) -> Arc<dyn WorkspaceClient> {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let body = serde_json::json!({
"error": "request rejected",
"message": format!("{code}: retry after reread"),
"diagnostics": [{
"code": code,
"severity": "error",
"message": format!("{code}: retry after reread"),
}],
})
.to_string();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 4096];
let _ = stream.read(&mut request).unwrap();
let response = format!(
"HTTP/1.1 {status} rejected\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
});
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
format!("http://{addr}"),
))
}
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
let mut names = definitions
.into_iter()
@@ -991,6 +1024,39 @@ mod tests {
assert!(names.contains(&"MemoryStagingClose".to_string()));
}
#[tokio::test]
async fn subjektiv_candidate_conflicts_survive_workspace_http_transport() {
let input = serde_json::json!({
"request_id": "decision-request-1",
"candidate_id": "candidate-1",
"reason": "candidate is already covered",
"decision": {
"kind": "close",
"action": "already_covered",
"affected_memory": [],
},
})
.to_string();
for (status, code) in [
(409, "candidate_decision_conflict"),
(403, "subject_scope_mismatch"),
] {
let tool = SubjectConsolidationTool {
client: repository_error_client(status, code),
operation: SubjectConsolidationOperation::DecideCandidate,
};
let output = tool
.execute(&input, ToolExecutionContext::direct())
.await
.expect("typed conflicts are model-visible retry output");
let content: serde_json::Value =
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(content["status"], "error");
assert_eq!(content["error"]["code"], code);
assert!(output.summary.contains("requires reread"));
}
}
#[test]
fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() {
let tools = workspace_http_subjektiv_consolidation_tools(test_client());
@@ -79,6 +79,15 @@ struct MemoryLifecycleTask {
event_tx: Option<broadcast::Sender<Event>>,
}
fn memory_lifecycle_requested(
lifecycle_enabled: bool,
config: &manifest::ResolvedMemoryFeatureConfig,
) -> bool {
lifecycle_enabled
&& config.profile.enabled
&& (config.profile.extraction.enabled || config.profile.consolidation.request_enabled)
}
impl MemoryLifecycleFeature {
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_resolved_config(
@@ -93,10 +102,7 @@ impl MemoryLifecycleFeature {
workspace_context: WorkerWorkspaceContext,
event_tx: Option<broadcast::Sender<Event>>,
) -> std::io::Result<Option<Self>> {
if !lifecycle_enabled
|| !config.profile.enabled
|| (!config.profile.extraction.enabled && !config.profile.consolidation.request_enabled)
{
if !memory_lifecycle_requested(lifecycle_enabled, &config) {
return Ok(None);
}
config
@@ -1365,6 +1371,26 @@ permission = "write"
config
}
#[test]
fn resolved_subjektiv_consolidation_profile_does_not_install_global_memory_lifecycle() {
let workspace = tempfile::tempdir().unwrap();
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&manifest::ProfileSelector::source_named(
manifest::ProfileRegistrySource::Builtin,
"subjektiv-memory-consolidation",
),
manifest::ProfileResolveOptions::with_worker_name("subject-consolidator"),
)
.unwrap();
assert!(!memory_lifecycle_requested(
true,
&resolved.manifest.feature.memory
));
}
fn test_task(
capture: CommittedSessionCapture,
client: Box<dyn LlmClient>,
+2 -1
View File
@@ -5370,6 +5370,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 +5378,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 {
+211 -29
View File
@@ -16544,7 +16544,9 @@ async fn scoped_memory_backend_operation(
}
const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:";
const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:";
const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str =
crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX;
const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation";
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
@@ -16591,6 +16593,38 @@ fn subjektiv_singleton_key(subject_id: &str) -> ApiResult<String> {
Ok(key)
}
fn is_dedicated_subjektiv_consolidation_worker(
worker: &InternalWorkerSummary,
workspace_id: &str,
singleton_key: &str,
) -> bool {
let profile_matches = worker.profile.as_deref().is_some_and(|profile| {
profile.strip_prefix("builtin:").unwrap_or(profile)
== SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE
});
let profile_tag = format!("profile:{SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE}");
worker.workspace.workspace_id.as_deref() == Some(workspace_id)
&& worker.singleton_key.as_deref() == Some(singleton_key)
&& singleton_key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX)
&& profile_matches
&& worker.tags.iter().any(|tag| tag == &profile_tag)
}
fn require_dedicated_subjektiv_consolidation_worker(
worker: &InternalWorkerSummary,
workspace_id: &str,
singleton_key: &str,
) -> ApiResult<()> {
if is_dedicated_subjektiv_consolidation_worker(worker, workspace_id, singleton_key) {
Ok(())
} else {
Err(Error::WorkspacePermissionDenied(
"subject Memory consolidation requires a Backend-managed dedicated Worker".to_string(),
)
.into())
}
}
fn subjektiv_subject_scope(
api: &WorkspaceApi,
workspace_id: &str,
@@ -16616,6 +16650,14 @@ fn subjektiv_subject_scope(
)
})?;
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?;
if authority == SubjektivWorkerAuthority::Consolidation {
let mut summary = api
.runtime
.worker(&worker)
.map_err(|error| error.into_error())?;
summary.singleton_key = Some(lease.key.clone());
require_dedicated_subjektiv_consolidation_worker(&summary, workspace_id, &lease.key)?;
}
Ok((subject_id.to_string(), worker, authority))
}
@@ -18187,7 +18229,6 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
}
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation";
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
async fn scoped_subjektiv_memory_consolidation(
@@ -18324,6 +18365,11 @@ fn try_reuse_subject_consolidation_worker(
.worker(&owner.worker)
.map_err(|error| error.into_error())?;
worker.singleton_key = Some(owner.key);
require_dedicated_subjektiv_consolidation_worker(
&worker,
&api.config.workspace_id,
singleton_key,
)?;
if worker.state != "idle" {
return Ok(Some(MemoryConsolidationOutput {
status: "skipped_existing_not_idle".into(),
@@ -29621,25 +29667,32 @@ impl From<Error> for ApiError {
severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(&ticket_error.to_string()),
}],
Error::RepositoryConflict(message) => subjektiv_conflict_code(message)
.map(|code| {
vec![RuntimeDiagnostic {
code: code.to_string(),
severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(message),
}]
})
.unwrap_or_default(),
Error::RepositoryConflict(message) | Error::WorkspacePermissionDenied(message) => {
subjektiv_diagnostic_code(message)
.map(|code| {
vec![RuntimeDiagnostic {
code: code.to_string(),
severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(message),
}]
})
.unwrap_or_default()
}
_ => Vec::new(),
};
Self { error, diagnostics }
}
}
fn subjektiv_conflict_code(message: &str) -> Option<&'static str> {
["revision_conflict", "stale_cursor"]
.into_iter()
.find(|code| message.starts_with(code))
fn subjektiv_diagnostic_code(message: &str) -> Option<&'static str> {
[
"revision_conflict",
"stale_cursor",
"candidate_decision_conflict",
"subject_scope_mismatch",
]
.into_iter()
.find(|code| message.starts_with(code))
}
impl ApiError {
@@ -43413,31 +43466,42 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let request = |requested_worker_name: &str,
intent: server_api::RuntimeWorkerSpawnIntent,
profile: &str| server_api::RuntimeWorkerSpawnRequest {
requested_worker_name: Some(requested_worker_name.to_string()),
intent,
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string()),
acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: server_api::RuntimeProfileSelector::Builtin(profile.to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachment_requests: Vec::new(),
};
let request =
|requested_worker_name: &str,
intent: server_api::RuntimeWorkerSpawnIntent,
profile: &str,
singleton_key: &str| server_api::RuntimeWorkerSpawnRequest {
requested_worker_name: Some(requested_worker_name.to_string()),
intent,
singleton_key: Some(singleton_key.to_string()),
acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: server_api::RuntimeProfileSelector::Builtin(profile.to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachment_requests: Vec::new(),
};
let subject_consolidation_key = "subjektiv-consolidation:subject-forged";
for candidate in [
request(
"Generic key squatter",
server_api::RuntimeWorkerSpawnIntent::WorkspaceCompanion,
"builtin:companion",
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,
),
request(
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,
server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator,
"builtin:orchestrator",
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,
),
request(
SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE,
server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator,
"builtin:subjektiv-memory-consolidation",
subject_consolidation_key,
),
] {
let error = scoped_create_runtime_worker(
@@ -43461,6 +43525,12 @@ mod tests {
.unwrap()
.is_none()
);
assert!(
api.store
.current_worker_singleton_owner(TEST_WORKSPACE_ID, subject_consolidation_key)
.unwrap()
.is_none()
);
assert!(find_workspace_orchestrator(&api).is_none());
}
@@ -51157,6 +51227,118 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn subjektiv_consolidation_scope_rejects_non_dedicated_singleton_owner() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let spawn = |profile: &str, key: &str| WorkerSpawnRequest {
requested_worker_name: Some(profile.to_string()),
singleton_key: Some(key.to_string()),
intent: WorkerSpawnIntent::WorkspaceOrchestrator,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: ProfileSelector::Builtin(profile.to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_control_operation: None,
};
let dedicated_key = "subjektiv-consolidation:dedicated-subject";
let dedicated = api
.spawn_workspace_worker(
EMBEDDED_WORKER_RUNTIME_ID,
spawn(SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE, dedicated_key),
)
.unwrap()
.worker
.unwrap();
assert!(is_dedicated_subjektiv_consolidation_worker(
&dedicated,
TEST_WORKSPACE_ID,
dedicated_key
));
let dedicated_context = server_api::ServerRequestContext {
actor: None,
worker_source: None,
runtime_source: Some(server_api::ServerRuntimeSource {
runtime_id: dedicated.worker.runtime_id.clone(),
worker_id: Some(dedicated.worker.worker_id.clone()),
}),
origin: None,
transport_headers: Vec::new(),
};
assert_eq!(
subjektiv_subject_scope(&api, TEST_WORKSPACE_ID, &dedicated_context)
.unwrap()
.2,
SubjektivWorkerAuthority::Consolidation
);
let forged_key = "subjektiv-consolidation:forged-subject";
let forged = api
.spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn("companion", forged_key))
.unwrap()
.worker
.unwrap();
let forged_context = server_api::ServerRequestContext {
actor: None,
worker_source: None,
runtime_source: Some(server_api::ServerRuntimeSource {
runtime_id: forged.worker.runtime_id.clone(),
worker_id: Some(forged.worker.worker_id.clone()),
}),
origin: None,
transport_headers: Vec::new(),
};
let error = subjektiv_subject_scope(&api, TEST_WORKSPACE_ID, &forged_context).unwrap_err();
assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN);
assert!(
try_reuse_subject_consolidation_worker(
&api,
forged_key,
"process pending subject Memory",
1,
1,
)
.is_err()
);
}
#[test]
fn subjektiv_http_errors_emit_typed_retry_diagnostics() {
for (error, code, status) in [
(
Error::RepositoryConflict(
"candidate_decision_conflict: candidate already resolved".to_string(),
),
"candidate_decision_conflict",
StatusCode::CONFLICT,
),
(
Error::WorkspacePermissionDenied(
"subject_scope_mismatch: candidate belongs to another subject".to_string(),
),
"subject_scope_mismatch",
StatusCode::FORBIDDEN,
),
] {
assert_eq!(api_error_status(&error), status);
let response = ApiError::from(error).into_repository_api_error();
assert_eq!(response.diagnostics.len(), 1);
assert_eq!(response.diagnostics[0].code, code);
}
}
#[test]
fn subjektiv_singleton_scope_is_explicit_and_opaque() {
assert_eq!(
@@ -4,14 +4,40 @@ import "./base.dcdl" // {
scope = "workspace_read";
model = { ref = "codex-oauth/gpt-5.6-luna"; };
engine = { reasoning = "medium"; };
web = { enabled = false; };
feature = {
task = { enabled = false; };
memory = { enabled = true; staging_tools = true; };
memory = {
enabled = true;
staging_tools = true;
resident = { inject_summary = false; };
extraction = { enabled = false; };
consolidation = { request_enabled = false; };
};
subjektiv = { enabled = false; extraction = { enabled = false; }; };
web = { enabled = false; };
image = { enabled = false; };
sub_worker = { enabled = false; };
worker = { enabled = false; };
flow = { enabled = false; };
worker = { enabled = false; direct_spawn = false; };
workspace_worker_discovery = { enabled = false; };
objective = { enabled = false; };
ticket = { enabled = false; thread = false; };
manage_workdir = { enabled = false; };
ticket = {
enabled = false;
authoring = false;
thread = false;
intake = false;
workflow = false;
};
merge_request = {
show = false;
open = false;
review = false;
readiness_check = false;
complete = false;
};
orchestration = { enabled = false; };
};
}