feat: integrate standalone subjektiv support

This commit is contained in:
2026-10-07 02:04:30 +09:00
65 changed files with 18273 additions and 9785 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "feature-storage"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
rusqlite.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
tempfile.workspace = true
+28
View File
@@ -0,0 +1,28 @@
# feature-storage
Neutral Host-owned SQLite lifecycle manager for trusted Features. Owns placement,
WAL/connection settings, transaction serialization, migrations, snapshots,
restore/delete fencing, and shutdown. Features receive a managed
`FeatureDatabase` after registration. Worker/model input never selects a database
path or receives SQL access.
Use `FeatureStorage::new(trusted_root)`, `register(FeatureRegistration)`, and
`scope(host_issued_scope_id).open(&registration)`. `ScopedFeatureStorage` and
`FeatureDatabase::scope_id()` are neutral storage isolation APIs: a local Host's
persisted random scope does not create a Workspace record or imply Backend
membership. Existing placement `<root>/<scope>/features/<feature>.sqlite` and
snapshot `workspace=` keys remain unchanged for physical compatibility.
`WorkspaceFeatureStorage`, `workspace()`, `workspace_id()`, and
`for_server_database()` are Backend compatibility aliases/helpers. They do not
introduce a Server dependency. The Host is responsible for state-root privacy,
symlink/path validation, and external execution leases before constructing this
trusted manager.
`configure_connection(&rusqlite::Connection)` is reusable by other Host stores
(such as JobStore). It sets the five-second busy timeout, foreign keys ON, WAL,
and synchronous FULL before migrations/transactions. The Feature manager uses
this same helper; WAL lock-contention retries stay within the busy timeout.
Dependencies: `rusqlite` (existing workspace backup support), `thiserror`, `uuid`;
tests use `tempfile`.
File diff suppressed because it is too large Load Diff
+20
View File
@@ -59,6 +59,26 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
description: "Standalone Yoi coding profile.",
imports: DEFAULT_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:standalone-subjektiv"),
path: "profiles/standalone-subjektiv.dcdl",
source: include_str!("../../../resources/profiles/standalone-subjektiv.dcdl"),
description: "Standalone with an explicitly connected local Subject.",
imports: &[BuiltinProfileImport {
specifier: "./standalone.dcdl",
resolved_path: "profiles/standalone.dcdl",
}],
},
BuiltinProfileResource {
selector: Some("builtin:standalone-subjektiv-consolidation"),
path: "profiles/standalone-subjektiv-consolidation.dcdl",
source: include_str!("../../../resources/profiles/standalone-subjektiv-consolidation.dcdl"),
description: "Delegated local Subject Job policy.",
imports: &[BuiltinProfileImport {
specifier: "./job.dcdl",
resolved_path: "profiles/job.dcdl",
}],
},
BuiltinProfileResource {
selector: Some("builtin:coder"),
path: "profiles/coder.dcdl",
+19 -4
View File
@@ -189,7 +189,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 {
// Subject policy is Host-neutral. Only an already bound Backend snapshot
// requires Workspace authority; a local Host supplies its own live capability.
if feature.subjektiv.workspace_settings.is_some() {
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
}
if feature.merge_request.show
@@ -1257,11 +1259,21 @@ mod tests {
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "role-worker");
if entry.name == "job" {
if matches!(
entry.name.as_str(),
"job" | "standalone-subjektiv-consolidation"
) {
assert!(resolved.manifest.compaction.is_none());
assert!(resolved.manifest.scope.allow.is_empty());
assert!(resolved.manifest.delegation_scope.allow.is_empty());
assert_eq!(resolved.manifest.engine.instruction, "internal.job_system");
assert_eq!(
resolved.manifest.engine.instruction,
if entry.name == "job" {
"internal.job_system"
} else {
"internal.subjektiv_memory_consolidation_system"
}
);
continue;
}
let compaction = resolved.manifest.compaction.as_ref().unwrap();
@@ -1669,7 +1681,10 @@ mod tests {
panic!("unexpected error: {error}");
};
assert_eq!(target, ProfileExecutionTarget::Standalone);
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv));
assert!(
!requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv),
"subject policy alone is no longer Backend authority"
);
assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
+26
View File
@@ -50,6 +50,9 @@ pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire,
/// Stamped only by the operator-owned standalone in-process transport.
/// This is not a Backend Account or authority accepted from wire fields.
LocalOperator,
Account {
account_id: String,
},
@@ -86,6 +89,7 @@ impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
Self::UntrustedWire => "untrusted-wire".into(),
Self::LocalOperator => "local-operator".into(),
Self::Account { account_id } => format!("account:{account_id}"),
Self::Worker {
runtime_id,
@@ -2175,6 +2179,28 @@ mod tests {
);
}
#[test]
fn local_operator_source_cannot_be_granted_by_serialized_input() {
let method = Method::SubmitTracked {
input: vec![Segment::text("forged local operator")],
submission_request_id: "local-forged".into(),
source: AuthenticatedInputSource::LocalOperator,
};
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
assert!(matches!(
decoded,
Method::SubmitTracked {
source: AuthenticatedInputSource::UntrustedWire,
..
}
));
assert_ne!(
AuthenticatedInputSource::LocalOperator.namespace(),
AuthenticatedInputSource::UntrustedWire.namespace()
);
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
+1 -1
View File
@@ -211,7 +211,7 @@ impl FsStore {
}
}
fn parse_jsonl(
pub(crate) fn parse_jsonl(
content: &[u8],
session_id: SessionId,
segment_id: SegmentId,
+2
View File
@@ -40,6 +40,8 @@ pub struct LoggedWorkerSubject {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedSessionHistoryOrigin {
/// Human input verified by the standalone Host, without a Backend Account.
LocalHumanInput,
HumanInput {
account_id: String,
},
+167 -3
View File
@@ -208,6 +208,172 @@ pub fn read_session_public_index(
let segment_ids = store
.list_segments_read_only(session_id)
.map_err(map_store_error)?;
project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| {
store
.read_all_read_only_bounded(session_id, segment_id, remaining)
.map_err(map_store_error)
})
}
/// Strict committed public projection for the standalone FsStore layout.
/// Uses the same lineage, commit filtering and entry projection as retained
/// WorkerSessionStore observation. No writes, migrations, trace/artifact reads,
/// access-time updates or directory creation occur here.
pub fn read_fs_session_public_index(
sessions_root: &Path,
session_id: SessionId,
limits: SessionPublicIndexLimits,
) -> Result<SessionPublicIndex, SessionPublicIndexReadError> {
read_fs_session_evidence_index(sessions_root, session_id, limits).map(|value| value.index)
}
/// Producer-compatible numeric hints for stable public evidence references.
/// Both committed annotated-history and filtered public-snapshot capture are
/// valid producers. No hidden history content is exposed by this metadata.
pub struct FsSessionEvidenceIndex {
pub index: SessionPublicIndex,
pub ranges: HashMap<(String, String), Vec<[u64; 2]>>,
pub has_open_run: bool,
}
pub fn read_fs_session_evidence_index(
sessions_root: &Path,
session_id: SessionId,
limits: SessionPublicIndexLimits,
) -> Result<FsSessionEvidenceIndex, SessionPublicIndexReadError> {
if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
let session_dir = sessions_root.join(session_id.to_string());
// IDs are typed and every storage component is observationally validated;
// a known Session ID never authorizes following arbitrary local symlinks.
let mut prefix = std::path::PathBuf::new();
for part in session_dir.components() {
prefix.push(part);
let metadata =
std::fs::symlink_metadata(&prefix).map_err(|_| SessionPublicIndexReadError::Missing)?;
if metadata.file_type().is_symlink() {
return Err(SessionPublicIndexReadError::Storage);
}
}
let mut segment_ids = Vec::new();
for entry in
std::fs::read_dir(&session_dir).map_err(|_| SessionPublicIndexReadError::Missing)?
{
let entry = entry.map_err(|_| SessionPublicIndexReadError::Storage)?;
let path = entry.path();
if path.extension().and_then(|v| v.to_str()) != Some("jsonl") {
continue;
}
let Some(id) = path
.file_stem()
.and_then(|v| v.to_str())
.and_then(|v| v.parse::<SegmentId>().ok())
else {
continue;
};
if !entry
.file_type()
.map_err(|_| SessionPublicIndexReadError::Storage)?
.is_file()
{
return Err(SessionPublicIndexReadError::Storage);
}
segment_ids.push(id);
if segment_ids.len() > limits.max_segments {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
}
segment_ids.sort();
if segment_ids.is_empty() {
return Err(SessionPublicIndexReadError::Missing);
}
let mut ranges = HashMap::<(String, String), Vec<[u64; 2]>>::new();
let mut has_open_run = false;
let index =
project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| {
let path = session_dir.join(format!("{segment_id}.jsonl"));
let bytes = crate::read_without_atime_bounded(&path, remaining)
.map_err(|_| SessionPublicIndexReadError::Storage)?;
if bytes.len() as u64 > remaining {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
let entries = crate::FsStore::parse_jsonl(&bytes, session_id, segment_id)
.map_err(map_store_error)?;
let mut open = false;
for entry in &entries {
match entry {
LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } => open = true,
LogEntry::RunCompleted { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunCancelled { .. }
| LogEntry::RunErrored { .. }
| LogEntry::PausedTurnAbandoned { .. } => open = false,
_ => {}
}
}
has_open_run |= open;
let committed = committed_conversation_records(&entries);
let state = crate::segment_log::collect_state(&committed);
for (position, entry) in state.annotated_history.iter().enumerate() {
ranges
.entry((
segment_id.to_string(),
format!("E{}", entry.metadata.entry_id.0),
))
.or_default()
.push([position as u64, position as u64]);
}
let snapshot = crate::public_snapshot::project_session_snapshot_for_segment(
session_id,
Some(segment_id),
&committed,
);
let mut position = 0_u64;
for entry in snapshot.entries {
if matches!(
entry.data,
SessionSnapshotEntryData::UserInput { .. }
| SessionSnapshotEntryData::Message { .. }
| SessionSnapshotEntryData::ToolCall { .. }
| SessionSnapshotEntryData::ToolResult { .. }
) {
ranges
.entry((segment_id.to_string(), format!("E{}", entry.entry_id)))
.or_default()
.push([position, position]);
position += 1;
}
}
Ok((entries, bytes.len() as u64))
})?;
// Intersect with the strict public projection, never turn knowledge of a
// hidden/reasoning history identity into a public evidence reference.
let public = index
.segments
.iter()
.flat_map(|seg| {
seg.entries
.iter()
.map(|entry| (seg.segment_id.clone(), entry.entry_ref.clone()))
})
.collect::<HashSet<_>>();
ranges.retain(|key, _| public.contains(key));
Ok(FsSessionEvidenceIndex {
index,
ranges,
has_open_run,
})
}
fn project_public_index_records(
session_id: SessionId,
segment_ids: Vec<SegmentId>,
limits: SessionPublicIndexLimits,
mut read_segment: impl FnMut(
SegmentId,
u64,
) -> Result<(Vec<LogEntry>, u64), SessionPublicIndexReadError>,
) -> Result<SessionPublicIndex, SessionPublicIndexReadError> {
if segment_ids.len() > limits.max_segments {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
@@ -224,9 +390,7 @@ pub fn read_session_public_index(
.max_bytes
.checked_sub(scanned_bytes)
.ok_or(SessionPublicIndexReadError::ResourceLimit)?;
let (entries, bytes) = store
.read_all_read_only_bounded(session_id, segment_id, remaining)
.map_err(map_store_error)?;
let (entries, bytes) = read_segment(segment_id, remaining)?;
scanned_bytes = scanned_bytes
.checked_add(bytes)
.ok_or(SessionPublicIndexReadError::ResourceLimit)?;
+2 -1
View File
@@ -1654,7 +1654,8 @@ fn legacy_entry_id(
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
match origin {
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::HumanInput { .. }
| LoggedSessionHistoryOrigin::LocalHumanInput => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
SessionEntryProvenance::FlowInstruction
+6
View File
@@ -9,6 +9,12 @@ license.workspace = true
agen.workspace = true
client.workspace = true
fs4.workspace = true
feature-storage.workspace = true
subjektiv.workspace = true
memory.workspace = true
server-api.workspace = true
libc.workspace = true
async-trait.workspace = true
manifest.workspace = true
job.workspace = true
rusqlite.workspace = true
+219 -10
View File
@@ -44,12 +44,15 @@ pub struct StandaloneHost {
record: StandaloneWorkerRecord,
lease: Option<StandaloneWorkerLease>,
jobs: crate::jobs::StandaloneJobs,
subject: Option<crate::subjektiv::SubjectConnection>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StandaloneStartupError {
#[error("the standalone state store could not be opened or validated")]
StateStore,
#[error("local Subject connection rejected: {0}")]
Subject(String),
#[error("the standalone Worker is already active")]
WorkerActive,
#[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
@@ -107,16 +110,53 @@ impl StandaloneHost {
launch: ResolvedStandaloneLaunch,
model_client: Option<Box<dyn LlmClient>>,
) -> Result<Self, StandaloneStartupError> {
if launch.subject_id.is_some()
&& (!launch.profile.manifest.feature.subjektiv.profile.enabled
|| launch
.profile
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| launch
.profile
.manifest
.feature
.subjektiv
.workspace_settings
.is_some())
{
return Err(StandaloneStartupError::Subject(
"incompatible local Subject policy".into(),
));
}
let store =
StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
let allocation = store
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
.map_err(classify_store_startup_error)?;
let worker_id = allocation.worker_id();
let jobs = match crate::jobs::StandaloneJobs::open(
&store.jobs_path(worker_id),
launch.cwd.clone(),
) {
let mut subject = match launch
.subject_id
.as_deref()
.map(|id| {
crate::subjektiv::StandaloneSubjects::open_existing(&launch.state_dir)?
.connect(id, worker_id)
})
.transpose()
{
Ok(subject) => subject,
Err(error) => {
let _ = store.abandon_allocation(allocation);
return Err(StandaloneStartupError::Subject(error.to_string()));
}
};
let jobs_path = subject
.as_ref()
.map(|s| s.jobs_path.clone())
.unwrap_or_else(|| store.jobs_path(worker_id));
let jobs = match crate::jobs::StandaloneJobs::open(&jobs_path, launch.cwd.clone()) {
Ok(jobs) => jobs,
Err(_) => {
let _ = store.abandon_allocation(allocation);
@@ -124,6 +164,12 @@ impl StandaloneHost {
}
};
if let Some(client) = &model_client {
jobs.bind_model_client(client.clone_boxed());
}
if let Some(subject) = &subject {
subject.host.bind_jobs(jobs.clone())?;
}
// WorkerId is the stable identity. The current Worker store remains
// name-keyed, so keep its derived storage key separate from the
// user-facing profile name.
@@ -156,9 +202,15 @@ impl StandaloneHost {
},
WorkerControllerTransport::InProcess,
);
if let Some(subject) = &subject {
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
}
if let Some(model_client) = model_client {
bootstrap = bootstrap.with_model_client(model_client);
}
if let Some(subject) = &mut subject {
subject.arm();
}
let started = match bootstrap.start().await {
Ok(started) => started,
Err(error) => {
@@ -174,12 +226,13 @@ impl StandaloneHost {
return Err(error);
}
};
let record = match store.commit_created(
let record = match store.commit_created_connected(
&allocation,
manifest,
storage_key,
active.session_id,
active.segment_id,
subject.as_ref().map(|s| s.binding.clone()),
) {
Ok(record) => record,
Err(_) => {
@@ -195,6 +248,7 @@ impl StandaloneHost {
record,
allocation.into_lease(),
jobs,
subject,
))
}
@@ -230,11 +284,50 @@ impl StandaloneHost {
let lease = store
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
.map_err(classify_store_startup_error)?;
let jobs = crate::jobs::StandaloneJobs::open(
&store.jobs_path(worker_id),
record.cwd.canonical_path.clone(),
)
.map_err(|_| StandaloneStartupError::StateStore)?;
let mut subject = record
.subject
.as_ref()
.map(|binding| {
if !record.manifest.feature.subjektiv.profile.enabled
|| record
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| record
.manifest
.feature
.subjektiv
.workspace_settings
.is_some()
{
return Err(crate::subjektiv::SubjectError::InvalidScope(
"persisted Subject policy changed".into(),
));
}
let catalog = crate::subjektiv::StandaloneSubjects::open_existing(store.root())?;
if catalog.scope_id() != binding.scope_id {
return Err(crate::subjektiv::SubjectError::InvalidScope(
"persisted local storage scope changed".into(),
));
}
catalog.connect(&binding.subject_id, worker_id)
})
.transpose()
.map_err(|e| StandaloneStartupError::Subject(e.to_string()))?;
let jobs_path = subject
.as_ref()
.map(|s| s.jobs_path.clone())
.unwrap_or_else(|| store.jobs_path(worker_id));
let jobs = crate::jobs::StandaloneJobs::open(&jobs_path, record.cwd.canonical_path.clone())
.map_err(|_| StandaloneStartupError::StateStore)?;
if let Some(client) = &model_client {
jobs.bind_model_client(client.clone_boxed());
}
if let Some(subject) = &subject {
subject.host.bind_jobs(jobs.clone())?;
}
let (backing_store, worker_store) = backing_store(&store, worker_id)?;
let storage_key = record.storage_key.clone();
let mut manifest = record.manifest.clone();
@@ -259,6 +352,9 @@ impl StandaloneHost {
},
WorkerControllerTransport::InProcess,
);
if let Some(subject) = &subject {
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
}
if let Some(model_client) = model_client {
bootstrap = bootstrap.with_model_client(model_client);
}
@@ -266,6 +362,9 @@ impl StandaloneHost {
.prepare_restored(&storage_key)
.await
.map_err(classify_startup_error)?;
if let Some(subject) = &mut subject {
subject.arm();
}
let started = prepared.start().await.map_err(classify_startup_error)?;
let active = match active_pointer(&worker_store, &storage_key) {
Ok(active) => active,
@@ -290,6 +389,7 @@ impl StandaloneHost {
record,
lease,
jobs,
subject,
))
}
@@ -300,7 +400,11 @@ impl StandaloneHost {
record: StandaloneWorkerRecord,
lease: StandaloneWorkerLease,
jobs: crate::jobs::StandaloneJobs,
subject: Option<crate::subjektiv::SubjectConnection>,
) -> Self {
if let Some(connection) = &subject {
connection.host.recover_jobs();
}
Self {
handle: started.handle,
shutdown: Some(started.shutdown),
@@ -310,6 +414,7 @@ impl StandaloneHost {
record,
lease: Some(lease),
jobs,
subject,
}
}
@@ -388,6 +493,12 @@ impl StandaloneHost {
self.retain_lease();
return Err(StandaloneShutdownError::StateStore);
}
if let Some(subject) = &mut self.subject {
if subject.close().is_err() {
self.retain_lease();
return Err(StandaloneShutdownError::StateStore);
}
}
if let Some(lease) = self.lease.take() {
lease
.release()
@@ -397,6 +508,9 @@ impl StandaloneHost {
}
fn retain_lease(&mut self) {
if let Some(subject) = &mut self.subject {
subject.retain();
}
if let Some(lease) = self.lease.take() {
lease.retain();
}
@@ -413,6 +527,51 @@ impl Drop for StandaloneHost {
}
}
// The in-process channel is owned by the local operator's Host, not an
// arbitrary socket or Backend account. decode_method discards wire provenance;
// only this boundary can stamp accepted local human input.
fn authorize_local_input(method: protocol::Method) -> protocol::Method {
use protocol::{AuthenticatedInputSource, Method};
match method {
Method::Submit {
submission_request_id,
input,
}
| Method::SubmitTracked {
submission_request_id,
input,
..
} => Method::SubmitTracked {
submission_request_id,
input,
source: AuthenticatedInputSource::LocalOperator,
},
Method::SubmitIfIdle {
submission_request_id,
input,
..
} => Method::SubmitIfIdle {
submission_request_id,
input,
source: AuthenticatedInputSource::LocalOperator,
},
Method::Notify {
notification_request_id,
message,
}
| Method::NotifyTracked {
notification_request_id,
message,
..
} => Method::NotifyTracked {
notification_request_id,
message,
source: AuthenticatedInputSource::LocalOperator,
},
method => method,
}
}
async fn run_protocol_session(
handle: worker::WorkerHandle,
streams: WorkerProtocolSessionStreams,
@@ -438,6 +597,7 @@ async fn run_protocol_session(
let Ok(method) = decode_method(&message) else {
return;
};
let method = authorize_local_input(method);
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
&& !send_protocol_event(&peer, event).await
{
@@ -578,6 +738,9 @@ fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError
WorkerBootstrapError::Worker(WorkerError::Provider(_)) => {
StandaloneStartupError::ModelProvider
}
WorkerBootstrapError::Worker(WorkerError::SubjektivSessionAttribution {
message, ..
}) => StandaloneStartupError::Subject(message),
WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration,
WorkerBootstrapError::Controller { source, .. }
if source.kind() == std::io::ErrorKind::Other =>
@@ -587,3 +750,49 @@ fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError
WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller,
}
}
#[cfg(test)]
mod local_input_tests {
use super::*;
use protocol::{AuthenticatedInputSource, Method};
#[test]
fn local_transport_stamps_operator_without_backend_account() {
let source = AuthenticatedInputSource::Account {
account_id: "forged-account".into(),
};
for method in [
Method::submit_text("plain", "hello"),
Method::SubmitTracked {
submission_request_id: "tracked".into(),
input: vec![],
source: source.clone(),
},
Method::SubmitIfIdle {
submission_request_id: "idle".into(),
input: vec![],
source: source.clone(),
},
Method::Notify {
notification_request_id: "plain-notify".into(),
message: "hello".into(),
},
Method::NotifyTracked {
notification_request_id: "tracked-notify".into(),
message: "hello".into(),
source,
},
] {
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
match authorize_local_input(decoded) {
Method::SubmitTracked { source, .. }
| Method::SubmitIfIdle { source, .. }
| Method::NotifyTracked { source, .. } => {
assert_eq!(source, AuthenticatedInputSource::LocalOperator)
}
other => panic!("unexpected local input {other:?}"),
}
}
}
}
+73 -7
View File
@@ -6,7 +6,6 @@
//! authority. The service owns the connection mutex and live-resource cleanup.
use std::path::Path;
use std::time::Duration;
use job::{JobRequest, JobResultSubmission};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
@@ -14,7 +13,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
const SCHEMA_VERSION: i64 = 1;
const SCHEMA_VERSION: i64 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -57,6 +56,8 @@ impl JobAttemptState {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobSnapshot {
pub request: JobRequest,
/// Durable Host-issued domain delegation, not model input or Profile identity.
pub domain_grant: Option<Value>,
pub state: JobState,
pub attempt: JobAttempt,
pub result: Option<Value>,
@@ -112,6 +113,8 @@ pub struct JobAttempt {
pub enum JobStoreError {
#[error("Job database: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error(transparent)]
Storage(#[from] feature_storage::FeatureStorageError),
#[error("Job JSON: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
@@ -151,16 +154,30 @@ impl JobStore {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
}
let mut conn = Connection::open(path)?;
conn.busy_timeout(Duration::from_secs(5))?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "FULL")?;
feature_storage::configure_connection(&conn)?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let version: i64 = tx.pragma_query_value(None, "user_version", |row| row.get(0))?;
match version {
0 => {
tx.execute_batch(SCHEMA)?;
tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
}
1 => {
tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
}
SCHEMA_VERSION => {}
@@ -186,7 +203,17 @@ impl JobStore {
}
pub(crate) fn reserve(&mut self, request: JobRequest) -> Result<JobSnapshot, JobStoreError> {
self.reserve_granted(request, None)
}
pub(crate) fn reserve_granted(
&mut self,
request: JobRequest,
grant: Option<Value>,
) -> Result<JobSnapshot, JobStoreError> {
request.validate()?;
if let Some(value) = &grant {
job::result_digest(value, job::ABSOLUTE_MAX_RESULT_BYTES)?;
}
let fingerprint = request.fingerprint()?;
let request_json = serde_json::to_string(&request)?;
let tx = self
@@ -198,7 +225,10 @@ impl JobStore {
[&request.job_id],
|row| row.get(0),
)?;
if stored != fingerprint || snapshot.request != request {
if stored != fingerprint
|| snapshot.request != request
|| snapshot.domain_grant != grant
{
return Err(JobStoreError::IntentConflict(request.job_id));
}
tx.commit()?;
@@ -219,6 +249,12 @@ impl JobStore {
],
)?;
insert_attempt(&tx, &request, 1)?;
if let Some(grant) = grant {
tx.execute(
"INSERT INTO job_domain_grants(job_id, grant_json) VALUES (?1, ?2)",
params![request.job_id, serde_json::to_string(&grant)?],
)?;
}
let snapshot = require_snapshot(&tx, &request.job_id)?;
tx.commit()?;
Ok(snapshot)
@@ -255,6 +291,16 @@ impl JobStore {
require_snapshot(&self.conn, job_id)
}
pub(crate) fn unacknowledged(&self) -> Result<Vec<JobSnapshot>, JobStoreError> {
let mut statement = self.conn.prepare("SELECT job_id FROM job_intents WHERE state = 'completed' AND acknowledged = 0 ORDER BY rowid")?;
let ids = statement
.query_map([], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
ids.into_iter()
.map(|id| require_snapshot(&self.conn, &id))
.collect()
}
/// Only unstarted current attempts are eligible for service startup/resume.
pub(crate) fn pending(&self) -> Result<Vec<JobSnapshot>, JobStoreError> {
let mut statement = self.conn.prepare(
@@ -570,6 +616,15 @@ fn read_snapshot(conn: &Connection, job_id: &str) -> Result<Option<JobSnapshot>,
};
Ok(JobSnapshot {
request: serde_json::from_str(&request)?,
domain_grant: conn
.query_row(
"SELECT grant_json FROM job_domain_grants WHERE job_id = ?1",
[job_id],
|row| row.get::<_, String>(0),
)
.optional()?
.map(|s| serde_json::from_str(&s))
.transpose()?,
state,
acknowledged,
attempt: JobAttempt {
@@ -585,6 +640,17 @@ fn read_snapshot(conn: &Connection, job_id: &str) -> Result<Option<JobSnapshot>,
.transpose()
}
const GRANT_SCHEMA: &str = "
CREATE TABLE job_domain_grants (
job_id TEXT PRIMARY KEY NOT NULL REFERENCES job_intents(job_id),
grant_json TEXT NOT NULL
);
CREATE TRIGGER job_grant_immutable BEFORE UPDATE ON job_domain_grants
BEGIN SELECT RAISE(ABORT, 'immutable Job domain grant'); END;
CREATE TRIGGER job_grant_retained BEFORE DELETE ON job_domain_grants
BEGIN SELECT RAISE(ABORT, 'retained Job domain grant'); END;
";
const SCHEMA: &str = "
CREATE TABLE job_intents (
job_id TEXT PRIMARY KEY NOT NULL,
+119 -4
View File
@@ -27,6 +27,17 @@ pub enum JobServiceError {
Cleanup(String),
}
pub(crate) trait JobAttemptFence: Send + Sync {
fn ensure_live(&self) -> Result<(), String>;
}
pub(crate) trait JobDomainProvider: Send + Sync {
fn grant(
&self,
snapshot: &JobSnapshot,
fence: Arc<dyn JobAttemptFence>,
) -> Result<worker::job::JobFeatureGrant, String>;
}
struct Execution {
cancel: watch::Sender<bool>,
complete: watch::Receiver<Option<Result<(), String>>>,
@@ -48,6 +59,8 @@ pub struct StandaloneJobs {
executions: Arc<Mutex<Executions>>,
cwd: PathBuf,
capacity: u16,
domain: Arc<Mutex<Option<Arc<dyn JobDomainProvider>>>>,
model_client: Arc<Mutex<Option<Box<dyn LlmClient>>>>,
}
impl StandaloneJobs {
@@ -60,9 +73,43 @@ impl StandaloneJobs {
})),
cwd,
capacity: job::DEFAULT_MAX_CONCURRENT_JOBS,
domain: Arc::default(),
model_client: Arc::default(),
})
}
pub(crate) fn bind_model_client(&self, client: Box<dyn LlmClient>) {
*self
.model_client
.lock()
.expect("Job model transport poisoned") = Some(client);
}
pub(crate) fn bind_domain(
&self,
provider: Arc<dyn JobDomainProvider>,
) -> Result<(), JobServiceError> {
let mut domain = self.domain.lock().expect("Job domain poisoned");
if domain.is_some() {
return Err(JobServiceError::Configuration(
"Job domain already bound".into(),
));
}
*domain = Some(provider);
Ok(())
}
pub(crate) fn request_granted(
&self,
request: JobRequest,
grant: serde_json::Value,
) -> Result<JobSnapshot, JobServiceError> {
let executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
self.with_store(|store| store.reserve_granted(request, Some(grant)))
}
fn with_store<T>(
&self,
operation: impl FnOnce(&mut JobStore) -> Result<T, JobStoreError>,
@@ -91,6 +138,10 @@ impl StandaloneJobs {
Ok(self.with_store(|store| store.pending())?)
}
pub fn unacknowledged_results(&self) -> Result<Vec<JobSnapshot>, JobServiceError> {
self.with_store(|store| store.unacknowledged())
}
/// Explicit new attempt, bounded by immutable intent limits. Unknown
/// outcomes cannot be retried without domain reconciliation/new intent.
pub fn retry(&self, job_id: &str) -> Result<JobSnapshot, JobServiceError> {
@@ -154,9 +205,40 @@ impl StandaloneJobs {
job_id: &str,
client: Option<Box<dyn LlmClient>>,
) -> Result<(), JobServiceError> {
let client = client.or_else(|| {
self.model_client
.lock()
.expect("Job model transport poisoned")
.as_ref()
.map(|c| c.clone_boxed())
});
let snapshot = self.get(job_id)?;
let prepared = worker::job::prepare_job(&snapshot.request, &self.cwd, client)
.map_err(|error| JobServiceError::Configuration(error.to_string()))?;
let timeout = Duration::from_secs(u64::from(snapshot.request.limits.timeout_seconds));
let deadline = tokio::time::Instant::now() + timeout;
let fence: Arc<dyn JobAttemptFence> = Arc::new(BoundAttemptFence {
store: self.store.clone(),
job_id: job_id.into(),
attempt_id: snapshot.attempt.attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
deadline,
});
let grant = match (
&snapshot.domain_grant,
self.domain.lock().expect("Job domain poisoned").as_ref(),
) {
(Some(_), Some(provider)) => provider
.grant(&snapshot, fence)
.map_err(JobServiceError::Configuration)?,
(Some(_), None) => {
return Err(JobServiceError::Configuration(
"persisted Job requires an unavailable explicit domain capability".into(),
));
}
(None, _) => worker::job::JobFeatureGrant::default(),
};
let prepared =
worker::job::prepare_job_with_grant(&snapshot.request, &self.cwd, client, grant)
.map_err(|error| JobServiceError::Configuration(error.to_string()))?;
let mut executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
@@ -209,13 +291,12 @@ impl StandaloneJobs {
let store = self.store.clone();
let id = job_id.to_string();
let attempt_id = snapshot.attempt.attempt_id.clone();
let timeout = Duration::from_secs(u64::from(snapshot.request.limits.timeout_seconds));
let sink = Arc::new(BoundResultSink {
store: store.clone(),
job_id: id.clone(),
attempt_id: attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
deadline: tokio::time::Instant::now() + timeout,
deadline,
});
let (cancel, mut cancellation) = watch::channel(false);
let (complete_tx, complete) = watch::channel(None);
@@ -341,6 +422,11 @@ impl StandaloneJobs {
// Close SQLite before the Host releases its lease. Cloned handles may
// outlive the Host but cannot mutate or retain the live connection.
self.store.lock().expect("Job store poisoned").take();
self.domain.lock().expect("Job domain poisoned").take();
self.model_client
.lock()
.expect("Job model transport poisoned")
.take();
error.map_or(Ok(()), Err)
}
}
@@ -391,3 +477,32 @@ impl worker::job::JobResultSink for BoundResultSink {
.map_err(|error| error.to_string())
}
}
struct BoundAttemptFence {
store: Arc<Mutex<Option<JobStore>>>,
job_id: String,
attempt_id: String,
input_revision: String,
deadline: tokio::time::Instant,
}
impl JobAttemptFence for BoundAttemptFence {
fn ensure_live(&self) -> Result<(), String> {
if tokio::time::Instant::now() >= self.deadline {
return Err("Job domain deadline expired".into());
}
let slot = self.store.lock().map_err(|_| "Job store poisoned")?;
let snapshot = slot
.as_ref()
.ok_or("Job Host closed")?
.get(&self.job_id)
.map_err(|e| e.to_string())?;
if snapshot.state != JobState::Pending
|| snapshot.attempt.state != JobAttemptState::Dispatched
|| snapshot.attempt.attempt_id != self.attempt_id
|| snapshot.request.input_revision != self.input_revision
{
return Err("Job domain grant is not bound to the current live attempt".into());
}
Ok(())
}
}
+345
View File
@@ -1,4 +1,10 @@
use super::*;
use agen::llm_client::{ClientError, Request, event::Event};
use async_trait::async_trait;
use futures::Stream;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::pin::Pin;
use worker::job::JobResultSink;
fn request(id: &str) -> JobRequest {
@@ -32,6 +38,345 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
}
}
#[derive(Clone, Default)]
struct DomainTestClient {
scripts: Arc<Mutex<VecDeque<Vec<Event>>>>,
requests: Arc<Mutex<Vec<Request>>>,
entered: Arc<tokio::sync::Notify>,
stall: bool,
failure_release: Option<Arc<tokio::sync::Notify>>,
}
impl DomainTestClient {
fn success() -> Self {
Self {
scripts: Arc::new(Mutex::new(VecDeque::from([
vec![
Event::tool_use_start(0, "result-1", "SubmitJobResult"),
Event::tool_input_delta(0, r#"{"result":{"valid":true}}"#),
Event::tool_use_stop(0),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "done"),
Event::text_block_stop(0, None),
],
]))),
..Default::default()
}
}
}
#[async_trait]
impl LlmClient for DomainTestClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
self.requests.lock().unwrap().push(request);
self.entered.notify_one();
if let Some(release) = &self.failure_release {
release.notified().await;
return Err(ClientError::Config(
"known scripted provider failure".into(),
));
}
if self.stall {
return Ok(Box::pin(futures::stream::pending()));
}
let events = self
.scripts
.lock()
.unwrap()
.pop_front()
.expect("unexpected model turn");
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
#[derive(Default)]
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
fn domain_grant(id: &str) -> Value {
json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"})
}
fn domain_request(id: &str) -> JobRequest {
let mut intent = request(id);
intent.profile = "builtin:standalone-subjektiv-consolidation".into();
intent
}
impl JobDomainProvider for RecordingDomainProvider {
fn grant(
&self,
snapshot: &JobSnapshot,
fence: Arc<dyn JobAttemptFence>,
) -> Result<worker::job::JobFeatureGrant, String> {
if snapshot.domain_grant != Some(domain_grant(&snapshot.request.job_id)) {
return Err("foreign or mismatched immutable domain grant".into());
}
self.0.lock().unwrap().push((snapshot.clone(), fence));
Ok(worker::job::JobFeatureGrant {
satisfied_requirements: vec!["feature.subjektiv"],
..Default::default()
})
}
}
async fn await_model(client: &DomainTestClient) {
tokio::time::timeout(Duration::from_secs(2), client.entered.notified())
.await
.unwrap();
}
async fn await_job(jobs: &StandaloneJobs, id: &str) -> JobSnapshot {
tokio::time::timeout(Duration::from_secs(3), jobs.wait(id))
.await
.unwrap()
.unwrap()
}
#[tokio::test]
async fn pending_domain_grants_survive_service_reopen_and_remain_immutable() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("jobs.sqlite3");
let jobs = StandaloneJobs::open(&path, temp.path().to_path_buf()).unwrap();
let intent = domain_request("granted");
let grant = domain_grant("granted");
let reserved = jobs.request_granted(intent.clone(), grant.clone()).unwrap();
let root = jobs.request(request("root")).unwrap();
assert_eq!(reserved.domain_grant, Some(grant.clone()));
assert!(root.domain_grant.is_none());
assert_eq!(
jobs.request_granted(intent.clone(), grant.clone()).unwrap(),
reserved
);
assert!(matches!(
jobs.request(intent.clone()),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
assert!(matches!(
jobs.request_granted(intent.clone(), domain_grant("foreign")),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
assert!(matches!(
jobs.request_granted(request("root"), grant.clone()),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
assert_eq!(jobs.pending().unwrap().len(), 2);
jobs.shutdown().await.unwrap();
let reopened = StandaloneJobs::open(&path, temp.path().to_path_buf()).unwrap();
let pending = reopened.pending().unwrap();
assert_eq!(pending.len(), 2);
assert!(pending.contains(&reserved));
assert!(pending.contains(&root));
assert_eq!(reopened.get("granted").unwrap(), reserved);
assert_eq!(
reopened
.request_granted(intent.clone(), grant.clone())
.unwrap(),
reserved
);
assert!(matches!(
reopened.request_granted(intent, json!({"different":true})),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
let provider = Arc::new(RecordingDomainProvider::default());
reopened.bind_domain(provider.clone()).unwrap();
reopened
.start_with_model_client("granted", DomainTestClient::success())
.await
.unwrap();
let completed = await_job(&reopened, "granted").await;
assert_eq!(completed.state, JobState::Completed);
assert_eq!(completed.domain_grant, Some(grant));
assert_eq!(completed.result, Some(json!({"valid":true})));
let captured = provider.0.lock().unwrap()[0].0.clone();
assert_eq!(
captured, reserved,
"provider receives the persisted immutable intent and grant"
);
reopened.shutdown().await.unwrap();
}
#[tokio::test]
async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_from_profile() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let granted = jobs
.request_granted(domain_request("granted"), domain_grant("granted"))
.unwrap();
let client = DomainTestClient::success();
let error = jobs
.start_with_model_client("granted", client.clone())
.await
.unwrap_err();
assert!(matches!(error, JobServiceError::Configuration(_)));
assert!(
error
.to_string()
.contains("unavailable explicit domain capability"),
"{error}"
);
assert_eq!(jobs.get("granted").unwrap(), granted);
assert!(client.requests.lock().unwrap().is_empty());
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
assert!(matches!(
jobs.bind_domain(provider.clone()),
Err(JobServiceError::Configuration(_))
));
let ungranted = jobs.request(domain_request("ungranted")).unwrap();
let error = jobs
.start_with_model_client("ungranted", client.clone())
.await
.unwrap_err();
assert!(matches!(error, JobServiceError::Configuration(_)));
assert!(error.to_string().contains("feature.subjektiv"), "{error}");
assert_eq!(jobs.get("ungranted").unwrap(), ungranted);
assert!(
provider.0.lock().unwrap().is_empty(),
"bound provider is not a profile-derived grant"
);
assert!(client.requests.lock().unwrap().is_empty());
jobs.shutdown().await.unwrap();
}
#[tokio::test]
async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
for (id, grant) in [
("foreign-job", domain_grant("another-job")),
(
"foreign-revision",
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}),
),
(
"foreign-consumer",
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}),
),
] {
let reserved = jobs.request_granted(domain_request(id), grant).unwrap();
let client = DomainTestClient::success();
let error = jobs
.start_with_model_client(id, client.clone())
.await
.unwrap_err();
assert!(matches!(error, JobServiceError::Configuration(_)));
assert!(
error.to_string().contains("foreign or mismatched"),
"{error}"
);
assert_eq!(jobs.get(id).unwrap(), reserved);
assert!(client.requests.lock().unwrap().is_empty());
}
assert!(provider.0.lock().unwrap().is_empty());
jobs.shutdown().await.unwrap();
}
#[tokio::test]
async fn domain_fence_is_live_only_for_current_dispatched_attempt_and_revoked_on_cancel() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
let reserved = jobs
.request_granted(domain_request("fenced"), domain_grant("fenced"))
.unwrap();
let failure_release = Arc::new(tokio::sync::Notify::new());
let first = DomainTestClient {
failure_release: Some(failure_release.clone()),
..Default::default()
};
jobs.start_with_model_client("fenced", first.clone())
.await
.unwrap();
await_model(&first).await;
let old_fence = provider.0.lock().unwrap()[0].1.clone();
old_fence.ensure_live().unwrap();
failure_release.notify_one();
let failed = await_job(&jobs, "fenced").await;
assert_eq!(failed.state, JobState::Failed);
assert!(failed.result.is_none());
assert!(old_fence.ensure_live().is_err());
// Only known failure permits a retry; cancellation remains terminal.
let retried = jobs.retry("fenced").unwrap();
assert_ne!(retried.attempt.attempt_id, reserved.attempt.attempt_id);
assert_eq!(retried.domain_grant, reserved.domain_grant);
assert!(old_fence.ensure_live().is_err());
let second = DomainTestClient {
stall: true,
..Default::default()
};
jobs.start_with_model_client("fenced", second.clone())
.await
.unwrap();
await_model(&second).await;
let current_fence = provider.0.lock().unwrap()[1].1.clone();
current_fence.ensure_live().unwrap();
assert!(
old_fence.ensure_live().is_err(),
"a new attempt must not revive a stale capability"
);
let cancelled = jobs.cancel("fenced").unwrap();
assert_eq!(cancelled.state, JobState::Cancelled);
assert!(
current_fence.ensure_live().is_err(),
"durable cancellation revokes current domain operations immediately"
);
assert!(old_fence.ensure_live().is_err());
let cleaned = await_job(&jobs, "fenced").await;
assert_eq!(cleaned.state, JobState::Cancelled);
assert!(cleaned.result.is_none());
assert!(
jobs.retry("fenced").is_err(),
"cancelled intent is not a known retryable failure"
);
jobs.shutdown().await.unwrap();
assert!(old_fence.ensure_live().is_err());
assert!(current_fence.ensure_live().is_err());
}
#[tokio::test]
async fn bound_domain_does_not_change_root_generic_result_only_execution() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
let root = jobs.request(request("root")).unwrap();
assert!(root.domain_grant.is_none());
let client = DomainTestClient::success();
jobs.start_with_model_client("root", client.clone())
.await
.unwrap();
let completed = await_job(&jobs, "root").await;
assert_eq!(completed.state, JobState::Completed);
assert_eq!(completed.result, Some(json!({"valid":true})));
assert!(completed.domain_grant.is_none());
assert!(provider.0.lock().unwrap().is_empty());
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
for sent in requests.iter() {
assert_eq!(
sent.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
["SubmitJobResult"]
);
assert_eq!(sent.config.max_tokens, Some(worker::job::MAX_JOB_TOKENS));
}
drop(requests);
jobs.shutdown().await.unwrap();
}
#[tokio::test]
async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() {
let temp = tempfile::tempdir().unwrap();
+32
View File
@@ -15,6 +15,7 @@ pub struct StandaloneLaunchConfig {
pub state_dir: PathBuf,
pub profile: ProfileSelector,
pub worker_name: String,
pub subject_id: Option<String>,
}
pub struct ResolvedStandaloneLaunch {
@@ -22,6 +23,7 @@ pub struct ResolvedStandaloneLaunch {
pub state_dir: PathBuf,
pub profile: ResolvedProfile,
pub prompt_catalog: PromptCatalogSource,
pub subject_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
@@ -32,6 +34,10 @@ pub enum StandaloneLaunchError {
PathProfileUnsupported,
#[error("the standalone profile could not be resolved")]
ProfileResolutionFailed,
#[error(
"local Subject selection requires an enabled, non-consolidator subjektiv Profile and canonical Subject id"
)]
InvalidSubjectPolicy,
}
impl StandaloneLaunchConfig {
@@ -46,9 +52,15 @@ impl StandaloneLaunchConfig {
state_dir: state_dir.into(),
profile,
worker_name: worker_name.into(),
subject_id: None,
}
}
pub fn with_subject(mut self, subject_id: String) -> Self {
self.subject_id = Some(subject_id);
self
}
/// Resolve only built-in/XDG profile authority and bind standalone scope
/// to the canonical process cwd. Repository-local profile discovery is
/// deliberately not part of this path.
@@ -74,12 +86,32 @@ impl StandaloneLaunchConfig {
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
validate_profile_execution_target(&profile.manifest, ProfileExecutionTarget::Standalone)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
if let Some(id) = &self.subject_id {
if !crate::subjektiv::valid_subject_id(id)
|| !profile.manifest.feature.subjektiv.profile.enabled
|| profile
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| profile
.manifest
.feature
.subjektiv
.workspace_settings
.is_some()
{
return Err(StandaloneLaunchError::InvalidSubjectPolicy);
}
}
Ok(ResolvedStandaloneLaunch {
cwd,
state_dir: self.state_dir,
profile,
prompt_catalog: PromptCatalogSource::builtins_only(),
subject_id: self.subject_id,
})
}
}
+2
View File
@@ -9,6 +9,8 @@ mod job_store;
pub mod jobs;
pub mod launch;
pub mod store;
pub mod subjektiv;
mod subjektiv_sessions;
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
+34
View File
@@ -87,6 +87,9 @@ pub struct StandaloneWorkerRecord {
pub storage_key: String,
pub cwd: StandaloneCwdIdentity,
pub manifest: WorkerManifest,
/// Original explicit local Subject/storage binding; never inferred from policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<crate::subjektiv::StandaloneSubjectBinding>,
pub active_session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_segment_id: Option<SegmentId>,
@@ -127,6 +130,16 @@ impl StandaloneWorkerStore {
Ok(Self { root })
}
/// Observational constructor: never creates a missing root or repairs state.
pub fn open_existing(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
let root = root.into();
let metadata = fs::symlink_metadata(&root).map_err(StandaloneStoreError::Io)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(StandaloneStoreError::NotDirectory);
}
Ok(Self { root })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
@@ -158,6 +171,25 @@ impl StandaloneWorkerStore {
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
self.commit_created_connected(
allocation,
manifest,
storage_key,
active_session_id,
active_segment_id,
None,
)
}
pub(crate) fn commit_created_connected(
&self,
allocation: &StandaloneWorkerAllocation,
manifest: WorkerManifest,
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
subject: Option<crate::subjektiv::StandaloneSubjectBinding>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let now = now_unix_ms()?;
let record = StandaloneWorkerRecord {
@@ -168,6 +200,7 @@ impl StandaloneWorkerStore {
storage_key,
cwd: allocation.cwd.clone(),
manifest,
subject,
active_session_id,
active_segment_id,
status: StandaloneWorkerStatus::Active,
@@ -793,6 +826,7 @@ permission = "write"
inode: None,
},
manifest,
subject: None,
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
active_segment_id: None,
status: StandaloneWorkerStatus::Stopped,
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
use super::*;
#[test]
fn catalog_reopen_preserves_random_scope_subject_and_behavior() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("state");
let catalog = StandaloneSubjects::open(&root).unwrap();
let subject = catalog
.create(
"continuing-owner",
Some("Keep user behavior separate from Memory."),
)
.unwrap();
let scope = catalog.scope_id().to_owned();
assert!(uuid::Uuid::parse_str(&scope).is_ok());
assert!(uuid::Uuid::parse_str(subject.id.strip_prefix("subject-").unwrap()).is_ok());
assert_ne!(scope, subject.id);
drop(catalog);
let reopened = StandaloneSubjects::open_existing(&root).unwrap();
assert_eq!(reopened.scope_id(), scope);
assert_eq!(reopened.list().unwrap(), vec![subject]);
}
#[test]
fn implicit_open_does_not_create_subject_storage() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("missing");
assert!(StandaloneSubjects::open_existing(&root).is_err());
assert!(!root.exists());
assert!(StandaloneSubjects::open_existing(temp.path()).is_err());
assert!(!temp.path().join("subjektiv").exists());
}
#[test]
fn subject_kernel_lease_is_not_worker_or_profile_scoped() {
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let a = catalog.create("a", None).unwrap();
let b = catalog.create("b", None).unwrap();
let mut first = catalog
.connect(&a.id, protocol::WorkerId::now_v7())
.unwrap();
assert!(matches!(
StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&a.id, protocol::WorkerId::now_v7()),
Err(SubjectError::Active(_))
));
let mut second = StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&b.id, protocol::WorkerId::now_v7())
.unwrap();
first.close().unwrap();
let mut replacement = StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&a.id, protocol::WorkerId::now_v7())
.unwrap();
replacement.close().unwrap();
second.close().unwrap();
}
#[test]
fn closed_capabilities_and_foreign_worker_context_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let subject = catalog.create("owner", None).unwrap();
let id = protocol::WorkerId::now_v7();
let mut connection = catalog.connect(&subject.id, id).unwrap();
let port = connection.host.clone();
let foreign = SubjektivHostContext {
worker_id: protocol::WorkerId::now_v7().to_string(),
session_id: session_store::SessionId::now_v7().to_string(),
};
let read = SubjektivMemoryBackendOperation::ResidentContext(
memory::backend::MemoryResidentSummaryOperation {},
);
assert!(port.memory(&foreign, read.clone()).is_err());
let owner = SubjektivHostContext {
worker_id: format!("standalone-{id}"),
session_id: session_store::SessionId::now_v7().to_string(),
};
assert!(port.memory(&owner, read.clone()).is_ok());
assert!(
port.record_session(&owner, false).is_err(),
"unattributed Session is not read authority"
);
connection.close().unwrap();
assert!(port.memory(&owner, read).is_err());
}
#[cfg(unix)]
#[test]
fn scope_rejects_symlinks_and_non_private_roots() {
use std::os::unix::fs::{PermissionsExt, symlink};
let temp = tempfile::tempdir().unwrap();
let alias = temp.path().join("alias");
let real = temp.path().join("real");
fs::create_dir(&real).unwrap();
fs::set_permissions(&real, fs::Permissions::from_mode(0o700)).unwrap();
symlink(&real, &alias).unwrap();
assert!(StandaloneSubjects::open(&alias).is_err());
let public = temp.path().join("public");
fs::create_dir(&public).unwrap();
fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).unwrap();
assert!(StandaloneSubjects::open(&public).is_err());
let catalog = StandaloneSubjects::open(&real).unwrap();
drop(catalog);
let scope = real.join("subjektiv/scope.json");
let original = fs::read(&scope).unwrap();
fs::remove_file(&scope).unwrap();
let foreign = temp.path().join("foreign.json");
fs::write(&foreign, &original).unwrap();
symlink(&foreign, &scope).unwrap();
assert!(StandaloneSubjects::open_existing(&real).is_err());
assert_eq!(fs::read(&foreign).unwrap(), original);
}
#[test]
fn different_local_roots_do_not_authorize_shared_subject_ids() {
let temp = tempfile::tempdir().unwrap();
let first = StandaloneSubjects::open(temp.path().join("first")).unwrap();
let subject = first.create("owner", None).unwrap();
let other = StandaloneSubjects::open(temp.path().join("other")).unwrap();
assert_ne!(first.scope_id(), other.scope_id());
assert!(
other
.connect(&subject.id, protocol::WorkerId::now_v7())
.is_err()
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "subjektiv"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
feature-storage = { path = "../feature-storage" }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
memory.workspace = true
manifest.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
server-api.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
tempfile.workspace = true
+76
View File
@@ -0,0 +1,76 @@
# subjektiv
Shared Subject-scoped Memory domain and model-facing operations. This crate has
no Workspace Server dependency. `server-api` is the existing transport-neutral
request/response contract crate, not an authentication or Server implementation.
## Storage scope
The trusted Host creates `feature_storage::FeatureStorage`, selects
`FeatureStorage::scope(scope_id)`, registers `REGISTRATION` (or uses
`SubjektivStore::register`), and opens `SubjektivStore::open`. A trusted managed
`FeatureDatabase` can also be opened with `SubjektivStore::from_database`.
`scope_id` identifies the physical storage sharing boundary. Backend uses its
existing Workspace ID. Standalone uses a persisted random local storage-scope
identity under its operator-managed state directory, **not a fake Workspace**.
Subject IDs are independently issued random domain identities. An ID is a lookup
key, never an authorization credential.
Existing SQL schema and migrations are retained. In particular,
`store_scope.workspace_id` still stores the physical storage scope, under its
historical column name. Existing Backend placement and snapshot manifest keys
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
to be checked against the physical scope; local provenance need not invent a
Workspace value. `workspace_id()` is a Backend compatibility alias for
`scope_id()`.
## Host operation boundary
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher
used by Backend and local Hosts. It owns query/cursor rules, bounded body and
nested provenance projections, immutable revision reads, proposal validation,
explicit staging/receipt rules, resident context, candidate decisions and
surface generation/publication/failure projections.
The Host context is not serializable or model input. Trusted Rust Hosts construct
it only after validating their external execution authority:
- `HostOperationContext::validated_body` binds one Subject and optionally an
existing immutable `SubjectSessionAttribution`. Explicit staging requires the
request Session to match that attribution.
- `HostOperationContext::validated_consolidation` binds one Subject, an optional
immutable candidate batch, and an optional `JobAttemptBinding`. Attenuated Jobs
must supply `Some(batch)`, including an empty batch; `None` is legacy non-Job
Host consolidation authority. Body-only operations are unavailable to
consolidation contexts, and candidate reads/decisions cannot escape the batch.
These constructors validate store/domain bindings, not external authentication.
Backend retains authenticated Runtime/Worker identity, current singleton and
live Job attempt checks, candidate grant checks, and public Session validation.
Local Hosts retain exclusive Subject execution leases, generic Job admission,
Worker/Session ownership, and committed public Session evidence validation.
Before explicit staging, the Host resolves evidence using its committed public
Session projection; the common API does not inspect Session files, hidden
reasoning, system prompts, or arbitrary paths. Lower-level repository/projection
functions are trusted Rust APIs, not model capabilities.
## Common consolidation Job rules
`job::bounded_candidate_batch(ids, max_result_bytes)` selects the exact T-704
ordered-prefix budget (100 candidates maximum, 2,048-byte envelope, twice each
JSON-escaped ID size plus 128 bytes per disposition).
`job::consolidation_required(count, bytes, force, availability)` owns the shared
trigger thresholds and empty-ready/surface-retry rules.
`job::validate_result(store, subject_id, batch, job_id, attempt_id, value)` checks
the strict result shape, exact admitted Subject/batch, durable dispositions, and
the persisted terminal surface outcome from that attempt. After validation,
`job::with_dispositions(store, subject_id, batch, value)` adds the deterministic
compact disposition projection. Hosts retain Job result binding/admission; these
helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
`require_job_surface_generation`, and `validate_job_surface_outcome` are public
trusted-domain methods for both Backend and local Hosts.
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`.
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
//! Trusted Host operation context, never deserialized from model input.
use super::{OperationError, OperationResult, subjektiv_store_error};
use crate::{SubjectSessionAttribution, SubjektivStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobAttemptBinding {
pub job_id: String,
pub attempt_id: String,
}
#[derive(Debug, Clone)]
enum Authority {
Body {
attribution: Option<SubjectSessionAttribution>,
},
Consolidation {
candidate_ids: Option<Vec<String>>,
job: Option<JobAttemptBinding>,
},
}
/// A capability issued by trusted Rust Host code after authentication/lease,
/// delegation, and committed Session evidence checks. Not a request DTO.
/// Constructors check domain binding, not external identity or execution leases.
/// Backend must retain its authenticated Worker and active Job grant checks;
/// standalone must hold the selected Subject lease and attenuate Job batches.
#[derive(Debug, Clone)]
pub struct HostOperationContext {
scope_id: String,
subject_id: String,
authority: Authority,
}
impl HostOperationContext {
/// The Host has validated body execution authority. Attribution, when supplied,
/// must already be immutable history in this store. Before StageExplicit the
/// Host must resolve/validate all evidence against committed public Session
/// entries; neither this constructor nor the operation reads Session files.
pub fn validated_body(
store: &SubjektivStore,
subject_id: &str,
attribution: Option<SubjectSessionAttribution>,
) -> OperationResult<Self> {
Self::require_subject(store, subject_id)?;
if let Some(record) = &attribution {
if record.subject_id != subject_id
|| store
.session_attribution(&record.session_id)
.map_err(subjektiv_store_error)?
.as_ref()
!= Some(record)
{
return Err(OperationError::PermissionDenied(
"Session attribution does not match the Host subject".into(),
));
}
}
Ok(Self {
scope_id: store.scope_id().into(),
subject_id: subject_id.into(),
authority: Authority::Body { attribution },
})
}
/// The Host has validated consolidation authority and (for a Job) its active
/// attempt. `Some(candidate_ids)` is the immutable admitted batch; no candidate
/// outside it is accessible. `None` retains Backend's non-Job consolidation
/// authority, and must not be issued to an attenuated Job.
pub fn validated_consolidation(
store: &SubjektivStore,
subject_id: &str,
candidate_ids: Option<Vec<String>>,
job: Option<JobAttemptBinding>,
) -> OperationResult<Self> {
Self::require_subject(store, subject_id)?;
if let Some(ids) = &candidate_ids {
for id in ids {
if store
.staging_candidate(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"candidate_not_found: {id}"
)));
}
}
}
if job.as_ref().is_some_and(|binding| {
binding.job_id.trim().is_empty() || binding.attempt_id.trim().is_empty()
}) {
return Err(OperationError::InvalidInput(
"Job attempt binding must not be empty".into(),
));
}
Ok(Self {
scope_id: store.scope_id().into(),
subject_id: subject_id.into(),
authority: Authority::Consolidation { candidate_ids, job },
})
}
fn require_subject(store: &SubjektivStore, id: &str) -> OperationResult<()> {
store
.subject(id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| OperationError::SubjectNotFound(id.into()))?;
Ok(())
}
pub fn subject_id(&self) -> &str {
&self.subject_id
}
pub(super) fn require_store(&self, store: &SubjektivStore) -> OperationResult<()> {
if self.scope_id != store.scope_id() {
return Err(OperationError::PermissionDenied(
"Host operation context belongs to another storage scope".into(),
));
}
Ok(())
}
pub(super) fn body(&self) -> OperationResult<Option<&SubjectSessionAttribution>> {
match &self.authority {
Authority::Body { attribution } => Ok(attribution.as_ref()),
_ => Err(OperationError::PermissionDenied(
"operation requires Subject body authority".into(),
)),
}
}
pub(super) fn consolidation(
&self,
) -> OperationResult<(Option<&[String]>, Option<&JobAttemptBinding>)> {
match &self.authority {
Authority::Consolidation { candidate_ids, job } => {
Ok((candidate_ids.as_deref(), job.as_ref()))
}
_ => Err(OperationError::PermissionDenied(
"operation requires consolidation authority".into(),
)),
}
}
pub(super) fn candidate(&self, id: &str) -> OperationResult<()> {
let (ids, _) = self.consolidation()?;
if ids.is_some_and(|ids| !ids.iter().any(|allowed| allowed == id)) {
return Err(OperationError::PermissionDenied(
"candidate is outside the admitted Job batch".into(),
));
}
Ok(())
}
}
+255
View File
@@ -0,0 +1,255 @@
use super::*;
use server_api::{
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
};
/// Executes the same domain operations and bounded projections for every Host.
/// The caller authenticates and validates evidence before issuing `context`.
pub fn execute(
store: &crate::SubjektivStore,
context: &HostOperationContext,
operation: Op,
) -> OperationResult<Response> {
context.require_store(store)?;
let subject_id = context.subject_id();
Ok(match operation {
Op::ResidentSummary(_) => {
context.body()?;
Response::ResidentSummary(resident_summary_output(
store
.resident_surface(subject_id)
.map_err(subjektiv_store_error)?,
))
}
Op::ResidentContext(_) => {
context.body()?;
let resident = store
.resident_context(subject_id)
.map_err(subjektiv_store_error)?;
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface),
})
}
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
Op::ListRevisions(input) => {
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
}
Op::ValidateProposal(input) => {
context.body()?;
Response::ProposalValidated(subjektiv_memory_validate_proposal(
store, subject_id, input,
)?)
}
Op::ReceiptStatus(input) => {
context.body()?;
validate_subjektiv_receipt_id(&input.receipt_id)?;
let candidate = store
.staging_candidate(subject_id, &input.receipt_id)
.map_err(subjektiv_store_error)?;
Response::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusResponse {
receipt_id: input.receipt_id,
status: if candidate.is_some() {
server_api::SubjektivMemoryReceiptStatus::Staged
} else {
server_api::SubjektivMemoryReceiptStatus::Missing
},
candidate_id: candidate.map(|record| record.id),
})
}
Op::StageExplicit(input) => {
let attribution = context.body()?.ok_or_else(|| {
OperationError::PermissionDenied(
"explicit staging requires validated Session attribution".into(),
)
})?;
if input.session_id != attribution.session_id {
return Err(OperationError::PermissionDenied(
"explicit staging Session does not match Host attribution".into(),
));
}
Response::Staged(subjektiv_memory_stage_explicit(
store,
input,
attribution.clone(),
)?)
}
Op::ListCandidates(input) => {
let (ids, _) = context.consolidation()?;
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let mut candidates = if let Some(ids) = ids {
let mut pending = Vec::new();
for id in ids {
if store
.staging_resolution(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
if let Some(record) = store
.staging_candidate(subject_id, id)
.map_err(subjektiv_store_error)?
{
pending.push(record);
}
}
}
pending
} else {
store
.pending_staging_candidates(subject_id, limit.saturating_add(1))
.map_err(subjektiv_store_error)?
};
let has_more = candidates.len() > limit;
candidates.truncate(limit);
Response::Candidates(server_api::SubjektivMemoryCandidateListResponse {
items: candidates
.into_iter()
.map(subjektiv_candidate_summary)
.collect(),
has_more,
})
}
Op::ReadCandidate(input) => {
context.candidate(&input.candidate_id)?;
let candidate = store
.staging_candidate(subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
OperationError::InvalidInput(format!(
"candidate_not_found: pending candidate `{}` was not found",
input.candidate_id
))
})?;
if store
.staging_resolution(subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.is_some()
{
return Err(OperationError::Conflict(format!(
"candidate_resolved: candidate `{}` is already resolved",
input.candidate_id
)));
}
Response::Candidate(subjektiv_candidate(candidate))
}
Op::DecideCandidate(input) => {
context.candidate(&input.candidate_id)?;
let receipt = store
.decide_candidate(subject_id, subjektiv_candidate_decision(input)?)
.map_err(subjektiv_store_error)?;
Response::CandidateDecided(subjektiv_candidate_decision_response(receipt))
}
Op::PrepareSurface(_) => {
let (ids, job) = context.consolidation()?;
if let Some(ids) = ids {
for id in ids {
if store
.staging_resolution(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"surface generation requires a durable disposition for batch candidate `{id}`"
)));
}
}
}
let generation = store
.prepare_job_surface_generation(
subject_id,
job.map(|b| (b.job_id.as_str(), b.attempt_id.as_str())),
)
.map_err(subjektiv_store_error)?;
let resident = store
.resident_surface(subject_id)
.map_err(subjektiv_store_error)?;
let mut response = subjektiv_surface_prepare_response(generation);
if resident.availability == crate::SurfaceAvailability::Ready {
response.current_snapshot_id = resident
.snapshot
.filter(|s| s.built_from_store_revision == response.store_revision)
.map(|s| s.id);
}
Response::SurfacePrepared(response)
}
Op::PublishSurface(input) => {
let (_, job) = context.consolidation()?;
if let Some(job) = job {
store
.require_job_surface_generation(
subject_id,
&input.generation_id,
&job.job_id,
&job.attempt_id,
)
.map_err(subjektiv_store_error)?;
}
let points = input
.points
.into_iter()
.map(|point| crate::SurfacePoint {
body_md: point.body_md,
memory_refs: point
.memory_refs
.into_iter()
.map(|r| crate::MemoryRevisionRef {
memory_id: r.memory_id,
revision: r.revision,
})
.collect(),
})
.collect();
let snapshot = store
.publish_surface_generation(subject_id, &input.generation_id, points)
.map_err(subjektiv_store_error)?;
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
snapshot_id: snapshot.id,
built_from_store_revision: snapshot.built_from_store_revision,
empty: snapshot.body_md.is_empty(),
})
}
Op::FailSurface(input) => {
let (_, job) = context.consolidation()?;
if let Some(job) = job {
store
.require_job_surface_generation(
subject_id,
&input.generation_id,
&job.job_id,
&job.attempt_id,
)
.map_err(subjektiv_store_error)?;
}
let revision = store
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
.map_err(subjektiv_store_error)?;
let status = if let Some(job) = job {
store
.validate_job_surface_outcome(
subject_id,
&input.generation_id,
revision,
"failed",
None,
Some(&input.reason_code),
&job.job_id,
&job.attempt_id,
)
.map_err(|e| {
OperationError::Conflict(format!(
"surface failure was not recorded for this attempt: {e}"
))
})?;
"failed_confirmed"
} else {
"failed"
};
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
store_revision: revision,
status: status.into(),
})
}
})
}
+343
View File
@@ -0,0 +1,343 @@
use super::*;
use crate::{MemoryDraft, SubjectRole, SubjectSessionAttribution, SubjektivStore};
use memory::extract::CandidateKind;
use server_api::{
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
};
fn open(root: &std::path::Path, scope: &str) -> (feature_storage::FeatureStorage, SubjektivStore) {
let manager = feature_storage::FeatureStorage::new(root);
let scope = manager.scope(scope).unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
(manager, store)
}
fn query(cursor: Option<String>) -> Op {
Op::Query(server_api::SubjektivMemoryQueryRequest {
query: None,
kinds: None,
states: None,
limit: Some(1),
cursor,
})
}
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op {
Op::Read(server_api::SubjektivMemoryReadRequest {
memory_id: id.into(),
revision,
offset,
byte_offset,
limit: Some(1),
evidence_cursor: None,
})
}
#[test]
fn neutral_scope_reopen_and_context_attenuation() {
let temp = tempfile::tempdir().unwrap();
let random_scope = uuid::Uuid::now_v7().to_string();
let (manager, store) = open(temp.path(), &random_scope);
assert_eq!(store.scope_id(), random_scope);
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let consolidation =
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
.unwrap();
assert!(
execute(
&store,
&body,
Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None })
)
.is_err()
);
assert!(
execute(
&store,
&consolidation,
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {})
)
.is_err()
);
let attribution =
SubjectSessionAttribution::new(&subject.id, "local-host", "worker", "session").unwrap();
assert!(
HostOperationContext::validated_body(&store, &subject.id, Some(attribution.clone()))
.is_err()
);
store
.record_session_attribution(attribution.clone())
.unwrap();
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
let (_, foreign) = open(&temp.path().join("foreign"), "foreign-scope");
assert!(matches!(
execute(&foreign, &body, query(None)),
Err(OperationError::PermissionDenied(_))
));
manager.shutdown().unwrap();
drop(store);
let (_manager, reopened) = open(temp.path(), &random_scope);
assert_eq!(reopened.subject(&subject.id).unwrap().unwrap(), subject);
assert!(reopened.session_attribution("session").unwrap().is_some());
}
#[test]
fn shared_query_cursor_and_utf8_fixed_revision_projection() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let original = "日本語\\\"".repeat(10_000);
let first = store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Lesson,
"first",
&original,
"reason",
"initial",
),
)
.unwrap();
store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Decision,
"second",
"second body",
"reason",
"initial",
),
)
.unwrap();
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
panic!()
};
assert!(page.has_more);
assert_eq!(page.items.len(), 1);
let cursor = page.next_cursor.unwrap();
store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Constraint,
"third",
"third body",
"reason",
"initial",
),
)
.unwrap();
assert!(matches!(
execute(&store, &context, query(Some(cursor))),
Err(OperationError::Conflict(_))
));
assert!(execute(&store, &context, read(&first.id, None, Some(1), None)).is_err());
let mut offset = None;
let mut byte_offset = None;
let mut reconstructed = String::new();
loop {
let Response::Read(page) = execute(
&store,
&context,
read(&first.id, Some(1), offset, byte_offset),
)
.unwrap() else {
panic!()
};
assert!(
serde_json::to_vec_pretty(&page).unwrap().len()
<= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
);
reconstructed.push_str(&page.body_md);
if !page.body_truncated {
break;
}
assert!(!page.body_md.is_empty());
offset = page.body_next_offset;
byte_offset = page.body_next_byte_offset;
}
assert_eq!(reconstructed, original);
}
#[test]
fn shared_surface_empty_snapshot_is_ready_not_missing() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let consolidation = HostOperationContext::validated_consolidation(
&store,
&subject.id,
Some(vec![]),
Some(JobAttemptBinding {
job_id: "job".into(),
attempt_id: "attempt".into(),
}),
)
.unwrap();
let prepared: server_api::SubjektivSurfacePrepareRequest = serde_json::from_str("{}").unwrap();
let Response::SurfacePrepared(prepared) =
execute(&store, &consolidation, Op::PrepareSurface(prepared)).unwrap()
else {
panic!()
};
let Response::SurfacePublished(published) = execute(
&store,
&consolidation,
Op::PublishSurface(server_api::SubjektivSurfacePublishRequest {
generation_id: prepared.generation_id,
points: vec![],
}),
)
.unwrap() else {
panic!()
};
assert!(published.empty);
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let Response::ResidentSummary(resident) = execute(
&store,
&body,
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {}),
)
.unwrap() else {
panic!()
};
assert_eq!(
resident.availability,
memory::backend::MemoryResidentSummaryAvailability::Ready
);
assert_eq!(resident.content, None);
}
#[test]
fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "random-local-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let attribution =
SubjectSessionAttribution::new(&subject.id, "host", "worker", "session").unwrap();
store
.record_session_attribution(attribution.clone())
.unwrap();
let context =
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
// Host-resolved public Session evidence with no invented Workspace identity.
let input = server_api::SubjektivMemoryStageExplicitRequest {
receipt_id: "receipt-1".into(),
session_id: "session".into(),
kind: CandidateKind::Lesson,
claim: "Preserve correction history".into(),
why_useful: "Allows auditing".into(),
staleness: None,
proposal: None,
evidence: vec![memory::extract::StagingEvidence {
id: "e1".into(),
entry_range: Some([1, 1]),
kind: memory::schema::EvidenceKind::new("model_output"),
origin: None,
excerpt: Some("Use immutable revisions".into()),
summary: None,
}],
source_refs: vec![memory::schema::SourceEvidenceRef {
session_id: Some("session".into()),
segment_id: Some("segment".into()),
entry_range: Some([1, 1]),
evidence_id: Some("e1".into()),
..Default::default()
}],
};
let mut foreign_session = input.clone();
foreign_session.session_id = "foreign".into();
assert!(matches!(
execute(&store, &context, Op::StageExplicit(foreign_session)),
Err(OperationError::PermissionDenied(_))
));
execute(&store, &context, Op::StageExplicit(input.clone())).unwrap();
execute(&store, &context, Op::StageExplicit(input)).unwrap();
let Response::ReceiptStatus(receipt) = execute(
&store,
&context,
Op::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusRequest {
receipt_id: "receipt-1".into(),
}),
)
.unwrap() else {
panic!()
};
assert_eq!(
receipt.status,
server_api::SubjektivMemoryReceiptStatus::Staged
);
let candidate_id = receipt.candidate_id.unwrap();
let no_batch =
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
.unwrap();
assert!(matches!(
execute(
&store,
&no_batch,
Op::ReadCandidate(server_api::SubjektivMemoryCandidateReadRequest {
candidate_id: candidate_id.clone()
})
),
Err(OperationError::PermissionDenied(_))
));
let batch = HostOperationContext::validated_consolidation(
&store,
&subject.id,
Some(vec![candidate_id.clone()]),
None,
)
.unwrap();
assert!(
execute(
&store,
&batch,
Op::PrepareSurface(serde_json::from_str("{}").unwrap())
)
.is_err()
);
let decision = server_api::SubjektivMemoryCandidateDecisionRequest {
request_id: "decision-1".into(),
candidate_id,
reason: "adopt lesson".into(),
decision: server_api::SubjektivMemoryCandidateDecision::Apply {
target: server_api::SubjektivMemoryApplyTarget::Create,
memory: server_api::SubjektivMemoryDraft {
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Preserve correction history".into(),
body_md: "Use immutable revisions".into(),
why_useful: "Allows auditing".into(),
staleness: None,
derived_from: vec![],
change_reason: "adopt".into(),
},
},
};
let first = execute(&store, &batch, Op::DecideCandidate(decision.clone())).unwrap();
let retry = execute(&store, &batch, Op::DecideCandidate(decision)).unwrap();
assert_eq!(
serde_json::to_value(first).unwrap(),
serde_json::to_value(retry).unwrap()
);
assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1);
execute(
&store,
&batch,
Op::PrepareSurface(serde_json::from_str("{}").unwrap()),
)
.unwrap();
}
+153
View File
@@ -0,0 +1,153 @@
//! Common consolidation Job policy and verified result projections.
//!
//! Hosts authenticate/admit Jobs and bind their immutable batch and attempt.
//! These helpers do not dispatch, replay, or grant Job execution authority.
use serde::Deserialize;
use crate::api::{OperationError, OperationResult};
use crate::{SubjektivStore, SurfaceAvailability};
pub const CANDIDATE_BATCH_LIMIT: usize = 100;
pub const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
pub const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
/// Selects an ordered prefix using the T-704 complete-result budget. Reserve
/// 2,048 bytes for the Subject/Host surface envelope, then twice the JSON-escaped
/// ID size plus 128 bytes per candidate for its compact verified disposition.
pub fn bounded_candidate_batch(
ids: impl IntoIterator<Item = String>,
max_result_bytes: u32,
) -> OperationResult<Vec<String>> {
let mut estimated_bytes = 2_048_usize;
let mut selected = Vec::new();
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
let id_bytes = serde_json::to_vec(&id)
.map_err(|e| OperationError::InvalidInput(e.to_string()))?
.len();
let additional = 2 * id_bytes + 128;
if estimated_bytes + additional > max_result_bytes as usize {
break;
}
estimated_bytes += additional;
selected.push(id);
}
Ok(selected)
}
/// Empty ready Subjects need no work, even when forced. Missing/stale/failed
/// surfaces bypass candidate thresholds; confirmed Memory is never discarded.
pub fn consolidation_required(
candidate_count: usize,
total_bytes: u64,
force: bool,
surface_availability: SurfaceAvailability,
) -> bool {
let surface_needs_generation = surface_availability != SurfaceAvailability::Ready;
if candidate_count == 0 {
return surface_needs_generation;
}
force
|| surface_needs_generation
|| candidate_count >= CONSOLIDATION_THRESHOLD_FILES
|| total_bytes >= CONSOLIDATION_THRESHOLD_BYTES
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
surface: SurfaceResult,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
reason_code: Option<String>,
}
/// Verifies the exact Subject/batch, every immutable disposition, and the actual
/// terminal surface outcome begun by this Job attempt. Model claims alone never
/// establish completion. Same admission semantics as the Backend T-704 adapter.
pub fn validate_result(
store: &SubjektivStore,
subject_id: &str,
batch: &[String],
job_id: &str,
attempt_id: &str,
value: &serde_json::Value,
) -> OperationResult<()> {
let result: ConsolidationResult = serde_json::from_value(value.clone())
.map_err(|e| OperationError::InvalidInput(format!("invalid consolidation result: {e}")))?;
if result.subject_id != subject_id || result.candidate_ids != batch {
return Err(OperationError::InvalidInput(
"consolidation result does not match the immutable Subject/batch grant".into(),
));
}
for candidate_id in batch {
if store
.staging_resolution(subject_id, candidate_id)
.map_err(|e| OperationError::Store(e.to_string()))?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"consolidation candidate `{candidate_id}` is unresolved"
)));
}
}
store
.validate_job_surface_outcome(
subject_id,
&result.surface.generation_id,
result.surface.store_revision,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
job_id,
attempt_id,
)
.map_err(|e| {
OperationError::InvalidInput(format!("consolidation surface result mismatch: {e}"))
})
}
/// Enriches a previously verified result with compact durable dispositions. Full
/// reasons, affected revisions and provenance remain in the receipt store. This
/// deterministic projection neither applies candidates nor creates a new ledger.
pub fn with_dispositions(
store: &SubjektivStore,
subject_id: &str,
batch: &[String],
value: &serde_json::Value,
) -> OperationResult<serde_json::Value> {
let mut dispositions = Vec::new();
for id in batch {
let resolution = store
.staging_resolution(subject_id, id)
.map_err(|e| OperationError::Store(e.to_string()))?
.ok_or_else(|| {
OperationError::InvalidInput(format!("candidate `{id}` has no durable disposition"))
})?;
dispositions.push(serde_json::json!({"candidate_id": id, "action": resolution.action}));
}
let mut enriched = value.clone();
enriched
.as_object_mut()
.ok_or_else(|| {
OperationError::InvalidInput("consolidation result must be an object".into())
})?
.insert(
"candidate_dispositions".into(),
serde_json::Value::Array(dispositions),
);
Ok(enriched)
}
#[cfg(test)]
mod tests;
+183
View File
@@ -0,0 +1,183 @@
use super::*;
use crate::{
CandidateDecision, CandidateDecisionRequest, StagingResolutionAction, SubjectRole,
SubjectStagingRecord,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
use memory::schema::SourceRef;
#[test]
fn escaped_ids_use_exact_t704_complete_result_budget() {
const MAX_RESULT_BYTES: u32 = 16 * 1024;
let ids: Vec<_> = (0..100)
.map(|i| format!("{i:03}{}", "\\\"".repeat(126)))
.collect();
let batch = bounded_candidate_batch(ids.clone(), MAX_RESULT_BYTES).unwrap();
assert!(!batch.is_empty());
assert!(batch.len() < ids.len());
assert_eq!(batch, ids[..batch.len()]);
let dispositions: Vec<_> = batch
.iter()
.map(|id| serde_json::json!({"candidate_id": id, "action": "already_covered"}))
.collect();
let result = serde_json::json!({
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) }
});
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
assert!(
bounded_candidate_batch(["one".into()], 2048)
.unwrap()
.is_empty()
);
assert_eq!(
bounded_candidate_batch((0..200).map(|i| i.to_string()), u32::MAX)
.unwrap()
.len(),
CANDIDATE_BATCH_LIMIT
);
}
#[test]
fn trigger_policy_preserves_empty_ready_and_surface_retry_rules() {
for availability in [
SurfaceAvailability::Ungenerated,
SurfaceAvailability::Stale,
SurfaceAvailability::Failed,
] {
assert!(consolidation_required(0, 0, false, availability));
assert!(consolidation_required(1, 1, false, availability));
}
assert!(!consolidation_required(
0,
0,
true,
SurfaceAvailability::Ready
));
assert!(!consolidation_required(
4,
49_999,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
5,
0,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
1,
50_000,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
1,
1,
true,
SurfaceAvailability::Ready
));
}
#[test]
fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
let temp = tempfile::tempdir().unwrap();
let manager = feature_storage::FeatureStorage::new(temp.path());
let scope = manager.scope("local-random-scope").unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let candidate = SubjectStagingRecord::attach(
&subject.id,
StagingRecord::from_candidate(
"candidate",
"dedup",
SourceRef {
segment_id: "segment".into(),
range: [1, 1],
},
ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: "claim".into(),
why_useful: "reason".into(),
staleness: None,
evidence_ids: vec![],
},
vec![],
vec![],
),
);
store.stage_candidate(candidate).unwrap();
let batch = vec!["candidate".to_string()];
let generation = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let snapshot = store
.publish_surface_generation(&subject.id, &generation.id, vec![])
.unwrap();
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}});
assert!(
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
.unwrap_err()
.to_string()
.contains("unresolved")
);
assert!(with_dispositions(&store, &subject.id, &batch, &ready).is_err());
store
.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision".into(),
candidate_id: "candidate".into(),
reason: "invalid".into(),
decision: CandidateDecision::Close {
action: StagingResolutionAction::Invalid,
affected_memory: vec![],
},
},
)
.unwrap();
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).unwrap();
let enriched = with_dispositions(&store, &subject.id, &batch, &ready).unwrap();
assert_eq!(
enriched["candidate_dispositions"],
serde_json::json!([{"candidate_id": "candidate", "action": "invalid"}])
);
assert_eq!(
enriched,
with_dispositions(&store, &subject.id, &batch, &ready).unwrap()
);
assert!(validate_result(&store, &subject.id, &batch, "job", "other-attempt", &ready).is_err());
assert!(validate_result(&store, &subject.id, &[], "job", "attempt", &ready).is_err());
// A current ready snapshot survives a redundant editor failure. Advance
// Memory first so this attempt genuinely needs a new surface and records failure.
store
.create_memory(
&subject.id,
crate::MemoryDraft::active(
CandidateKind::Lesson,
"new lesson",
"new body",
"reason",
"change",
),
)
.unwrap();
let failure = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let revision = store
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
.unwrap();
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}});
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
let mut false_reason = failed;
false_reason["surface"]["reason_code"] = serde_json::json!("invented");
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &false_reason).is_err());
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -957,6 +957,7 @@ mod tests {
language: "English".into(),
}),
subjektiv_attached: false,
backend_job: None,
})
.unwrap();
runtime.stop_worker(&worker.worker_ref, None).unwrap();
+9 -8
View File
@@ -457,20 +457,21 @@ pub(crate) async fn run_standalone(
state_dir: PathBuf,
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let worker_name = worker_name.unwrap_or_else(|| "local".to_string());
let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| {
manifest::ProfileSelector::parse_cli(&profile)
});
let history_root = workspace_root.clone();
let launch = StandaloneLaunchConfig {
state_dir,
cwd: workspace_root,
profile,
worker_name: worker_name.clone(),
}
.resolve()
.map_err(|error| {
let launch =
StandaloneLaunchConfig::new(workspace_root, state_dir, profile, worker_name.clone());
let launch = if let Some(subject_id) = subject_id {
launch.with_subject(subject_id)
} else {
launch
};
let launch = launch.resolve().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Standalone launch configuration failed: {error}"),
+4 -1
View File
@@ -65,6 +65,7 @@ pub enum LaunchMode {
Spawn {
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
},
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone Workers under the same client data root.
@@ -202,7 +203,8 @@ async fn launch_mode(
LaunchMode::Spawn {
worker_name,
profile,
} => match standalone_spawn::select(&workspace_root, worker_name, profile) {
subject_id,
} => match standalone_spawn::select(&workspace_root, worker_name, profile, subject_id) {
Ok(Some(selection)) => match target.spawn_worker() {
Ok(spawn) => {
console::run_standalone(
@@ -210,6 +212,7 @@ async fn launch_mode(
spawn.state_dir,
Some(selection.worker_name),
Some(selection.profile),
selection.subject_id,
)
.await
}
+134 -10
View File
@@ -12,13 +12,14 @@ use thiserror::Error;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
const VIEWPORT_HEIGHT: u16 = 6;
const VIEWPORT_HEIGHT: u16 = 7;
const FALLBACK_WORKER_NAME: &str = "worker";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StandaloneSpawnSelection {
pub worker_name: String,
pub profile: String,
pub subject_id: Option<String>,
}
#[derive(Debug, Error)]
@@ -55,6 +56,9 @@ enum SpawnAction {
struct SpawnForm {
worker_name: String,
cursor: usize,
subject_id: String,
subject_cursor: usize,
editing_subject: bool,
profile_choices: Vec<ProfileChoice>,
selected_profile: usize,
status: Option<(String, StatusKind)>,
@@ -75,12 +79,26 @@ impl SpawnForm {
Self {
worker_name,
cursor,
subject_id: String::new(),
subject_cursor: 0,
editing_subject: false,
profile_choices,
selected_profile,
status: None,
}
}
fn with_subject(mut self, subject_id: Option<String>) -> Self {
self.subject_id = subject_id.unwrap_or_default();
self.subject_cursor = self.subject_id.chars().count();
self
}
fn subject_selection(&self) -> Option<String> {
let id = self.subject_id.trim();
(!id.is_empty()).then(|| id.to_owned())
}
fn selected_profile(&self) -> &ProfileChoice {
&self.profile_choices[self.selected_profile]
}
@@ -98,6 +116,47 @@ impl SpawnForm {
}
self.status = None;
if key.code == KeyCode::F(2) {
self.editing_subject = !self.editing_subject;
return SpawnAction::None;
}
if self.editing_subject {
match key.code {
KeyCode::Left => self.subject_cursor = self.subject_cursor.saturating_sub(1),
KeyCode::Right => {
self.subject_cursor =
(self.subject_cursor + 1).min(self.subject_id.chars().count())
}
KeyCode::Home => self.subject_cursor = 0,
KeyCode::End => self.subject_cursor = self.subject_id.chars().count(),
KeyCode::Backspace => {
if self.subject_cursor > 0 {
self.subject_id
.remove(byte_index(&self.subject_id, self.subject_cursor - 1));
self.subject_cursor -= 1;
}
}
KeyCode::Delete => {
if self.subject_cursor < self.subject_id.chars().count() {
self.subject_id
.remove(byte_index(&self.subject_id, self.subject_cursor));
}
}
KeyCode::Char(ch) if is_safe_worker_char(ch) => {
self.subject_id
.insert(byte_index(&self.subject_id, self.subject_cursor), ch);
self.subject_cursor += 1;
}
KeyCode::Char(_) => {}
// Submit/cancel and profile cycling remain available while editing the Subject.
_ => return self.apply_form_key(key),
}
return SpawnAction::None;
}
self.apply_form_key(key)
}
fn apply_form_key(&mut self, key: KeyEvent) -> SpawnAction {
match key.code {
KeyCode::Esc => SpawnAction::Cancel,
KeyCode::Enter => {
@@ -167,12 +226,14 @@ pub(crate) fn select(
workspace_root: &Path,
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
let default_worker_name = default_worker_name(workspace_root);
if let Some(profile) = profile {
return Ok(Some(StandaloneSpawnSelection {
worker_name: worker_name.unwrap_or(default_worker_name),
profile,
subject_id,
}));
}
@@ -185,7 +246,7 @@ pub(crate) fn select(
with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
SpawnForm::new(worker_name, default_worker_name, choices).with_subject(subject_id),
)
})
}
@@ -214,6 +275,7 @@ fn run_picker(
let selection = StandaloneSpawnSelection {
worker_name: form.worker_name.trim().to_owned(),
profile: form.selected_profile().selector.clone(),
subject_id: form.subject_selection(),
};
form.status = Some(("starting worker...".to_owned(), StatusKind::Progress));
terminal.draw(|frame| draw_form(frame, &form))?;
@@ -261,6 +323,7 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(frame.area());
@@ -306,12 +369,32 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
chunks[2],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("subject: ", Style::default().fg(Color::DarkGray)),
Span::styled(
if form.subject_id.is_empty() {
"(none)"
} else {
&form.subject_id
},
Style::default().fg(Color::Cyan),
),
Span::styled(
" (F2 edit name/Subject ID)",
Style::default().fg(Color::DarkGray),
),
])),
chunks[3],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" enter spawn · left/right edit · esc cancel",
Style::default().fg(Color::DarkGray),
))),
chunks[3],
chunks[4],
);
let (message, color) = form
@@ -331,16 +414,20 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
Span::raw(" "),
Span::styled(message, Style::default().fg(color)),
])),
chunks[4],
chunks[5],
);
let prefix_width = " name: ".chars().count() as u16;
let x = chunks[1]
let (row, prefix, cursor) = if form.editing_subject {
(3, " subject: ", form.subject_cursor)
} else {
(1, " name: ", form.cursor)
};
let x = chunks[row]
.x
.saturating_add(prefix_width)
.saturating_add(form.cursor as u16)
.min(chunks[1].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[1].y));
.saturating_add(prefix.chars().count() as u16)
.saturating_add(cursor as u16)
.min(chunks[row].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[row].y));
}
fn default_worker_name(workspace_root: &Path) -> String {
@@ -394,9 +481,43 @@ mod tests {
fn default_form_preserves_old_spawn_layout_defaults() {
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(form.worker_name, "yoi");
assert_eq!(form.subject_selection(), None);
assert_eq!(form.selected_profile().selector, "builtin:standalone");
}
#[test]
fn optional_subject_field_edits_independently_and_can_be_cleared() {
let mut form = SpawnForm::new(None, "worker".to_owned(), choices());
form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
for ch in "subject-1".chars() {
form.apply_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(form.subject_selection().as_deref(), Some("subject-1"));
assert_eq!(form.worker_name, "worker");
form.apply_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:coder");
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
SpawnAction::Submit
);
for _ in 0..9 {
form.apply_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
assert_eq!(form.subject_selection(), None);
form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(form.worker_name, "worker2");
assert_eq!(form.subject_selection(), None);
}
#[test]
fn explicit_subject_prefills_the_optional_field() {
let form = SpawnForm::new(None, "worker".to_owned(), choices())
.with_subject(Some("subject-id".to_owned()));
assert_eq!(form.subject_selection().as_deref(), Some("subject-id"));
assert_eq!(form.subject_cursor, 10);
}
#[test]
fn tab_and_arrows_cycle_profiles() {
let mut form = SpawnForm::new(None, "yoi".to_owned(), choices());
@@ -453,6 +574,7 @@ mod tests {
assert!(rendered.contains("spawn worker"));
assert!(rendered.contains("name: yoi"));
assert!(rendered.contains("subject: (none)"));
assert!(rendered.contains("profile: builtin:standalone (default) — Standalone"));
assert!(rendered.contains("enter spawn · left/right edit · esc cancel"));
}
@@ -504,10 +626,12 @@ mod tests {
Path::new("/home/hare/Project/yoi"),
None,
Some("builtin:coder".to_owned()),
Some("subject-id".to_owned()),
)
.unwrap()
.unwrap();
assert_eq!(selection.worker_name, "yoi");
assert_eq!(selection.profile, "builtin:coder");
assert_eq!(selection.subject_id.as_deref(), Some("subject-id"));
}
}
+48
View File
@@ -52,6 +52,7 @@ pub struct WorkerBootstrap<St> {
transport: WorkerControllerTransport,
model_client: Option<Box<dyn LlmClient>>,
workdir_session: Option<WorkdirSessionHandle>,
subjektiv_host: Option<std::sync::Arc<dyn crate::subjektiv::SubjektivHost>>,
}
/// A constructed Worker whose host-owned live bindings can still be installed
@@ -106,6 +107,7 @@ where
transport,
model_client: None,
workdir_session: None,
subjektiv_host: None,
}
}
@@ -127,6 +129,16 @@ where
self
}
/// Bind an explicit, process-owned subject capability. Profile policy alone
/// is inert; the connection is never persisted into the Worker manifest.
pub fn with_subjektiv_host(
mut self,
host: std::sync::Arc<dyn crate::subjektiv::SubjektivHost>,
) -> Self {
self.subjektiv_host = Some(host);
self
}
/// Construct the Worker without exposing a controller handle. Runtime hosts
/// use this seam to bind Workdir, observation, Flow, and other live services
/// before [`PreparedWorker::start`] performs Feature installation.
@@ -147,6 +159,24 @@ where
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
let explicit_subjektiv_host = self.subjektiv_host.is_some();
worker.bind_subjektiv_host(self.subjektiv_host);
if explicit_subjektiv_host
&& worker.manifest().feature.subjektiv.profile.enabled
&& !worker
.manifest()
.feature
.subjektiv
.profile
.consolidation_tools
{
worker
.finalize_subjektiv_session_attribution(
true,
crate::SubjektivSessionAttributionLifecycle::NewSession,
)
.map_err(WorkerBootstrapError::Worker)?;
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
@@ -170,6 +200,24 @@ where
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
let explicit_subjektiv_host = self.subjektiv_host.is_some();
worker.bind_subjektiv_host(self.subjektiv_host);
if explicit_subjektiv_host
&& worker.manifest().feature.subjektiv.profile.enabled
&& !worker
.manifest()
.feature
.subjektiv
.profile
.consolidation_tools
{
worker
.finalize_subjektiv_session_attribution(
true,
crate::SubjektivSessionAttributionLifecycle::RestoredSession,
)
.map_err(WorkerBootstrapError::Worker)?;
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
+18 -17
View File
@@ -1377,6 +1377,7 @@ where
None => durable_parent_notifications,
};
let backend_job = worker.backend_job().cloned();
let subjektiv_host = worker.subjektiv_host_connection()?;
// Bounded Jobs complete their lifecycle synchronously, never by post-run background work.
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
let prompts = worker.prompts().clone();
@@ -1443,8 +1444,8 @@ where
workspace_client.clone(),
);
if binding.subjektiv_consolidation {
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job(
workspace_client, spawner_manifest.clone(), worker.llm_client_handle(),
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_host(
subjektiv_host.clone().ok_or_else(|| std::io::Error::other("Backend Job requires subjektiv Host capability"))?, spawner_manifest.clone(), worker.llm_client_handle(),
prompts.clone(), spawner_workspace_context.clone(),
)?;
result_feature = result_feature.with_surface(surface);
@@ -1453,7 +1454,9 @@ 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)?;
if subjektiv_host.is_some() {
validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?;
}
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
@@ -1476,7 +1479,7 @@ where
let subjektiv_consolidation_plan =
crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
worker.prompts().load_full(),
)?;
if let Some(plan) = subjektiv_consolidation_plan {
@@ -1485,22 +1488,20 @@ where
}
feature_registry.add_module(plan.module);
}
let ordinary_subjektiv_features_enabled =
crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest());
let ordinary_subjektiv_features_enabled = subjektiv_host.is_some()
&& 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(),
subjektiv_host.clone(),
worker.prompts(),
)?
{
let workspace_id = worker
.workspace_client()
.workspace_id()
.expect("validated subject resident source has Workspace identity")
.to_string();
let workspace_id = subjektiv_host
.as_ref()
.and_then(|host| host.host.workspace_id());
if let Some(refresh_feature) =
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_host(
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_connection(
lifecycle_enabled,
Arc::clone(&resident_summary_source),
worker.prompts(),
@@ -1516,7 +1517,7 @@ where
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.committed_session_capture_handle(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
)?
{
feature_registry.add_module(subjektiv_memory);
@@ -1525,7 +1526,7 @@ where
&& let Some(subjektiv_sessions) =
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.workspace_client_handle(),
subjektiv_host.clone(),
)?
{
feature_registry.add_module(subjektiv_sessions);
@@ -1548,7 +1549,7 @@ where
}
if backend_job.is_none() && let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
lifecycle_enabled,
worker.workspace_client_handle(),
subjektiv_host.clone(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
@@ -1563,7 +1564,7 @@ where
worker.manifest().feature.subjektiv.clone(),
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
+116 -174
View File
@@ -4,8 +4,10 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(test)]
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -158,6 +160,7 @@ impl dyn WorkspaceClient + '_ {
}
}
#[cfg(test)]
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn execute_subjektiv_memory_operation(
@@ -327,9 +330,7 @@ pub fn workspace_http_memory_tools(client: Arc<dyn WorkspaceClient>) -> Vec<Tool
]
}
pub fn workspace_http_subjektiv_consolidation_tools(
client: Arc<dyn WorkspaceClient>,
) -> Vec<ToolDefinition> {
fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefinition> {
use SubjectConsolidationOperation as Operation;
[
(
@@ -371,14 +372,14 @@ pub fn workspace_http_subjektiv_consolidation_tools(
]
.into_iter()
.map(|(name, description, schema, operation)| {
let client = Arc::clone(&client);
let host = host.clone();
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(schema.clone()),
Arc::new(SubjectConsolidationTool {
client: Arc::clone(&client),
host: host.clone(),
operation,
}) as Arc<dyn Tool>,
)
@@ -398,7 +399,7 @@ enum SubjectConsolidationOperation {
}
struct SubjectConsolidationTool {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
operation: SubjectConsolidationOperation,
}
@@ -434,59 +435,17 @@ impl Tool for SubjectConsolidationTool {
)?)
}
};
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed(
"subjektiv consolidation requires Workspace authority".into(),
)
})?;
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
let parsed =
serde_json::from_str::<server_api::RepositoryApiError>(&response.body).ok();
if let Some((code, message)) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
)
})
.map(|diagnostic| (diagnostic.code.clone(), error.message.clone()))
}) {
let response = match self.host.memory(operation) {
Ok(response) => response,
Err(SubjektivHostError::Conflict { code, message }) => {
return Ok(ToolOutput {
summary: format!("Candidate decision requires reread: {code}."),
content: Some(
serde_json::json!({
"status": "error",
"error": { "code": code, "message": message }
})
.to_string(),
),
content: Some(serde_json::json!({"status":"error", "error":{"code":code,"message":message}}).to_string()),
attachments: Vec::new(),
});
}
let detail = parsed.map(|error| error.message).unwrap_or(response.body);
return Err(if matches!(response.status, 400 | 404 | 409 | 422) {
ToolError::InvalidArgument(detail)
} else {
ToolError::ExecutionFailed(detail)
});
}
let response: server_api::SubjektivMemoryBackendResponse =
serde_json::from_str(&response.body)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
Err(error) => return Err(error.into()),
};
let summary = match &response {
server_api::SubjektivMemoryBackendResponse::Query(value) => {
format!("Found {} subject Memory item(s).", value.items.len())
@@ -686,7 +645,7 @@ struct SubjectBehaviorRefreshState {
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
@@ -730,16 +689,12 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation_with(
self.client.as_ref(),
) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> {
match self.host.memory(
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
},
)? {
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
other => Err(SubjektivHostError::Unavailable(format!(
"unexpected subject resident context response: {other:?}"
))),
}
@@ -862,10 +817,11 @@ const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-befor
pub(crate) struct SubjektivResidentRestoreRefreshFeature {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: String,
workspace_id: Option<String>,
}
impl SubjektivResidentRestoreRefreshFeature {
#[cfg(test)]
pub(crate) fn for_host(
lifecycle_enabled: bool,
source: Arc<dyn SystemPromptContributionSource>,
@@ -875,6 +831,20 @@ impl SubjektivResidentRestoreRefreshFeature {
lifecycle_enabled.then(|| Self::new(source, prompts, workspace_id))
}
pub(crate) fn for_connection(
lifecycle_enabled: bool,
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: Option<String>,
) -> Option<Self> {
lifecycle_enabled.then_some(Self {
source,
prompts,
workspace_id,
})
}
#[cfg(test)]
pub(crate) fn new(
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -883,7 +853,7 @@ impl SubjektivResidentRestoreRefreshFeature {
Self {
source,
prompts,
workspace_id: workspace_id.into(),
workspace_id: Some(workspace_id.into()),
}
}
}
@@ -981,7 +951,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: String,
workspace_id: Option<String>,
}
#[async_trait]
@@ -1000,7 +970,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
context.system_items().append_resident_summary_refresh(
body,
session_store::PromptRenderProvenance {
workspace_id: Some(self.workspace_id.clone()),
workspace_id: self.workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
@@ -1114,51 +1084,25 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
}
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
manifest.feature.subjektiv.execution_enabled()
manifest.feature.subjektiv.profile.enabled
&& !manifest.feature.subjektiv.profile.consolidation_tools
}
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
if !ordinary_subjektiv_features_enabled(manifest) || host.is_none() {
return Ok(None);
}
manifest
.feature
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !client.is_available() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv resident Memory requires Backend Workspace API authority",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv resident Memory requires Backend Workspace API authority",
)
})?;
let settings = manifest
.feature
.subjektiv
.workspace_settings()
.expect("validated enabled subjektiv config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"subjektiv Workspace settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
));
}
.map_err(std::io::Error::other)?;
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
host: host.expect("checked explicit connection"),
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
@@ -1172,57 +1116,51 @@ pub(crate) struct SubjektivConsolidationFeatureInstallPlan {
impl SubjektivConsolidationFeatureInstallPlan {
pub(crate) fn prepare(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
) -> std::io::Result<Option<Self>> {
if !manifest.feature.subjektiv.profile.consolidation_tools {
let config = &manifest.feature.subjektiv;
if !config.profile.enabled || !config.profile.consolidation_tools || host.is_none() {
return Ok(None);
}
let config = &manifest.feature.subjektiv;
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !config.execution_enabled() || config.profile.extraction.enabled {
config.validate_execution().map_err(std::io::Error::other)?;
let host = host.expect("checked explicit connection");
if config.profile.extraction.enabled || !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation profile requires trusted subject settings without automatic extraction",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation tools require Backend Workspace API authority",
)
})?;
let settings = config
.workspace_settings()
.expect("validated enabled subjektiv config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"subjektiv settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
"subjektiv consolidation requires explicit Host grant without automatic extraction",
));
}
let system_prompt_override = prompts
.subjektiv_memory_consolidation_system(&settings.language)
.subjektiv_memory_consolidation_system(&host.host.settings().language)
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(Some(Self {
module: SubjektivConsolidationToolsFeature {
tools: workspace_http_subjektiv_consolidation_tools(client),
},
module: SubjektivConsolidationToolsFeature::new(host)?,
system_prompt_override,
}))
}
}
#[derive(Clone)]
pub(crate) struct SubjektivConsolidationToolsFeature {
pub struct SubjektivConsolidationToolsFeature {
tools: Vec<ToolDefinition>,
}
impl SubjektivConsolidationToolsFeature {
/// Reusable installation for a generic Job with an explicit attenuated Host.
pub fn new(host: SubjektivHostConnection) -> std::io::Result<Self> {
if !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Host did not grant consolidation",
));
}
Ok(Self {
tools: subjektiv_consolidation_tools(host),
})
}
}
impl FeatureModule for SubjektivConsolidationToolsFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(
@@ -1293,6 +1231,29 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn backend_resident_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
let host = crate::subjektiv::BackendSubjektivHost::from_resolved_config(
&manifest.feature.subjektiv,
client,
)?;
let host = host
.map(|host| {
SubjektivHostConnection::new(
host,
crate::subjektiv::SubjektivHostContext {
worker_id: manifest.worker.name.clone(),
session_id: "session-1".into(),
},
)
})
.transpose()?;
ordinary_subjektiv_resident_summary_source(manifest, host, prompts)
}
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
@@ -1601,7 +1562,7 @@ mod tests {
let plan = SubjektivConsolidationFeatureInstallPlan::prepare(
&manifest,
test_client(),
Some(crate::subjektiv::test_connection(test_client())),
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
)
.unwrap()
@@ -1696,13 +1657,9 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"),
));
let ready = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_client.clone(),
test_prompts(),
)
.unwrap()
.unwrap();
let ready = backend_resident_source(&manifest, ready_client.clone(), test_prompts())
.unwrap()
.unwrap();
let loaded = ready.load().await;
let SystemPromptContribution::Ready(loaded) = loaded else {
panic!("subject resident context must be present");
@@ -1722,13 +1679,9 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
None,
));
let ready_empty = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_empty_client,
test_prompts(),
)
.unwrap()
.unwrap();
let ready_empty = backend_resident_source(&manifest, ready_empty_client, test_prompts())
.unwrap()
.unwrap();
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
panic!("empty surface must retain Subject behavior");
};
@@ -1740,7 +1693,7 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Stale,
memory::backend::MemoryResidentSummaryAvailability::Failed,
] {
let source = ordinary_subjektiv_resident_summary_source(
let source = backend_resident_source(
&manifest,
Arc::new(SubjectResidentClient::new(
availability,
@@ -1765,10 +1718,9 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let initial = source.load().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
@@ -1838,10 +1790,9 @@ permission = "write"
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::denied());
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let SystemPromptContribution::Ready(failure) = source.load().await else {
panic!("fetch failure must be explicit in resident context");
@@ -1874,7 +1825,7 @@ permission = "write"
)
.unwrap();
assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
backend_resident_source(&disabled, test_client(), test_prompts())
.unwrap()
.is_none()
);
@@ -1886,30 +1837,22 @@ permission = "write"
Some("policy-only surface must not load"),
));
assert!(
ordinary_subjektiv_resident_summary_source(
&policy_only,
unattached_client.clone(),
test_prompts()
)
.unwrap()
.is_none()
backend_resident_source(&policy_only, unattached_client.clone(), test_prompts())
.unwrap()
.is_none()
);
assert!(unattached_client.paths.lock().unwrap().is_empty());
let foreign = subject_manifest("other-workspace");
assert!(
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
.is_err()
);
assert!(backend_resident_source(&foreign, test_client(), test_prompts()).is_err());
}
#[test]
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
let manifest = subject_manifest("workspace");
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let source = backend_resident_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
));
@@ -1923,10 +1866,9 @@ permission = "write"
#[test]
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
let manifest = subject_manifest("workspace");
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let source = backend_resident_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new(
source,
Arc::new(ArcSwap::from(
@@ -2034,7 +1976,7 @@ permission = "write"
(403, "subject_scope_mismatch"),
] {
let tool = SubjectConsolidationTool {
client: repository_error_client(status, code),
host: crate::subjektiv::test_connection(repository_error_client(status, code)),
operation: SubjectConsolidationOperation::DecideCandidate,
};
let output = tool
@@ -2051,7 +1993,7 @@ permission = "write"
#[test]
fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() {
let tools = workspace_http_subjektiv_consolidation_tools(test_client());
let tools = subjektiv_consolidation_tools(crate::subjektiv::test_connection(test_client()));
let names = tool_names(tools.clone());
assert_eq!(
names,
@@ -101,6 +101,7 @@ struct MemoryLifecycleTask {
capture: CommittedSessionCaptureHandle,
extensions: SessionExtensionHandle,
workspace_client: Arc<dyn WorkspaceClient>,
subjektiv_host: Option<crate::subjektiv::SubjektivHostConnection>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -175,6 +176,7 @@ impl MemoryLifecycleFeature {
capture,
extensions,
workspace_client,
subjektiv_host: None,
manifest,
client,
prompts,
@@ -192,32 +194,34 @@ impl SubjektivLifecycleFeature {
config: manifest::ResolvedSubjektivFeatureConfig,
capture: CommittedSessionCaptureHandle,
extensions: SessionExtensionHandle,
workspace_client: Arc<dyn WorkspaceClient>,
host: Option<crate::subjektiv::SubjektivHostConnection>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_context: WorkerWorkspaceContext,
event_tx: Option<broadcast::Sender<Event>>,
) -> std::io::Result<Option<Self>> {
if !lifecycle_enabled || !config.execution_enabled() || !config.profile.extraction.enabled {
if !lifecycle_enabled
|| !config.profile.enabled
|| host.is_none()
|| !config.profile.extraction.enabled
{
return Ok(None);
}
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv extraction requires Backend Workspace API authority",
));
}
Ok(Some(Self {
task: MemoryLifecycleTask {
config: LifecycleConfig::from_subjektiv(config),
target: ExtractionTarget::Subjektiv,
capture,
extensions,
workspace_client,
workspace_client: crate::unavailable_workspace_client(
None,
"subjektiv Host has no legacy Memory authority",
),
subjektiv_host: host,
manifest,
client,
prompts,
@@ -477,10 +481,11 @@ impl MemoryLifecycleTask {
),
ExtractionTarget::Subjektiv => MemoryStagingOutputState::new_subjektiv(
view.clone(),
Arc::clone(&self.workspace_client),
self.subjektiv_host
.clone()
.expect("subjektiv task has Host connection"),
source,
audit.run_id.to_string(),
capture.session_id.clone(),
),
};
let client = if let Some(model) = self.config.extraction.model.as_ref() {
@@ -499,6 +504,11 @@ impl MemoryLifecycleTask {
.config
.workspace_settings()
.map(|snapshot| snapshot.language)
.or_else(|| {
self.subjektiv_host
.as_ref()
.map(|host| host.host.settings().language)
})
else {
self.record_preparation_failure(
&audit,
@@ -737,14 +747,25 @@ impl MemoryLifecycleTask {
.with_workspace_settings(self.config.workspace_settings.as_ref());
let operation = memory::backend::MemoryConsolidateStagingOperation { force: false };
let request = if subjektiv {
self.workspace_client
.request_subjektiv_memory_staging_consolidation(operation)
.await
let host = self
.subjektiv_host
.as_ref()
.expect("subjektiv task has Host connection");
host.host
.request_consolidation(&host.context, operation)
.map_err(|error| {
super::memory::WorkspaceMemoryBackendError::Backend(error.to_string())
})
} else {
self.workspace_client
.request_memory_staging_consolidation(operation)
.await
};
let audit = if subjektiv {
audit.without_legacy_backend()
} else {
audit
};
match request {
Ok(output) => {
tracing::debug!(
@@ -1319,6 +1340,7 @@ permission = "write"
capture: capture_handle,
extensions,
workspace_client,
subjektiv_host: None,
manifest: test_manifest(),
client,
prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1379,7 +1401,7 @@ permission = "write"
manifest::ResolvedSubjektivFeatureConfig::default(),
capture,
extensions,
crate::worker::marker_workspace_client(None, "disabled"),
None,
test_manifest(),
Box::new(ScriptClient::new(Vec::new())),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1407,7 +1429,7 @@ permission = "write"
config,
capture,
extensions,
crate::worker::marker_workspace_client(None, "extraction-disabled"),
None,
test_manifest(),
Box::new(ScriptClient::new(Vec::new())),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1444,7 +1466,7 @@ permission = "write"
config,
capture,
extensions,
crate::worker::marker_workspace_client(None, "child-disabled"),
None,
test_manifest(),
Box::new(ScriptClient::new(Vec::new())),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1593,6 +1615,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
run_background_task(task).await;
@@ -1647,6 +1672,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
run_background_task(task).await;
@@ -1689,6 +1717,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
run_background_task(task).await;
@@ -1806,6 +1837,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
let registry = start_background_task(task);
tokio::time::timeout(Duration::from_secs(5), async {
@@ -41,7 +41,9 @@ pub(crate) struct MemoryStagingOutputState {
#[derive(Clone)]
enum MemoryStagingDestination {
WorkspaceMemory,
Subjektiv { session_id: String },
Subjektiv {
host: crate::subjektiv::SubjektivHostConnection,
},
}
impl MemoryStagingOutputState {
@@ -64,17 +66,19 @@ impl MemoryStagingOutputState {
pub(crate) fn new_subjektiv(
view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>,
host: crate::subjektiv::SubjektivHostConnection,
source: SourceRef,
extract_run_id: String,
session_id: String,
) -> Self {
Self {
view: Arc::new(view),
workspace_client,
workspace_client: crate::unavailable_workspace_client(
None,
"subjektiv Host has no legacy Memory authority",
),
source,
extract_run_id,
destination: MemoryStagingDestination::Subjektiv { session_id },
destination: MemoryStagingDestination::Subjektiv { host },
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
@@ -263,35 +267,11 @@ impl Tool for StageMemoryCandidateTool {
}
}
}
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}"
))
})?;
MemoryStagingDestination::Subjektiv { host } => {
let output = host
.host
.stage_candidate(&host.context, operation)
.map_err(ToolError::from)?;
vec![output.staging_id]
}
};
@@ -607,13 +587,16 @@ mod tests {
let client = Arc::new(RecordingSubjektivClient::default());
let state = MemoryStagingOutputState::new_subjektiv(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
client.clone(),
{
let mut host = crate::subjektiv::test_connection(client.clone());
host.context.session_id = "session-committed".into();
host
},
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
"run-1".to_string(),
"session-committed".to_string(),
);
let tool = StageMemoryCandidateTool {
state: state.clone(),
@@ -14,9 +14,7 @@ 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::is_builtin_subjektiv_consolidation_profile;
use crate::feature::builtin::memory_surface_output::{
MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition,
};
@@ -29,7 +27,10 @@ use crate::internal_worker::{
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec,
run_internal_worker_with_cancel_sender,
};
use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext, WorkspaceClient};
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
#[cfg(test)]
use crate::worker::WorkspaceClient;
use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext};
use manifest::WorkerManifest;
const TASK_NAME: &str = "subjektiv-memory-surface";
@@ -39,13 +40,13 @@ const EDITOR_MAX_TURNS: u32 = 3;
const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。";
#[derive(Clone)]
pub(crate) struct SubjektivSurfaceLifecycleFeature {
pub struct SubjektivSurfaceLifecycleFeature {
task: SubjektivSurfaceLifecycleTask,
}
#[derive(Clone)]
struct SubjektivSurfaceLifecycleTask {
workspace_client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -94,18 +95,20 @@ impl LlmClient for InputBudgetLlmClient {
impl SubjektivSurfaceLifecycleFeature {
pub(crate) fn from_manifest(
lifecycle_enabled: bool,
workspace_client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
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 {
if !lifecycle_enabled
|| !is_builtin_subjektiv_consolidation_profile(&manifest)
|| host.is_none()
{
return Ok(None);
}
Self::for_job(
workspace_client,
Self::for_host(
host.expect("checked connection"),
manifest,
client,
prompts,
@@ -114,8 +117,9 @@ impl SubjektivSurfaceLifecycleFeature {
.map(Some)
}
pub(crate) fn for_job(
workspace_client: Arc<dyn WorkspaceClient>,
/// Install the reusable surface Feature for an explicitly granted Host.
pub fn for_host(
host: SubjektivHostConnection,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -125,22 +129,16 @@ impl SubjektivSurfaceLifecycleFeature {
.feature
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !manifest.feature.subjektiv.execution_enabled() {
.map_err(std::io::Error::other)?;
if !manifest.feature.subjektiv.profile.enabled || !host.host.consolidation_granted() {
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",
std::io::ErrorKind::PermissionDenied,
"surface generation requires explicit Host consolidation grant",
));
}
Ok(Self {
task: SubjektivSurfaceLifecycleTask {
workspace_client,
host,
manifest,
client,
prompts,
@@ -150,7 +148,7 @@ impl SubjektivSurfaceLifecycleFeature {
}
/// The Job tool awaits this clean-context lifecycle before submitting its result.
pub(crate) async fn complete_for_job(
pub async fn complete_for_job(
&self,
cancellation: BackgroundTaskCancellation,
reusable_surface: Option<&serde_json::Value>,
@@ -234,7 +232,7 @@ impl SubjektivSurfaceLifecycleTask {
));
}
let generation = self
.workspace_client
.host
.prepare_subjektiv_memory_surface()
.await
.map_err(surface_hook_error)?;
@@ -299,7 +297,7 @@ impl SubjektivSurfaceLifecycleTask {
));
}
match self
.workspace_client
.host
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
generation_id: generation.generation_id.clone(),
points,
@@ -312,9 +310,8 @@ impl SubjektivSurfaceLifecycleTask {
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
}));
}
Err(WorkspaceMemoryBackendError::Http { status, .. })
if status == reqwest::StatusCode::CONFLICT
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
Err(SubjektivHostError::Conflict { .. })
if attempt + 1 < MAX_GENERATION_ATTEMPTS =>
{
continue;
}
@@ -334,18 +331,7 @@ impl SubjektivSurfaceLifecycleTask {
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 language = self.host.host.settings().language;
let system_prompt = self
.prompts
.load_full()
@@ -424,7 +410,7 @@ impl SubjektivSurfaceLifecycleTask {
job_confirmation_required: bool,
) -> Result<serde_json::Value, HookError> {
let failure = self
.workspace_client
.host
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
generation_id: generation.generation_id.clone(),
reason_code: reason_code.to_string(),
@@ -586,7 +572,7 @@ fn estimated_editor_request_tokens(request: &Request) -> Result<usize, HookError
Ok(request.len().saturating_add(3) / 4)
}
fn surface_hook_error(error: WorkspaceMemoryBackendError) -> HookError {
fn surface_hook_error(error: SubjektivHostError) -> HookError {
HookError::new(HookErrorCategory::Internal, error.to_string())
}
@@ -889,7 +875,7 @@ permission = "write"
calls: Arc<AtomicUsize>,
) -> SubjektivSurfaceLifecycleTask {
SubjektivSurfaceLifecycleTask {
workspace_client,
host: crate::subjektiv::test_connection(workspace_client),
manifest: test_manifest(),
client: Box::new(SurfaceEditorClient { calls }),
prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -13,7 +13,7 @@ use memory::extract::CandidateKind;
use schemars::JsonSchema;
use serde::Deserialize;
use server_api::{
SubjektivMemoryBackendOperation, SubjektivMemoryBackendRequest, SubjektivMemoryBackendResponse,
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse,
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest,
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
@@ -33,7 +33,11 @@ use crate::hook::{
PreRequestContext, RunCommitted, RunCommittedContext,
};
use crate::session_capture::{SessionCapture, SessionEntryEvidence};
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
use crate::subjektiv::SubjektivHostConnection;
#[cfg(test)]
use crate::worker::WorkspaceClient;
#[cfg(test)]
use server_api::SubjektivMemoryBackendRequest;
const QUERY_TOOL: &str = "SubjektivMemoryQuery";
const READ_TOOL: &str = "SubjektivMemoryRead";
@@ -58,7 +62,7 @@ pub(crate) struct SubjektivMemoryFeature {
#[derive(Clone)]
struct SubjektivMemoryState {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
capture: CommittedSessionCaptureHandle,
}
@@ -76,22 +80,17 @@ impl SubjektivMemoryFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
capture: CommittedSessionCaptureHandle,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
) -> std::io::Result<Option<Self>> {
if !config.execution_enabled() {
if !config.profile.enabled || host.is_none() {
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 Memory tools require Backend Workspace API authority",
));
}
let host = host.expect("checked explicit connection");
Ok(Some(Self {
state: SubjektivMemoryState { client, capture },
state: SubjektivMemoryState { host, capture },
}))
}
}
@@ -483,48 +482,7 @@ impl SubjektivMemoryState {
&self,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, ToolError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivMemory(
SubjektivMemoryBackendRequest { operation },
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
let parsed =
serde_json::from_str::<server_api::RepositoryApiError>(&response.body).ok();
if let Some((code, message)) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict" | "stale_cursor"
)
})
.map(|diagnostic| (diagnostic.code.as_str(), error.message.as_str()))
}) {
return Err(ToolError::StructuredConflict {
code: code.to_string(),
message: message.to_string(),
});
}
let detail = parsed
.map(|error| error.message)
.unwrap_or_else(|| response.body.clone());
let message = format!(
"subjektiv Memory backend returned HTTP {}: {detail}",
response.status
);
return if matches!(response.status, 400 | 404 | 409 | 422) {
Err(ToolError::InvalidArgument(message))
} else {
Err(ToolError::ExecutionFailed(message))
};
}
serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!("decode subjektiv response: {error}"))
})
self.host.memory(operation).map_err(Into::into)
}
fn backend_receipt_status(
@@ -628,7 +586,7 @@ struct PendingRetryHook {
#[async_trait]
impl Hook<PreLlmRequest> for PendingRetryHook {
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
replay_pending_committed_memory(&self.state)?;
replay_pending_committed_memory_with_policy(&self.state, true)?;
Ok(HookPreRequestAction::Continue)
}
}
@@ -649,6 +607,12 @@ impl Hook<BeforeSessionRewrite> for PendingRewriteHook {
}
fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), HookError> {
replay_pending_committed_memory_with_policy(state, false)
}
fn replay_pending_committed_memory_with_policy(
state: &SubjektivMemoryState,
allow_pending: bool,
) -> Result<(), HookError> {
let capture = state
.capture
.capture()
@@ -691,7 +655,11 @@ fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), H
vec![call.evidence],
item.proposal,
) {
failures.push(format!("{receipt_id}: {error}"));
if !allow_pending
|| !matches!(&error,ToolError::StructuredConflict {code,..} if code=="pending_commit")
{
failures.push(format!("{receipt_id}: {error}"));
}
}
}
if failures.is_empty() {
@@ -1109,7 +1077,7 @@ mod tests {
});
SubjektivMemoryFeature {
state: SubjektivMemoryState {
client: Arc::new(TestWorkspaceClient),
host: crate::subjektiv::test_connection(Arc::new(TestWorkspaceClient)),
capture,
},
}
@@ -1245,7 +1213,7 @@ mod tests {
"\\\u{0000}".repeat(server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES);
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ReadWorkspaceClient(response)),
host: crate::subjektiv::test_connection(Arc::new(ReadWorkspaceClient(response))),
capture: feature().state.capture,
},
operation: ReadOperation::Read,
@@ -1265,7 +1233,9 @@ mod tests {
let oversized_tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ReadWorkspaceClient(oversized_response)),
host: crate::subjektiv::test_connection(Arc::new(ReadWorkspaceClient(
oversized_response,
))),
capture: feature().state.capture,
},
operation: ReadOperation::Read,
@@ -1285,7 +1255,9 @@ mod tests {
for code in ["revision_conflict", "stale_cursor"] {
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ConflictWorkspaceClient(code)),
host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient(
code,
))),
capture: feature().state.capture,
},
operation: ReadOperation::Query,
@@ -1386,7 +1358,7 @@ mod tests {
});
PendingCommitHook {
state: SubjektivMemoryState {
client: client.clone(),
host: crate::subjektiv::test_connection(client.clone()),
capture,
},
}
@@ -1439,7 +1411,7 @@ mod tests {
});
let hook = PendingCommitHook {
state: SubjektivMemoryState {
client: client.clone(),
host: crate::subjektiv::test_connection(client.clone()),
capture,
},
};
@@ -1515,7 +1487,7 @@ mod tests {
})
});
let state = SubjektivMemoryState {
client: client.clone(),
host: crate::subjektiv::test_connection(client.clone()),
capture,
};
assert_eq!(
@@ -11,16 +11,20 @@ use async_trait::async_trait;
use schemars::JsonSchema;
use server_api::{
SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivSessionBackendOperation,
SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult,
SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest,
SubjektivSessionReadRequest, SubjektivSessionSearchRequest,
SubjektivSessionBackendResponse, SubjektivSessionBackendResult, SubjektivSessionDiagnosticCode,
SubjektivSessionErrorResponse, SubjektivSessionListRequest, SubjektivSessionReadRequest,
SubjektivSessionSearchRequest,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
use crate::subjektiv::SubjektivHostConnection;
#[cfg(test)]
use crate::worker::WorkspaceClient;
#[cfg(test)]
use server_api::SubjektivSessionBackendRequest;
const LIST_TOOL: &str = "SubjektivSessionList";
const SEARCH_TOOL: &str = "SubjektivSessionSearch";
@@ -37,28 +41,23 @@ pub(crate) struct SubjektivSessionFeature {
#[derive(Clone)]
struct SubjektivSessionState {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
}
impl SubjektivSessionFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
) -> std::io::Result<Option<Self>> {
if !config.execution_enabled() {
if !config.profile.enabled || host.is_none() {
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",
));
}
let host = host.expect("checked explicit connection");
Ok(Some(Self {
state: SubjektivSessionState { client },
state: SubjektivSessionState { host },
}))
}
}
@@ -154,25 +153,7 @@ impl SubjektivSessionState {
&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
)))
self.host.session(operation).map_err(Into::into)
}
}
@@ -293,7 +274,9 @@ mod tests {
}
fn state(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionState {
SubjektivSessionState { client }
SubjektivSessionState {
host: crate::subjektiv::test_connection(client),
}
}
fn feature(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionFeature {
@@ -363,7 +346,9 @@ mod tests {
let config = manifest::ResolvedSubjektivFeatureConfig::default();
let result = SubjektivSessionFeature::from_resolved_config(
&config,
Arc::new(SessionToolClient::new(list_response())),
Some(crate::subjektiv::test_connection(Arc::new(
SessionToolClient::new(list_response()),
))),
)
.unwrap();
assert!(result.is_none());
+58 -7
View File
@@ -8,7 +8,7 @@
//! cannot be preempted by dropping its tool future and must itself be bounded.
mod result;
pub use result::{JobResultFeature, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
use std::borrow::Cow;
use std::path::Path;
@@ -72,6 +72,28 @@ pub struct PreparedInternalJob {
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
system_prompt: String,
features: FeatureRegistryBuilder,
finalizer: Option<Arc<dyn JobResultFinalizer>>,
}
/// Explicit Host-supplied domain modules and the Profile requirements they satisfy.
/// Profile names never issue this grant. The Host must bind all module operations to
/// the immutable request/attempt and fence them on cancellation, timeout and shutdown.
#[derive(Default)]
pub struct JobFeatureGrant {
pub features: FeatureRegistryBuilder,
pub satisfied_requirements: Vec<&'static str>,
pub finalizer: Option<Arc<dyn JobResultFinalizer>>,
}
pub fn prepare_job_with_grant(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
grant: JobFeatureGrant,
) -> Result<PreparedInternalJob, JobExecutionError> {
let registry = ProfileDiscovery::user_settings().discover()?;
prepare_job_from_registry_with_grant(request, cwd, client, &registry, grant)
}
/// Resolve exactly the requested registry Profile using the existing user settings registry.
@@ -91,6 +113,16 @@ fn prepare_job_from_registry(
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
registry: &manifest::ProfileRegistry,
) -> Result<PreparedInternalJob, JobExecutionError> {
prepare_job_from_registry_with_grant(request, cwd, client, registry, JobFeatureGrant::default())
}
fn prepare_job_from_registry_with_grant(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
registry: &manifest::ProfileRegistry,
grant: JobFeatureGrant,
) -> Result<PreparedInternalJob, JobExecutionError> {
request.validate()?;
let resolved = ProfileResolver::new()
@@ -101,7 +133,16 @@ fn prepare_job_from_registry(
ProfileResolveOptions::with_worker_name("internal-job"),
)?;
let mut manifest = resolved.manifest;
validate_result_only_profile(&manifest)?;
validate_profile_with_grant(&manifest, &grant.satisfied_requirements)?;
if let Some(finalizer) = &grant.finalizer {
finalizer
.validate_profile(&manifest)
.map_err(JobExecutionError::UnsupportedProfile)?;
}
let mut tool_names = vec![SUBMIT_JOB_RESULT_TOOL.to_string()];
for descriptor in grant.features.descriptors() {
tool_names.extend(descriptor.tools.iter().map(|tool| tool.name.clone()));
}
let scope = Scope::empty();
let prompts = PromptCatalog::builtins_only()?;
let template = SystemPromptTemplate::parse(
@@ -113,7 +154,7 @@ fn prepare_job_from_registry(
cwd: Cow::Borrowed("(no filesystem)"),
language: &manifest.engine.language,
scope: &scope,
tool_names: vec![SUBMIT_JOB_RESULT_TOOL.into()],
tool_names,
feature_instructions: &[],
agents_md: None,
resident_summary: None,
@@ -148,6 +189,8 @@ fn prepare_job_from_registry(
manifest,
client,
system_prompt,
features: grant.features,
finalizer: grant.finalizer,
})
}
@@ -165,7 +208,11 @@ impl PreparedInternalJob {
where
F: FnOnce(tokio::sync::mpsc::Sender<()>),
{
let feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?;
let mut feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?;
if let Some(finalizer) = self.finalizer {
feature =
feature.with_finalizer(finalizer, self.manifest.clone(), self.client.clone_boxed());
}
let accepted = feature.accepted();
let engine_policy = self.manifest.engine.clone();
let spec = InternalWorkerSpec {
@@ -182,7 +229,7 @@ impl PreparedInternalJob {
system_prompt: self.system_prompt,
input: self.request.worker_input(&attempt_id)?,
cache_key: None,
features: FeatureRegistryBuilder::new().with_module(feature.clone()),
features: self.features.with_module(feature.clone()),
required_tools: &[SUBMIT_JOB_RESULT_TOOL],
authority: InternalWorkerAuthority {
workspace: WorkerWorkspaceContext::no_workspace(),
@@ -218,7 +265,11 @@ impl PreparedInternalJob {
}
/// Validate requirements, not Profile names. Nothing configured as active is silently stripped.
fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExecutionError> {
fn validate_profile_with_grant(
manifest: &WorkerManifest,
satisfied: &[&str],
) -> Result<(), JobExecutionError> {
let manifest::FeatureConfig {
task,
memory,
@@ -306,7 +357,7 @@ fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExec
manifest.session.record_event_trace,
),
] {
if enabled {
if enabled && !satisfied.contains(&name) {
unavailable.push(name);
}
}
+85 -1
View File
@@ -23,6 +23,29 @@ pub trait JobResultSink: Send + Sync {
fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String>;
}
/// Consumer-owned asynchronous completion before durable result acceptance.
/// The generic runner owns no domain algorithms. This runs in the result tool,
/// never an orphaned background task, with the selected policy/model and a
/// cancellation token that must propagate into any child resource cleanup.
#[async_trait]
pub trait JobResultFinalizer: Send + Sync {
fn validate_profile(&self, manifest: &manifest::WorkerManifest) -> Result<(), String>;
async fn finalize(
&self,
result: Value,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
cancellation: crate::feature::background::BackgroundTaskCancellation,
) -> Result<Value, ToolError>;
}
#[derive(Clone)]
struct FinalizerContext {
finalizer: Arc<dyn JobResultFinalizer>,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SubmitJobResultInput {
@@ -32,6 +55,7 @@ struct SubmitJobResultInput {
#[derive(Default)]
struct SubmissionState {
sealed: Option<Value>,
prepared: Option<Value>,
unconfirmed: Option<String>,
}
@@ -46,6 +70,9 @@ pub struct JobResultFeature {
state: Arc<Mutex<SubmissionState>>,
accepted: Arc<Mutex<Option<::job::JobResultSubmission>>>,
cancelled: Arc<Mutex<HashSet<String>>>,
serial: Arc<tokio::sync::Mutex<()>>,
cancellation: crate::feature::background::BackgroundTaskCancellation,
finalizer: Option<FinalizerContext>,
}
impl JobResultFeature {
@@ -65,9 +92,26 @@ impl JobResultFeature {
state: Arc::default(),
accepted: Arc::default(),
cancelled: Arc::default(),
serial: Arc::default(),
cancellation: Default::default(),
finalizer: None,
})
}
pub fn with_finalizer(
mut self,
finalizer: Arc<dyn JobResultFinalizer>,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
) -> Self {
self.finalizer = Some(FinalizerContext {
finalizer,
manifest,
client,
});
self
}
pub(super) fn accepted(&self) -> Arc<Mutex<Option<::job::JobResultSubmission>>> {
self.accepted.clone()
}
@@ -107,6 +151,44 @@ impl Tool for JobResultFeature {
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
::job::result_digest(&input.result, self.max_result_bytes)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let _serial = self.serial.lock().await;
if self.cancellation.is_cancelled() {
return Err(ToolError::Cancelled(ToolOutput {
summary: "Job result submission cancelled".into(),
content: None,
attachments: vec![],
}));
}
let cached = {
let state = self.state.lock().expect("Job submission state poisoned");
if state
.sealed
.as_ref()
.is_some_and(|sealed| sealed != &input.result)
{
return Err(ToolError::InvalidArgument(
"Job result is sealed; retry only the identical result".into(),
));
}
state.prepared.clone()
};
let prepared = if let Some(value) = cached {
value
} else if let Some(finalizer) = &self.finalizer {
finalizer
.finalizer
.finalize(
input.result.clone(),
finalizer.manifest.clone(),
finalizer.client.clone_boxed(),
self.cancellation.clone(),
)
.await?
} else {
input.result.clone()
};
::job::result_digest(&prepared, self.max_result_bytes)
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
let mut state = self.state.lock().expect("Job submission state poisoned");
if self
.cancelled
@@ -129,12 +211,13 @@ impl Tool for JobResultFeature {
} else {
// Seal before dispatch: a sink error can follow durable Host acceptance.
state.sealed = Some(input.result.clone());
state.prepared = Some(prepared.clone());
}
let submission = ::job::JobResultSubmission {
job_id: self.job_id.clone(),
attempt_id: self.attempt_id.clone(),
input_revision: self.input_revision.clone(),
result: input.result,
result: prepared,
};
let mut accepted = self.accepted.lock().expect("Job result state poisoned");
if accepted.is_none() {
@@ -156,6 +239,7 @@ impl Tool for JobResultFeature {
}
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
self.cancellation.cancel();
self.cancelled
.lock()
.expect("Job cancellation state poisoned")
+459
View File
@@ -1,14 +1,21 @@
//! Scripted in-process model executions, not provider/process tests.
use super::*;
use crate::feature::background::BackgroundTaskCancellation;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, Request};
use agen::tool::{Tool, ToolExecutionContext};
use agen::tool::{ToolError, ToolOutput};
use async_trait::async_trait;
use futures::Stream;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[derive(Clone)]
struct ScriptedClient {
@@ -592,6 +599,458 @@ fn unknown_prompt_and_model_are_not_substituted_even_with_injected_client() {
}
}
// A domain capability is issued by the Host, never by the selected Profile.
#[derive(Clone, Default)]
struct GrantedTool(Arc<AtomicUsize>);
#[async_trait]
impl Tool for GrantedTool {
async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutput {
summary: "granted".into(),
content: None,
attachments: vec![],
})
}
}
impl FeatureModule for GrantedTool {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("test-job-domain", "Test domain").with_tool(
ToolDeclaration::new("GrantedDomainTool", "Explicit Host capability"),
)
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let tool = self.clone();
context.tools().register(ToolContribution::new(
"GrantedDomainTool",
Arc::new(move || {
(
agen::tool::ToolMeta::new("GrantedDomainTool"),
Arc::new(tool.clone()) as Arc<dyn Tool>,
)
}),
))
}
}
#[tokio::test]
async fn domain_grant_is_explicit_not_profile_derived_and_preserves_selected_policy() {
let root = tempfile::tempdir().unwrap();
let mut profile = artifact("internal.flow_verifier_system", 100000, 1000);
profile["feature"] = json!({"task":{"enabled":true}});
let registry = registry(root.path(), profile.clone(), profile);
let marker = GrantedTool::default();
for selector in ["user:a", "user:b", "default"] {
assert!(matches!(
prepare_job_from_registry(
&request(selector),
root.path(),
Some(Box::new(ScriptedClient::new(vec![]))),
&registry,
),
Err(JobExecutionError::UnsupportedProfile(_))
));
let client = ScriptedClient::new(vec![
call("GrantedDomainTool", json!({})),
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})),
prose(),
]);
let job = prepare_job_from_registry_with_grant(
&request(selector),
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
features: FeatureRegistryBuilder::new().with_module(marker.clone()),
satisfied_requirements: vec!["feature.task"],
finalizer: None,
},
)
.unwrap();
assert_eq!(
job.manifest.model.model_id.as_deref(),
Some("scripted-model")
);
assert_eq!(
job.manifest.engine.instruction,
"internal.flow_verifier_system"
);
assert_eq!(job.manifest.engine.max_turns.unwrap().get(), MAX_JOB_TURNS);
assert_eq!(job.manifest.engine.max_tokens, Some(MAX_JOB_TOKENS));
assert!(job.manifest.scope.allow.is_empty());
assert!(job.manifest.delegation_scope.allow.is_empty());
job.run("attempt".into(), Arc::new(RecordingSink::default()), |_| {})
.await
.unwrap();
for sent in client.requests.lock().unwrap().iter() {
let mut names = sent
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
names.sort();
assert_eq!(names, ["GrantedDomainTool", SUBMIT_JOB_RESULT_TOOL]);
assert_eq!(sent.config.max_tokens, Some(MAX_JOB_TOKENS));
}
}
assert_eq!(marker.0.load(Ordering::SeqCst), 3);
}
#[test]
fn domain_grant_does_not_satisfy_unrelated_profile_requirements() {
let root = tempfile::tempdir().unwrap();
for (key, value, needle) in [
(
"feature",
json!({"task":{"enabled":true},"web":{"enabled":true}}),
"feature.web",
),
("scope", json!("workspace_read"), "scope"),
(
"delegation_scope",
json!("workspace_write"),
"delegation_scope",
),
] {
let mut profile = artifact("default", 512, 3);
profile[key] = value;
let registry = registry(root.path(), profile.clone(), profile);
let error = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(ScriptedClient::new(vec![]))),
&registry,
JobFeatureGrant {
features: FeatureRegistryBuilder::new().with_module(GrantedTool::default()),
satisfied_requirements: vec!["feature.task"],
finalizer: None,
},
)
.err()
.unwrap();
assert!(
matches!(error, JobExecutionError::UnsupportedProfile(_)),
"{error}"
);
assert!(error.to_string().contains(needle), "{error}");
}
}
#[derive(Default)]
struct ControlledFinalizer {
calls: AtomicUsize,
active: Arc<AtomicUsize>,
manifests: Mutex<Vec<manifest::WorkerManifest>>,
entered: tokio::sync::Notify,
release: tokio::sync::Notify,
cancellation_seen: tokio::sync::Notify,
cleanup: tokio::sync::Notify,
cancelled: AtomicBool,
gated: bool,
// Reusing a captured request with the supplied client proves transport identity.
probe: Option<Arc<Mutex<Vec<Request>>>>,
reject_profile: bool,
}
struct FinalizerResource(Arc<AtomicUsize>);
impl Drop for FinalizerResource {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait]
impl JobResultFinalizer for ControlledFinalizer {
fn validate_profile(&self, _: &manifest::WorkerManifest) -> Result<(), String> {
if self.reject_profile {
Err("test finalizer refuses selected policy".into())
} else {
Ok(())
}
}
async fn finalize(
&self,
result: Value,
manifest: manifest::WorkerManifest,
client: Box<dyn LlmClient>,
cancellation: BackgroundTaskCancellation,
) -> Result<Value, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.active.fetch_add(1, Ordering::SeqCst);
let _resource = FinalizerResource(self.active.clone());
self.manifests.lock().unwrap().push(manifest);
if let Some(requests) = &self.probe {
let request = requests.lock().unwrap()[0].clone();
let events = client
.stream(request)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let _: Vec<_> = futures::StreamExt::collect(events).await;
}
self.entered.notify_one();
if self.gated {
tokio::select! {
_ = self.release.notified() => {},
_ = cancellation.cancelled() => {
self.cancelled.store(true, Ordering::SeqCst);
self.cancellation_seen.notify_one();
self.cleanup.notified().await;
return Err(ToolError::Cancelled(ToolOutput {
summary: "finalizer cleaned up".into(), content: None, attachments: vec![],
}));
}
}
}
Ok(json!({"prepared":result,"completion":self.calls.load(Ordering::SeqCst)}))
}
}
async fn signalled(notify: &tokio::sync::Notify) {
tokio::time::timeout(std::time::Duration::from_secs(2), notify.notified())
.await
.unwrap();
}
#[tokio::test]
async fn result_finalization_is_awaited_with_selected_model_policy_and_no_orphan_resource() {
let root = tempfile::tempdir().unwrap();
let registry = registry(
root.path(),
artifact("default", 777, 4),
artifact("default", 512, 3),
);
let client = ScriptedClient::new(vec![
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"draft":true}})),
prose(),
prose(),
]);
let finalizer = Arc::new(ControlledFinalizer {
gated: true,
probe: Some(client.requests.clone()),
..Default::default()
});
let job = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
finalizer: Some(finalizer.clone()),
..Default::default()
},
)
.unwrap();
let expected = serde_json::to_value(&job.manifest).unwrap();
let sink = Arc::new(RecordingSink::default());
let recording = sink.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
let run = tokio::spawn(async move {
job.run("attempt".into(), recording, move |sender| {
let _ = tx.send(sender);
})
.await
});
let sender = rx.await.unwrap();
signalled(&finalizer.entered).await;
assert!(!run.is_finished());
assert!(
sink.0.lock().unwrap().is_empty(),
"sink cannot accept an unfinished result"
);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 1);
assert_eq!(
serde_json::to_value(&finalizer.manifests.lock().unwrap()[0]).unwrap(),
expected
);
assert_eq!(
client.requests.lock().unwrap().len(),
2,
"finalizer uses the injected selected transport"
);
finalizer.release.notify_one();
let accepted = tokio::time::timeout(std::time::Duration::from_secs(2), run)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(
accepted.submission.result,
json!({"prepared":{"draft":true},"completion":1})
);
assert_eq!(sink.0.lock().unwrap().as_slice(), &[accepted.submission]);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 0);
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1);
assert!(sender.is_closed());
}
#[tokio::test]
async fn cancellation_reaches_finalizer_and_runner_waits_for_cleanup_without_acceptance() {
let root = tempfile::tempdir().unwrap();
let registry = registry(
root.path(),
artifact("default", 512, 3),
artifact("default", 512, 3),
);
let finalizer = Arc::new(ControlledFinalizer {
gated: true,
..Default::default()
});
let job = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(ScriptedClient::new(vec![
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})),
prose(),
]))),
&registry,
JobFeatureGrant {
finalizer: Some(finalizer.clone()),
..Default::default()
},
)
.unwrap();
let sink = Arc::new(RecordingSink::default());
let recording = sink.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
let run = tokio::spawn(async move {
job.run("attempt".into(), recording, move |sender| {
let _ = tx.send(sender);
})
.await
});
let sender = rx.await.unwrap();
signalled(&finalizer.entered).await;
assert!(sink.0.lock().unwrap().is_empty());
sender.send(()).await.unwrap();
signalled(&finalizer.cancellation_seen).await;
assert!(finalizer.cancelled.load(Ordering::SeqCst));
assert!(
!run.is_finished(),
"cancellation must await consumer resource cleanup"
);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 1);
assert!(sink.0.lock().unwrap().is_empty());
finalizer.cleanup.notify_one();
let outcome = tokio::time::timeout(std::time::Duration::from_secs(2), run)
.await
.unwrap()
.unwrap();
assert!(
matches!(outcome, Err(JobExecutionError::Cancelled)),
"{outcome:?}"
);
assert!(sink.0.lock().unwrap().is_empty());
assert_eq!(finalizer.active.load(Ordering::SeqCst), 0);
assert!(sender.is_closed());
}
#[test]
fn finalizer_can_refuse_selected_profile_before_execution() {
let root = tempfile::tempdir().unwrap();
let registry = registry(
root.path(),
artifact("default", 512, 3),
artifact("default", 512, 3),
);
let finalizer = Arc::new(ControlledFinalizer {
reject_profile: true,
..Default::default()
});
let client = ScriptedClient::new(vec![]);
let error = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
finalizer: Some(finalizer.clone()),
..Default::default()
},
)
.err()
.unwrap();
assert!(matches!(error, JobExecutionError::UnsupportedProfile(_)));
assert!(
error
.to_string()
.contains("test finalizer refuses selected policy")
);
assert!(client.requests.lock().unwrap().is_empty());
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 0);
}
#[derive(Default)]
struct UncertainThenAcceptedSink(Mutex<Vec<::job::JobResultSubmission>>);
impl JobResultSink for UncertainThenAcceptedSink {
fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String> {
let mut submissions = self.0.lock().unwrap();
submissions.push(submission);
if submissions.len() == 1 {
Err("durable acceptance is uncertain".into())
} else {
Ok(())
}
}
}
#[tokio::test]
async fn exact_uncertain_sink_retry_reuses_prepared_outcome_without_rerunning_finalizer() {
let root = tempfile::tempdir().unwrap();
let job = prepared(root.path(), &request("user:a"), ScriptedClient::new(vec![]));
let finalizer = Arc::new(ControlledFinalizer::default());
let sink = Arc::new(UncertainThenAcceptedSink::default());
let feature = JobResultFeature::new(&request("user:a"), "attempt".into(), sink.clone())
.unwrap()
.with_finalizer(finalizer.clone(), job.manifest, job.client);
assert!(
feature
.execute(
r#"{"result":{"draft":true}}"#,
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert!(feature.unconfirmed().is_some());
assert!(feature.accepted().lock().unwrap().is_none());
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1);
assert!(
feature
.execute(
r#"{"result":{"draft":false}}"#,
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert_eq!(sink.0.lock().unwrap().len(), 1);
feature
.execute(
r#"{"result":{"draft":true}}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
feature
.execute(
r#"{"result":{"draft":true}}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(feature.unconfirmed().is_none());
let submissions = sink.0.lock().unwrap();
assert_eq!(submissions.len(), 2);
assert_eq!(submissions[0], submissions[1]);
assert_eq!(
submissions[0].result,
json!({"prepared":{"draft":true},"completion":1})
);
assert_eq!(
feature.accepted().lock().unwrap().as_ref(),
Some(&submissions[1])
);
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 0);
}
struct UnknownSink(Mutex<Vec<::job::JobResultSubmission>>);
impl JobResultSink for UnknownSink {
fn submit(&self, value: ::job::JobResultSubmission) -> Result<(), String> {
+1
View File
@@ -25,6 +25,7 @@ pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
pub mod spawn;
pub mod subjektiv;
pub mod wip;
mod internal_worker;
+3 -1
View File
@@ -712,7 +712,9 @@ impl SessionCapture {
fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance {
match origin {
WorkerHistoryProvenance::HumanInput { .. } => SessionEntryProvenance::HumanInput,
WorkerHistoryProvenance::HumanInput { .. } | WorkerHistoryProvenance::LocalHumanInput => {
SessionEntryProvenance::HumanInput
}
WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
WorkerHistoryProvenance::BackendInstruction { .. } => {
+700
View File
@@ -0,0 +1,700 @@
//! Explicit Host capability for subject-scoped Memory and committed Sessions.
//!
//! A manifest is policy, not a connection. Hosts bind Subject/storage authority
//! in their implementation; the Worker supplies its own Worker/Session context.
//! Neither Subject identity nor filesystem/Workspace authority is model input.
pub use crate::feature::builtin::memory::SubjektivConsolidationToolsFeature;
pub use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
use std::sync::Arc;
use std::time::Duration;
use agen::tool::ToolError;
use memory::backend::{
MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryStageCandidateOperation,
};
use server_api::*;
use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation};
/// Host-issued execution context, never deserialized from model arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostContext {
pub worker_id: String,
pub session_id: String,
}
/// Explicit local configuration. This is not a trusted Workspace snapshot and
/// is not persisted in the reusable Worker manifest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostSettings {
pub language: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SubjektivHostError {
#[error("{message}")]
Conflict { code: String, message: String },
#[error("{0}")]
InvalidArgument(String),
#[error("{0}")]
Unavailable(String),
}
impl From<SubjektivHostError> for ToolError {
fn from(error: SubjektivHostError) -> Self {
match error {
SubjektivHostError::Conflict { code, message } => {
Self::StructuredConflict { code, message }
}
SubjektivHostError::InvalidArgument(message) => Self::InvalidArgument(message),
SubjektivHostError::Unavailable(message) => Self::ExecutionFailed(message),
}
}
}
/// Object-safe capability implemented by Backend and standalone Hosts.
/// Implementations must enforce their bound Subject, Worker/Session authority,
/// committed evidence and (for Jobs) immutable batch attenuation. Knowing an
/// identifier is not a grant. No method grants ambient filesystem access.
pub trait SubjektivHost: Send + Sync + 'static {
fn settings(&self) -> SubjektivHostSettings;
/// Separate explicit grant for decision/surface execution; Profile policy
/// alone must never authorize consolidation tools.
fn consolidation_granted(&self) -> bool {
false
}
/// Optional real Backend identity, used only for prompt provenance.
fn workspace_id(&self) -> Option<String> {
None
}
fn memory(
&self,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError>;
fn session(
&self,
context: &SubjektivHostContext,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError>;
fn stage_candidate(
&self,
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError>;
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError>;
fn request_consolidation(
&self,
context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError>;
}
/// Backend adapter. Existing authenticated Workspace operations and snapshot
/// validation remain the authority; no local Workspace is synthesized.
pub struct BackendSubjektivHost {
client: Arc<dyn WorkspaceClient>,
settings: SubjektivHostSettings,
workspace_id: String,
consolidation_granted: bool,
}
impl BackendSubjektivHost {
pub fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Arc<dyn SubjektivHost>>> {
config.validate_execution().map_err(std::io::Error::other)?;
if !config.execution_enabled() {
return Ok(None);
}
let settings = config
.workspace_settings()
.expect("execution requires trusted settings");
if !client.is_available() || client.workspace_id() != Some(settings.workspace_id.as_str()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv requires available Backend authority matching trusted Workspace settings",
));
}
Ok(Some(Arc::new(Self {
client,
workspace_id: settings.workspace_id,
settings: SubjektivHostSettings {
language: settings.language,
},
consolidation_granted: config.profile.consolidation_tools,
})))
}
fn execute<T: serde::de::DeserializeOwned>(
&self,
operation: WorkspaceServerOperation,
) -> Result<T, SubjektivHostError> {
let response = self
.client
.execute_server_operation(operation)
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
decode(response)
}
}
fn decode<T: serde::de::DeserializeOwned>(
response: crate::WorkspaceResponse,
) -> Result<T, SubjektivHostError> {
if !response.is_success() {
let parsed = serde_json::from_str::<RepositoryApiError>(&response.body).ok();
let message = parsed
.as_ref()
.map(|error| error.message.clone())
.unwrap_or(response.body);
if let Some(code) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
| "stale_cursor"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
)
})
.map(|diagnostic| diagnostic.code.clone())
}) {
return Err(SubjektivHostError::Conflict { code, message });
}
return Err(if response.status == 409 {
SubjektivHostError::Conflict {
code: "conflict".into(),
message,
}
} else if matches!(response.status, 400 | 404 | 422) {
SubjektivHostError::InvalidArgument(message)
} else {
SubjektivHostError::Unavailable(message)
});
}
serde_json::from_str(&response.body).map_err(|error| {
SubjektivHostError::Unavailable(format!("decode subjektiv response: {error}"))
})
}
impl SubjektivHost for BackendSubjektivHost {
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: self.settings.language.clone(),
}
}
fn workspace_id(&self) -> Option<String> {
Some(self.workspace_id.clone())
}
fn consolidation_granted(&self) -> bool {
self.consolidation_granted
}
fn memory(
&self,
_context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
// Preserve the bounded resident-context request timeout.
if matches!(
operation,
SubjektivMemoryBackendOperation::ResidentContext(_)
) {
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/memory", self.workspace_id),
serde_json::to_string(&SubjektivMemoryBackendRequest { operation })
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
return decode(
self.client
.execute_with_timeout(request, Duration::from_secs(5))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
}
self.execute(WorkspaceServerOperation::SubjektivMemory(
SubjektivMemoryBackendRequest { operation },
))
}
fn session(
&self,
_context: &SubjektivHostContext,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
SubjektivSessionBackendRequest { operation },
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
// Session domain diagnostics are typed even on non-2xx responses.
if let Ok(value) = serde_json::from_str(&response.body) {
return Ok(value);
}
decode(response)
}
fn stage_candidate(
&self,
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivStageCandidate(
SubjektivStageCandidateRequest {
session_id: context.session_id.clone(),
operation,
},
))
}
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivRecordSession(
SubjektivRecordSessionRequest {
session_id: context.session_id.clone(),
create_if_missing,
},
))
}
fn request_consolidation(
&self,
_context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/consolidation", self.workspace_id),
serde_json::to_string(&MemoryConsolidateStagingRequest {
force: operation.force,
})
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
let response: MemoryConsolidationResponse = decode(response)?;
Ok(MemoryConsolidationOutput {
status: response.status,
summary: response.summary,
candidate_count: response.candidate_count,
total_bytes: response.total_bytes,
})
}
}
/// Live connection with Worker-supplied authority. Not serializable and never
/// inherited by an ordinary SubWorker.
#[derive(Clone)]
pub struct SubjektivHostConnection {
pub host: Arc<dyn SubjektivHost>,
pub context: SubjektivHostContext,
}
impl SubjektivHostConnection {
pub fn new(
host: Arc<dyn SubjektivHost>,
context: SubjektivHostContext,
) -> std::io::Result<Self> {
let language = host.settings().language;
if context.worker_id.trim().is_empty()
|| context.session_id.trim().is_empty()
|| context.worker_id.chars().any(char::is_control)
|| context.session_id.chars().any(char::is_control)
|| !manifest::is_normalized_workspace_memory_language(&language)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid subjektiv Host context/settings",
));
}
Ok(Self { host, context })
}
pub fn memory(
&self,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
if let SubjektivMemoryBackendOperation::StageExplicit(input) = &operation
&& input.session_id != self.context.session_id
{
return Err(SubjektivHostError::InvalidArgument(
"explicit staging targets a foreign Session".into(),
));
}
if matches!(
operation,
SubjektivMemoryBackendOperation::DecideCandidate(_)
| SubjektivMemoryBackendOperation::PrepareSurface(_)
| SubjektivMemoryBackendOperation::PublishSurface(_)
| SubjektivMemoryBackendOperation::FailSurface(_)
) && !self.host.consolidation_granted()
{
return Err(SubjektivHostError::InvalidArgument(
"Host did not grant consolidation".into(),
));
}
self.host.memory(&self.context, operation)
}
pub fn session(
&self,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
self.host.session(&self.context, operation)
}
pub async fn prepare_subjektiv_memory_surface(
&self,
) -> Result<SubjektivSurfacePrepareResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::PrepareSurface(
SubjektivSurfacePrepareRequest {},
))? {
SubjektivMemoryBackendResponse::SurfacePrepared(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface preparation response".into(),
)),
}
}
pub async fn publish_subjektiv_memory_surface(
&self,
input: SubjektivSurfacePublishRequest,
) -> Result<SubjektivSurfacePublishResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::PublishSurface(input))? {
SubjektivMemoryBackendResponse::SurfacePublished(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface publication response".into(),
)),
}
}
pub async fn fail_subjektiv_memory_surface(
&self,
input: SubjektivSurfaceFailureRequest,
) -> Result<SubjektivSurfaceFailureResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::FailSurface(input))? {
SubjektivMemoryBackendResponse::SurfaceFailed(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface failure response".into(),
)),
}
}
}
#[cfg(test)]
pub(crate) fn test_connection(client: Arc<dyn WorkspaceClient>) -> SubjektivHostConnection {
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
config.profile.enabled = true;
config.profile.consolidation_tools = true;
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: client.workspace_id().unwrap().to_string(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
SubjektivHostConnection::new(
BackendSubjektivHost::from_resolved_config(&config, client)
.unwrap()
.unwrap(),
SubjektivHostContext {
worker_id: "worker-test".into(),
session_id: "session-1".into(),
},
)
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerControllerTransport,
WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext,
};
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
use futures_util::Stream;
use session_store::{CombinedStore, FsStore, FsWorkerStore, WorkerMetadataStore};
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Default)]
struct LocalHost {
contexts: Mutex<Vec<SubjektivHostContext>>,
attributed: Mutex<Vec<(SubjektivHostContext, bool)>>,
}
impl SubjektivHost for LocalHost {
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: "English".into(),
}
}
fn memory(
&self,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
self.contexts.lock().unwrap().push(context.clone());
match operation {
SubjektivMemoryBackendOperation::ResidentContext(_) => {
Ok(SubjektivMemoryBackendResponse::ResidentContext(
SubjektivResidentContextOutput {
behavior_md: "Explicit local subject behavior.".into(),
behavior_revision: 1,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability:
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
content: None,
},
},
))
}
_ => Err(SubjektivHostError::Unavailable(
"not granted by test Host".into(),
)),
}
}
fn session(
&self,
_: &SubjektivHostContext,
_: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("not used".into()))
}
fn stage_candidate(
&self,
_: &SubjektivHostContext,
_: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("not used".into()))
}
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.attributed
.lock()
.unwrap()
.push((context.clone(), create_if_missing));
Ok(SubjektivRecordSessionResponse {
subject_id: "test-owned-local-subject".into(),
session_id: context.session_id.clone(),
})
}
fn request_consolidation(
&self,
_: &SubjektivHostContext,
_: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("no candidates".into()))
}
}
#[derive(Clone)]
struct ScriptedClient(tokio::sync::mpsc::UnboundedSender<Request>);
#[async_trait]
impl LlmClient for ScriptedClient {
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>
{
self.0.send(request).unwrap();
Ok(Box::pin(futures_util::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "Done.")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
async fn launch_case(enabled: bool, attach: bool) {
let temp = tempfile::tempdir().unwrap();
let _runtime_sandbox =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let name = format!("subjektiv-host-{}", uuid::Uuid::now_v7());
let mut manifest = WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "{name}"
[model]
scheme = "anthropic"
model_id = "scripted-model"
[engine]
[scope]
allow = []
[feature.task]
enabled = true
"#
))
.unwrap();
manifest.feature.subjektiv.profile.enabled = enabled;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest.feature.subjektiv.profile.extraction.threshold = Some(u64::MAX);
assert!(manifest.feature.subjektiv.workspace_settings.is_none());
let store = CombinedStore::new(
FsStore::new(temp.path().join("sessions")).unwrap(),
FsWorkerStore::new(temp.path().join("workers")).unwrap(),
);
let host = Arc::new(LocalHost::default());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let bootstrap = WorkerBootstrap::new(
manifest,
store.clone(),
crate::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash-output"),
},
WorkerControllerTransport::InProcess,
)
.with_model_client(ScriptedClient(tx));
let bootstrap = if attach {
bootstrap.with_subjektiv_host(host.clone())
} else {
bootstrap
};
let prepared = bootstrap.prepare().await.unwrap();
let expected = SubjektivHostContext {
worker_id: name.clone(),
session_id: prepared.worker().session_id().to_string(),
};
assert!(
prepared
.worker()
.manifest()
.feature
.subjektiv
.workspace_settings
.is_none()
);
assert!(prepared.worker().workspace_id().is_none());
assert!(
prepared
.worker()
.workspace_client()
.workspace_id()
.is_none()
);
let controller = prepared.start().await.unwrap();
controller
.handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Hello",
))
.await
.unwrap();
let request = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
let tools = request
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(
tools.contains(&"TaskCreate"),
"ordinary Features must survive"
);
for name in [
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryRemember",
"SubjektivSessionList",
"SubjektivSessionRead",
] {
assert_eq!(tools.contains(&name), enabled && attach, "{name}");
}
assert!(!tools.contains(&"MemoryApplyCandidate"));
let rendered = format!(
"{}\n{}",
request.system_prompt.as_deref().unwrap_or_default(),
request
.items
.iter()
.filter_map(|item| item.as_text())
.collect::<Vec<_>>()
.join("\n")
);
assert_eq!(
rendered.contains("Explicit local subject behavior."),
enabled && attach
);
if enabled && attach {
assert_eq!(
*host.attributed.lock().unwrap(),
vec![(expected.clone(), true)]
);
assert!(
host.contexts
.lock()
.unwrap()
.iter()
.all(|context| context == &expected)
);
let metadata = store.read_by_name(&name).unwrap().unwrap();
assert!(matches!(
metadata.subjektiv_session_attribution,
Some(session_store::SubjektivSessionAttributionState::Confirmed { .. })
));
} else {
assert!(host.contexts.lock().unwrap().is_empty());
assert!(host.attributed.lock().unwrap().is_empty());
}
controller
.handle
.send(Method::Shutdown {
command: protocol::WorkerCommandEnvelope::new(1),
})
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(5), controller.shutdown)
.await
.unwrap()
.unwrap();
controller.controller_task.await.unwrap();
}
#[tokio::test]
async fn explicit_local_host_installs_ordinary_features_without_workspace_snapshot() {
launch_case(true, true).await;
}
#[tokio::test]
async fn enabled_policy_without_host_is_inert() {
launch_case(true, false).await;
}
#[tokio::test]
async fn disabled_policy_with_host_has_no_subjektiv_side_effects() {
launch_case(false, true).await;
}
#[test]
fn consolidation_installation_requires_separate_explicit_host_grant() {
let connection = SubjektivHostConnection::new(
Arc::new(LocalHost::default()),
SubjektivHostContext {
worker_id: "test-worker".into(),
session_id: "test-session".into(),
},
)
.unwrap();
assert!(SubjektivConsolidationToolsFeature::new(connection).is_err());
}
}
+162 -62
View File
@@ -357,6 +357,9 @@ pub(crate) fn authenticated_input_provenance(
) -> WorkerHistoryProvenance {
match source {
protocol::AuthenticatedInputSource::UntrustedWire => WorkerHistoryProvenance::LegacyUnknown,
protocol::AuthenticatedInputSource::LocalOperator => {
WorkerHistoryProvenance::LocalHumanInput
}
protocol::AuthenticatedInputSource::Account { account_id } => {
WorkerHistoryProvenance::HumanInput {
account_id: account_id.clone(),
@@ -2752,6 +2755,7 @@ 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>>,
subjektiv_host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>,
/// One-shot generic Feature lifecycle notification required after restore.
restore_feature_lifecycle_pending: bool,
/// Complete system prompt replacement installed by an enabled Feature.
@@ -3014,6 +3018,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -3233,6 +3238,49 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.workspace_context.client_handle()
}
/// Bind a live capability before Feature installation. This is deliberately
/// separate from Workspace authority and is not inherited by SubWorkers.
pub fn bind_subjektiv_host(&mut self, host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>) {
self.subjektiv_host = host;
}
pub fn subjektiv_host_connection(
&self,
) -> std::io::Result<Option<crate::subjektiv::SubjektivHostConnection>> {
use crate::subjektiv::{
BackendSubjektivHost, SubjektivHostConnection, SubjektivHostContext,
};
let config = &self.manifest.feature.subjektiv;
config.validate_execution().map_err(std::io::Error::other)?;
if !config.profile.enabled {
return Ok(None);
}
let host = if let Some(host) = &self.subjektiv_host {
if config.workspace_settings.is_some()
|| self.workspace_id().is_some()
|| self.workspace_client().workspace_id().is_some()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"explicit local subjektiv Host cannot replace trusted Backend Workspace authority",
));
}
Some(host.clone())
} else {
BackendSubjektivHost::from_resolved_config(config, self.workspace_client_handle())?
};
host.map(|host| {
SubjektivHostConnection::new(
host,
SubjektivHostContext {
worker_id: self.manifest.worker.name.clone(),
session_id: self.session_id().to_string(),
},
)
})
.transpose()
}
/// Bind the host-owned Worker-session observation projection. The provider
/// is responsible for workspace authorization and per-capture revalidation.
pub fn bind_worker_observation_provider(
@@ -7324,6 +7372,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -7433,64 +7482,62 @@ where
create_if_missing,
initial_diagnostic.to_string(),
))?;
let response = match self.workspace_client().execute_server_operation(
WorkspaceServerOperation::SubjektivRecordSession(
server_api::SubjektivRecordSessionRequest {
session_id: session_id.to_string(),
create_if_missing,
},
),
) {
Ok(response) => response,
Err(error) => {
let message = bounded_attribution_diagnostic(format!(
"Session attribution transport outcome is unknown: {error}"
));
self.persist_subjektiv_session_attribution(
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
),
)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: incomplete_subjektiv_attribution_label(create_if_missing),
message,
});
}
};
if !response.is_success() {
let message = bounded_attribution_diagnostic(format!(
"Session attribution was rejected with HTTP {}: {}",
response.status, response.body
));
let state = if create_if_missing {
SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: message.clone(),
}
} else {
SubjektivSessionAttributionState::LegacyUnknown {
session_id,
diagnostic: message.clone(),
}
};
self.persist_subjektiv_session_attribution(state)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: if create_if_missing {
"failed"
} else {
"legacy_unknown"
},
message,
});
}
let output: server_api::SubjektivRecordSessionResponse =
match serde_json::from_str(&response.body) {
let output = if self.subjektiv_host.is_some() {
let connection = self
.subjektiv_host_connection()
.map_err(|error| WorkerError::SubjektivSessionAttribution {
state: "failed",
message: error.to_string(),
})?
.ok_or_else(|| WorkerError::SubjektivSessionAttribution {
state: "failed",
message: "Session attribution requires enabled explicit Host capability".into(),
})?;
match connection
.host
.record_session(&connection.context, create_if_missing)
{
Ok(output) => output,
Err(error) => {
let message = bounded_attribution_diagnostic(error.to_string());
let rejected =
!matches!(error, crate::subjektiv::SubjektivHostError::Unavailable(_));
let state = if create_if_missing && rejected {
SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: message.clone(),
}
} else {
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
)
};
self.persist_subjektiv_session_attribution(state)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: if create_if_missing && rejected {
"failed"
} else {
incomplete_subjektiv_attribution_label(create_if_missing)
},
message,
});
}
}
} else {
let response = match self.workspace_client().execute_server_operation(
WorkspaceServerOperation::SubjektivRecordSession(
server_api::SubjektivRecordSessionRequest {
session_id: session_id.to_string(),
create_if_missing,
},
),
) {
Ok(response) => response,
Err(error) => {
let message = bounded_attribution_diagnostic(format!(
"Session attribution response outcome is unknown: {error}"
"Session attribution transport outcome is unknown: {error}"
));
self.persist_subjektiv_session_attribution(
incomplete_subjektiv_attribution_state(
@@ -7505,6 +7552,54 @@ where
});
}
};
if !response.is_success() {
let message = bounded_attribution_diagnostic(format!(
"Session attribution was rejected with HTTP {}: {}",
response.status, response.body
));
let state = if create_if_missing {
SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: message.clone(),
}
} else {
SubjektivSessionAttributionState::LegacyUnknown {
session_id,
diagnostic: message.clone(),
}
};
self.persist_subjektiv_session_attribution(state)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: if create_if_missing {
"failed"
} else {
"legacy_unknown"
},
message,
});
}
let output: server_api::SubjektivRecordSessionResponse =
match serde_json::from_str(&response.body) {
Ok(output) => output,
Err(error) => {
let message = bounded_attribution_diagnostic(format!(
"Session attribution response outcome is unknown: {error}"
));
self.persist_subjektiv_session_attribution(
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
),
)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: incomplete_subjektiv_attribution_label(create_if_missing),
message,
});
}
};
output
};
if output.session_id != session_id.to_string()
|| output.subject_id.trim().is_empty()
|| output.subject_id.chars().any(char::is_control)
@@ -7616,6 +7711,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -7744,6 +7840,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -8130,6 +8227,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: true,
feature_system_prompt_override: None,
user_segments: state.user_segments,
@@ -9852,7 +9950,7 @@ mod build_summary_prompt_tests {
});
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
manifest,
client,
Some(crate::subjektiv::test_connection(client)),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
)
.unwrap()
@@ -14731,12 +14829,14 @@ permission = "write"
let resident_source = || {
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
&manifest,
Arc::new(RestoreSubjectResidentClient {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("literal /prepare()".into()),
load_count: Arc::default(),
behavior_revision: Some(behavior_revision.clone()),
}),
Some(crate::subjektiv::test_connection(Arc::new(
RestoreSubjectResidentClient {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("literal /prepare()".into()),
load_count: Arc::default(),
behavior_revision: Some(behavior_revision.clone()),
},
))),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
)
.unwrap()
+2
View File
@@ -16,6 +16,8 @@ axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
config-source.workspace = true
feature-storage.workspace = true
subjektiv.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
job.workspace = true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -151,23 +151,7 @@ fn bounded_candidate_batch(
ids: impl IntoIterator<Item = String>,
max_result_bytes: u32,
) -> Result<Vec<String>> {
// Reserve the bounded subject and Host surface envelope. Each candidate ID
// appears twice (input claims and verified outcome); account for JSON escaping,
// not raw UTF-8 bytes. The fixed per-item allowance covers action and framing.
let mut estimated_bytes = 2_048_usize;
let mut selected = Vec::new();
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
let id_bytes = serde_json::to_vec(&id)
.map_err(|e| Error::InvalidInput(e.to_string()))?
.len();
let additional = 2 * id_bytes + 128;
if estimated_bytes + additional > max_result_bytes as usize {
break;
}
estimated_bytes += additional;
selected.push(id);
}
Ok(selected)
subjektiv::job::bounded_candidate_batch(ids, max_result_bytes).map_err(Error::from)
}
pub(super) fn dispatch(
@@ -234,25 +218,6 @@ pub(super) fn dispatch(
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
surface: SurfaceResult,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
reason_code: Option<String>,
}
pub(super) fn validate_result(
api: &WorkspaceApi,
request: &BackendJobRequest,
@@ -262,39 +227,18 @@ pub(super) fn validate_result(
let Some(grant) = &request.grants.subjektiv_consolidation else {
return Ok(());
};
let result: ConsolidationResult = serde_json::from_value(value.clone())
.map_err(|e| Error::InvalidInput(format!("invalid consolidation result: {e}")))?;
if result.subject_id != grant.subject_id || result.candidate_ids != grant.candidate_ids {
return Err(Error::InvalidInput(
"consolidation result does not match the immutable Subject/batch grant".into(),
));
}
let store =
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|e| Error::Store(e.to_string()))?;
for candidate_id in &grant.candidate_ids {
if store
.staging_resolution(&grant.subject_id, candidate_id)
.map_err(|e| Error::Store(e.to_string()))?
.is_none()
{
return Err(Error::InvalidInput(format!(
"consolidation candidate `{candidate_id}` is unresolved"
)));
}
}
store
.validate_job_surface_outcome(
&grant.subject_id,
&result.surface.generation_id,
result.surface.store_revision,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
&request.job_id,
&attempt.attempt_id,
)
.map_err(|e| Error::InvalidInput(format!("consolidation surface result mismatch: {e}")))
subjektiv::job::validate_result(
&store,
&grant.subject_id,
&grant.candidate_ids,
&request.job_id,
&attempt.attempt_id,
value,
)
.map_err(Error::from)
}
/// Persist actual candidate dispositions alongside the separate surface outcome.
@@ -313,28 +257,8 @@ pub(super) fn with_dispositions(
let store =
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|e| Error::Store(e.to_string()))?;
let mut dispositions = Vec::new();
for id in &grant.candidate_ids {
let resolution = store
.staging_resolution(&grant.subject_id, id)
.map_err(|e| Error::Store(e.to_string()))?
.ok_or_else(|| {
Error::InvalidInput(format!("candidate `{id}` has no durable disposition"))
})?;
// Full reasons, affected revisions and source edges remain in the existing
// immutable receipt store. Duplicating them makes a valid bounded batch's
// completion unbounded; this result is its verified compact projection.
dispositions.push(serde_json::json!({ "candidate_id": id, "action": resolution.action }));
}
let mut enriched = value.clone();
enriched
.as_object_mut()
.ok_or_else(|| Error::InvalidInput("consolidation result must be an object".into()))?
.insert(
"candidate_dispositions".into(),
serde_json::Value::Array(dispositions),
);
Ok(enriched)
subjektiv::job::with_dispositions(&store, &grant.subject_id, &grant.candidate_ids, value)
.map_err(Error::from)
}
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ server-api = { workspace = true }
workdir = { workspace = true }
tokio-tungstenite = { workspace = true }
session-store = { workspace = true }
standalone = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true }
tui = { workspace = true }
+4 -1
View File
@@ -27,6 +27,7 @@ pub(crate) enum CliCommand {
Plugin,
Mcp,
Session,
Subject,
Login,
}
@@ -46,6 +47,7 @@ impl CliCommand {
CliCommand::Plugin => "yoi plugin",
CliCommand::Mcp => "yoi mcp",
CliCommand::Session => "yoi session",
CliCommand::Subject => "yoi subject",
CliCommand::Login => "yoi login",
}
}
@@ -65,7 +67,8 @@ impl CliCommand {
| CliCommand::WorkerCleanup
| CliCommand::Plugin
| CliCommand::Mcp
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
| CliCommand::Session
| CliCommand::Subject => CliConnectionRequirement::LocalOnly,
}
}
}
+213 -6
View File
@@ -3,6 +3,7 @@ mod mcp_cli;
mod objective_cli;
mod plugin_cli;
mod session_cli;
mod subject_cli;
mod ticket_cli;
mod worker_cleanup_cli;
mod workspace_bootstrap;
@@ -41,6 +42,10 @@ enum Mode {
target: client::ResolvedTarget,
},
Session(session_cli::SessionCli),
Subject {
cli: subject_cli::SubjectCli,
target: Box<dyn Target>,
},
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
Ticket {
cli: ticket_cli::TicketCli,
@@ -171,6 +176,26 @@ async fn run(mode: Mode) -> ExitCode {
}
}
}
Mode::Subject { cli, target } => {
let result = if cli == subject_cli::SubjectCli::Help {
Ok(subject_cli::HELP.to_owned())
} else {
match target.spawn_worker() {
Ok(spawn) => subject_cli::run(cli, &spawn.state_dir),
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}
};
match result {
Ok(output) => {
print!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("yoi subject: {error}");
ExitCode::FAILURE
}
}
}
Mode::Session(cli) => match session_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
@@ -406,6 +431,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
LaunchMode::Spawn {
worker_name: None,
profile: None,
subject_id: None,
}
};
return Ok(Mode::Tui {
@@ -454,6 +480,21 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
.map_err(|error| ParseError(error.to_string()))?;
return Ok(Mode::Objective { cli, target });
}
"subject" => {
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(ParseError(
"yoi subject is local-only and does not accept Backend selectors".to_owned(),
));
}
if !target_selection.explicit_local {
return Err(ParseError(
"yoi subject requires explicit --local selection".to_owned(),
));
}
let cli = subject_cli::parse(&args[1..])?;
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Subject)?;
return Ok(Mode::Subject { cli, target });
}
"session" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
let session_cli = session_cli::parse_session_args(&args[1..])
@@ -828,6 +869,19 @@ fn required_option_value<'a>(
Ok(value)
}
fn set_subject_selector(selector: &mut Option<String>, value: &str) -> Result<(), ParseError> {
if selector.is_some() {
return Err(ParseError(
"--subject must not be provided more than once".to_owned(),
));
}
if value.trim().is_empty() || value.starts_with('-') {
return Err(ParseError("--subject requires a nonempty ID".to_owned()));
}
*selector = Some(value.to_owned());
Ok(())
}
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
@@ -837,6 +891,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
let mut worker_name = None;
let mut session = None;
let mut profile = None;
let mut subject_id = None;
let mut socket_override = None;
let mut runtime_id = None;
let mut worker_id = None;
@@ -884,6 +939,15 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
socket_override = Some(PathBuf::from(value));
i += 2;
}
"--subject" => {
let value = required_option_value(args, i, "--subject")?;
set_subject_selector(&mut subject_id, value)?;
i += 2;
}
arg if arg.starts_with("--subject=") => {
set_subject_selector(&mut subject_id, &arg["--subject=".len()..])?;
i += 1;
}
"--profile" => {
let value = args
.get(i + 1)
@@ -989,6 +1053,24 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
}
}
if subject_id.is_some() {
if session.is_some()
|| socket_override.is_some()
|| runtime_id.is_some()
|| worker_id.is_some()
{
return Err(ParseError(
"--subject can only be used for fresh local spawn, not attach or restore"
.to_owned(),
));
}
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(ParseError(
"--subject is local-only and does not accept Backend selectors".to_owned(),
));
}
}
if worker_id.is_some() && runtime_id.is_none() {
return Err(ParseError(
"--worker-id requires --runtime-id for Runtime API attach".to_string(),
@@ -1018,12 +1100,29 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
return Err(ParseError("--socket requires --worker".to_string()));
}
let target = resolve_tui_target(
connection_resolver,
CliCommand::DefaultTui,
target_selection,
&workspace_root,
)?;
let target = if subject_id.is_some() {
// A local Subject selector must never trigger Backend Workspace discovery.
let target = resolve_connection_aware_cli_connection(
connection_resolver,
CliCommand::DefaultTui,
target_selection.explicit_local,
None,
None,
)?;
if target.kind() != TargetKind::Standalone {
return Err(ParseError(
"--subject requires a local target; use --local".to_owned(),
));
}
target
} else {
resolve_tui_target(
connection_resolver,
CliCommand::DefaultTui,
target_selection,
&workspace_root,
)?
};
if target.kind() == TargetKind::Standalone {
if runtime_id.is_some() || worker_id.is_some() {
@@ -1081,6 +1180,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
LaunchMode::Spawn {
worker_name,
profile,
subject_id,
}
} else {
if worker_name.is_some()
@@ -1912,6 +2012,7 @@ Connection-aware commands:
Console options:
--workspace <PATH> Standalone cwd or client display scope (defaults to cwd)
--profile <REF> Select the Standalone Profile recipe
--subject <ID> Select an existing local Subject for fresh spawn only
--runtime-id <ID> Backend Runtime id
--worker-id <ID> Backend Worker id; requires --runtime-id
@@ -1926,6 +2027,7 @@ Host commands:
plugin <COMMAND> Author/check/pack explicit Plugin packages
mcp <COMMAND> Inspect configured MCP servers
session <COMMAND> Inspect/prune Standalone session logs
--local subject <COMMAND> Explicitly create/list local Subjects (create <ROLE>, list)
Standalone binaries:
yoi-server Workspace Backend server/admin CLI
@@ -2618,11 +2720,115 @@ backend = "shared"
LaunchMode::Spawn {
worker_name: Some(ref name),
profile: None,
subject_id: None,
} if name == "my-local-worker"
));
assert!(target.spawn_worker().unwrap().state_dir.is_absolute());
}
#[test]
fn subject_selection_is_carried_only_to_local_fresh_spawn() {
let resolver = DefaultBackendCliConnectionResolver {
backend_url: "http://default-backend.example",
};
for selector in [
vec!["--subject", "subject-id"],
vec!["--subject=subject-id"],
] {
let args = [
vec!["--local", "--profile", "builtin:standalone-subjektiv"],
selector,
]
.concat()
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
let Mode::Tui {
target,
mode: LaunchMode::Spawn { subject_id, .. },
..
} = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected local spawn");
};
assert_eq!(target.kind(), TargetKind::Standalone);
assert_eq!(subject_id.as_deref(), Some("subject-id"));
}
for input in [
vec!["--subject", "subject-id"],
vec![
"--backend",
"http://backend.example",
"--subject",
"subject-id",
],
vec!["--local", "--workspace-id", "ws", "--subject=id"],
vec!["--local", "--subject", ""],
vec!["--local", "--subject", " "],
vec!["--local", "--subject="],
vec!["--local", "--subject=id", "--subject=other"],
vec!["--local", "--subject=id", "--subject", "other"],
vec!["--local", "--subject=id", "--session", "session"],
vec![
"--local",
"--subject=id",
"--socket",
"socket",
"--worker",
"worker",
],
vec!["--local", "--subject=id", "--runtime-id", "runtime"],
vec!["--local", "--subject=id", "--worker-id", "worker"],
vec!["--local", "resume", "--subject=id"],
vec!["--local", "resume", "--subject", "id"],
] {
let args = input
.iter()
.map(|arg| (*arg).to_owned())
.collect::<Vec<_>>();
assert!(
parse_args_slice_with_connection_resolver(&args, &resolver).is_err(),
"{input:?}"
);
}
}
#[test]
fn subject_commands_require_explicit_local_target() {
let resolver = DefaultBackendCliConnectionResolver {
backend_url: "http://default-backend.example",
};
for command in [vec!["create", "assistant"], vec!["list"], vec!["--help"]] {
let args = [vec!["--local", "subject"], command]
.concat()
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
let Mode::Subject { target, .. } =
parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected Subject mode");
};
assert_eq!(target.kind(), TargetKind::Standalone);
assert!(target.spawn_worker().unwrap().state_dir.is_absolute());
}
for input in [
vec!["subject", "list"],
vec!["--backend", "http://backend.example", "subject", "list"],
vec!["--local", "--workspace-id=ws", "subject", "list"],
vec!["--local", "subject", "list", "--backend=url"],
] {
let args = input
.iter()
.map(|arg| (*arg).to_owned())
.collect::<Vec<_>>();
assert!(
parse_args_slice_with_connection_resolver(&args, &resolver).is_err(),
"{input:?}"
);
}
}
#[test]
fn parser_rejects_removed_top_level_resume_flags() {
for (args, expected) in [
@@ -3346,6 +3552,7 @@ backend = "shared"
LaunchMode::Spawn {
worker_name,
profile,
subject_id: None,
},
workspace_root,
..
+184
View File
@@ -0,0 +1,184 @@
use std::path::Path;
use standalone::subjektiv::StandaloneSubjects;
use super::ParseError;
pub(crate) const HELP: &str = "Local Subjects (operator-managed standalone state):
yoi --local subject create <ROLE> [--behavior-md <TEXT>]
yoi --local subject list
yoi --local --subject <ID> [--profile builtin:standalone-subjektiv]
Create prints a Subject record as JSON; list prints a JSON array.
Selection is explicit and only applies to fresh local launches.
Resume restores the original Subject binding and does not accept --subject.
";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SubjectCli {
Help,
Create {
role: String,
behavior_md: Option<String>,
},
List,
}
pub(crate) fn parse(args: &[String]) -> Result<SubjectCli, ParseError> {
match args.first().map(String::as_str) {
None => Ok(SubjectCli::Help),
Some("--help" | "-h") if args.len() == 1 => Ok(SubjectCli::Help),
Some("list") if args.len() == 1 => Ok(SubjectCli::List),
Some("create") => {
let role = args
.get(1)
.filter(|role| !role.starts_with('-') && !role.trim().is_empty())
.ok_or_else(|| ParseError("subject create requires a nonempty ROLE".to_owned()))?;
let mut behavior_md = None;
let mut i = 2;
while i < args.len() {
if behavior_md.is_some() {
return Err(ParseError(
"subject create accepts --behavior-md only once".to_owned(),
));
}
match args[i].as_str() {
"--behavior-md" => {
let text = args
.get(i + 1)
.filter(|text| !text.starts_with("--"))
.ok_or_else(|| ParseError("--behavior-md requires TEXT".to_owned()))?;
behavior_md = Some(text.clone());
i += 2;
}
arg if arg.starts_with("--behavior-md=") => {
behavior_md = Some(arg.trim_start_matches("--behavior-md=").to_owned());
i += 1;
}
arg => {
return Err(ParseError(format!(
"unknown subject create argument `{arg}`"
)));
}
}
}
Ok(SubjectCli::Create {
role: role.clone(),
behavior_md,
})
}
Some(arg) => Err(ParseError(format!(
"invalid subject command or arguments `{arg}`; use `yoi --local subject --help`"
))),
}
}
pub(crate) fn run(cli: SubjectCli, state_dir: &Path) -> Result<String, Box<dyn std::error::Error>> {
if cli == SubjectCli::Help {
return Ok(HELP.to_owned());
}
let subjects = StandaloneSubjects::open(state_dir)?;
let json = match cli {
SubjectCli::Create { role, behavior_md } => {
serde_json::to_string_pretty(&subjects.create(&role, behavior_md.as_deref())?)?
}
SubjectCli::List => serde_json::to_string_pretty(&subjects.list()?)?,
SubjectCli::Help => unreachable!(),
};
Ok(format!("{json}\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_owned()).collect()
}
#[test]
fn parse_explicit_create_and_list() {
assert_eq!(
parse(&args(&[
"create",
"assistant",
"--behavior-md",
"Remember decisions."
]))
.unwrap(),
SubjectCli::Create {
role: "assistant".to_owned(),
behavior_md: Some("Remember decisions.".to_owned())
}
);
assert_eq!(parse(&args(&["list"])).unwrap(), SubjectCli::List);
}
#[test]
fn reject_missing_empty_duplicate_and_unknown_arguments() {
for input in [
vec!["create"],
vec!["create", " "],
vec!["create", "role", "--behavior-md"],
vec!["create", "role", "--behavior-md=x", "--behavior-md=y"],
vec!["list", "--subject=id"],
vec!["create", "role", "--backend=url"],
] {
assert!(parse(&args(&input)).is_err(), "{input:?}");
}
}
#[test]
fn create_and_list_share_only_the_selected_state_root() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
// New state roots are created privately; temporary parent directories
// need not meet the state-root permission contract.
let state_dir = root.path().join("state");
let other_state_dir = other.path().join("state");
let created: serde_json::Value = serde_json::from_str(
&run(
SubjectCli::Create {
role: "assistant".to_owned(),
behavior_md: Some("Be precise.".to_owned()),
},
&state_dir,
)
.unwrap(),
)
.unwrap();
let listed: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, &state_dir).unwrap()).unwrap();
assert_eq!(listed, serde_json::json!([created]));
let elsewhere: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, &other_state_dir).unwrap()).unwrap();
assert_eq!(elsewhere, serde_json::json!([]));
}
#[cfg(unix)]
#[test]
fn nonprivate_existing_root_requires_explicit_operator_permission_change() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(run(SubjectCli::List, root.path()).is_err());
assert_eq!(
std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777,
0o755
);
assert!(!root.path().join("subjektiv").exists());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let listed: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, root.path()).unwrap()).unwrap();
assert_eq!(listed, serde_json::json!([]));
}
#[test]
fn help_does_not_open_state() {
let root = tempfile::tempdir().unwrap();
let absent = root.path().join("absent");
assert_eq!(run(SubjectCli::Help, &absent).unwrap(), HELP);
assert!(!absent.exists());
}
}