fix: harden subject memory consolidation
This commit is contained in:
@@ -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]
|
#[test]
|
||||||
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
|
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
|
||||||
let tmp = TempDir::new().unwrap();
|
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> {
|
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||||
let mut names = definitions
|
let mut names = definitions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -991,6 +1024,39 @@ mod tests {
|
|||||||
assert!(names.contains(&"MemoryStagingClose".to_string()));
|
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]
|
#[test]
|
||||||
fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() {
|
fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() {
|
||||||
let tools = workspace_http_subjektiv_consolidation_tools(test_client());
|
let tools = workspace_http_subjektiv_consolidation_tools(test_client());
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ struct MemoryLifecycleTask {
|
|||||||
event_tx: Option<broadcast::Sender<Event>>,
|
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 {
|
impl MemoryLifecycleFeature {
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn from_resolved_config(
|
pub(crate) fn from_resolved_config(
|
||||||
@@ -93,10 +102,7 @@ impl MemoryLifecycleFeature {
|
|||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
event_tx: Option<broadcast::Sender<Event>>,
|
event_tx: Option<broadcast::Sender<Event>>,
|
||||||
) -> std::io::Result<Option<Self>> {
|
) -> std::io::Result<Option<Self>> {
|
||||||
if !lifecycle_enabled
|
if !memory_lifecycle_requested(lifecycle_enabled, &config) {
|
||||||
|| !config.profile.enabled
|
|
||||||
|| (!config.profile.extraction.enabled && !config.profile.consolidation.request_enabled)
|
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
config
|
config
|
||||||
@@ -1365,6 +1371,26 @@ permission = "write"
|
|||||||
config
|
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(
|
fn test_task(
|
||||||
capture: CommittedSessionCapture,
|
capture: CommittedSessionCapture,
|
||||||
client: Box<dyn LlmClient>,
|
client: Box<dyn LlmClient>,
|
||||||
|
|||||||
@@ -5370,6 +5370,7 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
|
|||||||
|
|
||||||
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||||
pub(crate) const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
pub(crate) const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
||||||
|
pub(crate) const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:";
|
||||||
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
|
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
|
||||||
pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-orchestrator";
|
pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-orchestrator";
|
||||||
|
|
||||||
@@ -5377,7 +5378,7 @@ pub(crate) fn is_reserved_internal_worker_singleton_key(key: &str) -> bool {
|
|||||||
matches!(
|
matches!(
|
||||||
key,
|
key,
|
||||||
MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
||||||
)
|
) || key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkerDisplayMetadata {
|
struct WorkerDisplayMetadata {
|
||||||
|
|||||||
@@ -16544,7 +16544,9 @@ async fn scoped_memory_backend_operation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:";
|
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> {
|
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
|
||||||
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
|
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)
|
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(
|
fn subjektiv_subject_scope(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -16616,6 +16650,14 @@ fn subjektiv_subject_scope(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?;
|
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))
|
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 MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||||
const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation";
|
|
||||||
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
||||||
|
|
||||||
async fn scoped_subjektiv_memory_consolidation(
|
async fn scoped_subjektiv_memory_consolidation(
|
||||||
@@ -18324,6 +18365,11 @@ fn try_reuse_subject_consolidation_worker(
|
|||||||
.worker(&owner.worker)
|
.worker(&owner.worker)
|
||||||
.map_err(|error| error.into_error())?;
|
.map_err(|error| error.into_error())?;
|
||||||
worker.singleton_key = Some(owner.key);
|
worker.singleton_key = Some(owner.key);
|
||||||
|
require_dedicated_subjektiv_consolidation_worker(
|
||||||
|
&worker,
|
||||||
|
&api.config.workspace_id,
|
||||||
|
singleton_key,
|
||||||
|
)?;
|
||||||
if worker.state != "idle" {
|
if worker.state != "idle" {
|
||||||
return Ok(Some(MemoryConsolidationOutput {
|
return Ok(Some(MemoryConsolidationOutput {
|
||||||
status: "skipped_existing_not_idle".into(),
|
status: "skipped_existing_not_idle".into(),
|
||||||
@@ -29621,25 +29667,32 @@ impl From<Error> for ApiError {
|
|||||||
severity: HostDiagnosticSeverity::Error,
|
severity: HostDiagnosticSeverity::Error,
|
||||||
message: sanitize_backend_error(&ticket_error.to_string()),
|
message: sanitize_backend_error(&ticket_error.to_string()),
|
||||||
}],
|
}],
|
||||||
Error::RepositoryConflict(message) => subjektiv_conflict_code(message)
|
Error::RepositoryConflict(message) | Error::WorkspacePermissionDenied(message) => {
|
||||||
.map(|code| {
|
subjektiv_diagnostic_code(message)
|
||||||
vec![RuntimeDiagnostic {
|
.map(|code| {
|
||||||
code: code.to_string(),
|
vec![RuntimeDiagnostic {
|
||||||
severity: HostDiagnosticSeverity::Error,
|
code: code.to_string(),
|
||||||
message: sanitize_backend_error(message),
|
severity: HostDiagnosticSeverity::Error,
|
||||||
}]
|
message: sanitize_backend_error(message),
|
||||||
})
|
}]
|
||||||
.unwrap_or_default(),
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
Self { error, diagnostics }
|
Self { error, diagnostics }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subjektiv_conflict_code(message: &str) -> Option<&'static str> {
|
fn subjektiv_diagnostic_code(message: &str) -> Option<&'static str> {
|
||||||
["revision_conflict", "stale_cursor"]
|
[
|
||||||
.into_iter()
|
"revision_conflict",
|
||||||
.find(|code| message.starts_with(code))
|
"stale_cursor",
|
||||||
|
"candidate_decision_conflict",
|
||||||
|
"subject_scope_mismatch",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|code| message.starts_with(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiError {
|
impl ApiError {
|
||||||
@@ -43413,31 +43466,42 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(dir.path());
|
init_clean_git_workspace(dir.path());
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
let request = |requested_worker_name: &str,
|
let request =
|
||||||
intent: server_api::RuntimeWorkerSpawnIntent,
|
|requested_worker_name: &str,
|
||||||
profile: &str| server_api::RuntimeWorkerSpawnRequest {
|
intent: server_api::RuntimeWorkerSpawnIntent,
|
||||||
requested_worker_name: Some(requested_worker_name.to_string()),
|
profile: &str,
|
||||||
intent,
|
singleton_key: &str| server_api::RuntimeWorkerSpawnRequest {
|
||||||
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string()),
|
requested_worker_name: Some(requested_worker_name.to_string()),
|
||||||
acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted {
|
intent,
|
||||||
expected_segments: 0,
|
singleton_key: Some(singleton_key.to_string()),
|
||||||
},
|
acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||||
profile: server_api::RuntimeProfileSelector::Builtin(profile.to_string()),
|
expected_segments: 0,
|
||||||
ticket_assignment: None,
|
},
|
||||||
initial_submit: Vec::new(),
|
profile: server_api::RuntimeProfileSelector::Builtin(profile.to_string()),
|
||||||
workdir_attachment_requests: Vec::new(),
|
ticket_assignment: None,
|
||||||
};
|
initial_submit: Vec::new(),
|
||||||
|
workdir_attachment_requests: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let subject_consolidation_key = "subjektiv-consolidation:subject-forged";
|
||||||
for candidate in [
|
for candidate in [
|
||||||
request(
|
request(
|
||||||
"Generic key squatter",
|
"Generic key squatter",
|
||||||
server_api::RuntimeWorkerSpawnIntent::WorkspaceCompanion,
|
server_api::RuntimeWorkerSpawnIntent::WorkspaceCompanion,
|
||||||
"builtin:companion",
|
"builtin:companion",
|
||||||
|
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,
|
||||||
),
|
),
|
||||||
request(
|
request(
|
||||||
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,
|
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,
|
||||||
server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator,
|
server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator,
|
||||||
"builtin:orchestrator",
|
"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(
|
let error = scoped_create_runtime_worker(
|
||||||
@@ -43461,6 +43525,12 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none()
|
.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());
|
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()
|
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]
|
#[test]
|
||||||
fn subjektiv_singleton_scope_is_explicit_and_opaque() {
|
fn subjektiv_singleton_scope_is_explicit_and_opaque() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -4,14 +4,40 @@ import "./base.dcdl" // {
|
|||||||
scope = "workspace_read";
|
scope = "workspace_read";
|
||||||
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
||||||
engine = { reasoning = "medium"; };
|
engine = { reasoning = "medium"; };
|
||||||
|
web = { enabled = false; };
|
||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = false; };
|
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; };
|
web = { enabled = false; };
|
||||||
|
image = { enabled = false; };
|
||||||
sub_worker = { 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; };
|
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; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user