refactor: bind Subject metadata and prompt confirmation to Host lifecycle
This commit is contained in:
@@ -210,18 +210,6 @@ impl WorkerMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_active_session(metadata: &mut WorkerMetadata, active: Option<WorkerActiveSegmentRef>) {
|
|
||||||
if let Some(session_id) = active.as_ref().map(|active| active.session_id)
|
|
||||||
&& metadata
|
|
||||||
.subjektiv_session_attribution
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|state| state.session_id() != session_id)
|
|
||||||
{
|
|
||||||
metadata.subjektiv_session_attribution = None;
|
|
||||||
}
|
|
||||||
metadata.active = active;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync persistence backend for Worker metadata.
|
/// Sync persistence backend for Worker metadata.
|
||||||
pub trait WorkerMetadataStore: Send + Sync {
|
pub trait WorkerMetadataStore: Send + Sync {
|
||||||
/// Create or replace metadata for its `worker_name` key.
|
/// Create or replace metadata for its `worker_name` key.
|
||||||
@@ -297,7 +285,7 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
workspace_root: Option<PathBuf>,
|
workspace_root: Option<PathBuf>,
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.update_by_name(worker_name, |metadata| {
|
self.update_by_name(worker_name, |metadata| {
|
||||||
replace_active_session(metadata, active);
|
metadata.active = active;
|
||||||
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
||||||
if let Some(workspace_id) = workspace_id {
|
if let Some(workspace_id) = workspace_id {
|
||||||
metadata.workspace_id = Some(workspace_id);
|
metadata.workspace_id = Some(workspace_id);
|
||||||
@@ -317,7 +305,7 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.update_by_name(worker_name, |metadata| {
|
self.update_by_name(worker_name, |metadata| {
|
||||||
replace_active_session(metadata, active);
|
metadata.active = active;
|
||||||
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
||||||
if let Some(workspace_id) = workspace_id {
|
if let Some(workspace_id) = workspace_id {
|
||||||
metadata.workspace_id = Some(workspace_id);
|
metadata.workspace_id = Some(workspace_id);
|
||||||
@@ -334,7 +322,7 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
workspace_root: Option<PathBuf>,
|
workspace_root: Option<PathBuf>,
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.update_by_name(worker_name, |metadata| {
|
self.update_by_name(worker_name, |metadata| {
|
||||||
replace_active_session(metadata, active);
|
metadata.active = active;
|
||||||
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
||||||
if let Some(workspace_root) = workspace_root {
|
if let Some(workspace_root) = workspace_root {
|
||||||
metadata.workspace_root = Some(workspace_root);
|
metadata.workspace_root = Some(workspace_root);
|
||||||
@@ -342,18 +330,6 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the Host-owned Subjektiv Session attribution lifecycle state while
|
|
||||||
/// preserving the active Session pointer and every unrelated field.
|
|
||||||
fn set_subjektiv_session_attribution(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
state: SubjektivSessionAttributionState,
|
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError> {
|
|
||||||
self.update_by_name(worker_name, |metadata| {
|
|
||||||
metadata.subjektiv_session_attribution = Some(state);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set spawned-child registry state while preserving active pointer and manifest snapshot.
|
/// Set spawned-child registry state while preserving active pointer and manifest snapshot.
|
||||||
fn set_spawned_children(
|
fn set_spawned_children(
|
||||||
&self,
|
&self,
|
||||||
@@ -1087,13 +1063,13 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.set_subjektiv_session_attribution(
|
.update_by_name("subject-worker", |metadata| {
|
||||||
"subject-worker",
|
metadata.subjektiv_session_attribution =
|
||||||
SubjektivSessionAttributionState::OutcomeUnknown {
|
Some(SubjektivSessionAttributionState::OutcomeUnknown {
|
||||||
session_id,
|
session_id,
|
||||||
diagnostic: "request interrupted".to_string(),
|
diagnostic: "request interrupted".to_string(),
|
||||||
},
|
});
|
||||||
)
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.set_active(
|
.set_active(
|
||||||
@@ -1117,7 +1093,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_active_session_clears_prior_session_attribution_state() {
|
fn generic_active_update_does_not_interpret_host_attribution() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let store = FsWorkerStore::new(tmp.path()).unwrap();
|
let store = FsWorkerStore::new(tmp.path()).unwrap();
|
||||||
let old_session_id = crate::new_session_id();
|
let old_session_id = crate::new_session_id();
|
||||||
@@ -1129,13 +1105,13 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.set_subjektiv_session_attribution(
|
.update_by_name("subject-worker", |metadata| {
|
||||||
"subject-worker",
|
metadata.subjektiv_session_attribution =
|
||||||
SubjektivSessionAttributionState::Confirmed {
|
Some(SubjektivSessionAttributionState::Confirmed {
|
||||||
session_id: old_session_id,
|
session_id: old_session_id,
|
||||||
subject_id: "subject-1".to_string(),
|
subject_id: "subject-1".to_string(),
|
||||||
},
|
});
|
||||||
)
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
store
|
store
|
||||||
@@ -1151,7 +1127,9 @@ mod tests {
|
|||||||
restored.active,
|
restored.active,
|
||||||
Some(WorkerActiveSegmentRef::pending_segment(new_session_id))
|
Some(WorkerActiveSegmentRef::pending_segment(new_session_id))
|
||||||
);
|
);
|
||||||
assert!(restored.subjektiv_session_attribution.is_none());
|
assert!(
|
||||||
|
matches!(restored.subjektiv_session_attribution, Some(SubjektivSessionAttributionState::Confirmed { session_id, .. }) if session_id == old_session_id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
//! an external plugin-loading surface.
|
//! an external plugin-loading surface.
|
||||||
|
|
||||||
pub mod backend_job_result;
|
pub mod backend_job_result;
|
||||||
|
|
||||||
|
/// Host metadata participants are independent of model-facing Tool enablement.
|
||||||
|
/// Disabling a Feature must not preserve stale Session-bound authority evidence.
|
||||||
|
pub(crate) fn session_metadata_lifecycles()
|
||||||
|
-> Vec<std::sync::Arc<dyn crate::metadata_lifecycle::SessionMetadataLifecycle>> {
|
||||||
|
vec![std::sync::Arc::new(
|
||||||
|
crate::subjektiv_metadata::SessionAttributionMetadata,
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
pub mod chat_invocation;
|
pub mod chat_invocation;
|
||||||
pub mod drive;
|
pub mod drive;
|
||||||
pub mod flow_transition;
|
pub mod flow_transition;
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ struct SubjectBehaviorRefreshState {
|
|||||||
struct WorkspaceSubjektivResidentSummarySource {
|
struct WorkspaceSubjektivResidentSummarySource {
|
||||||
host: SubjektivHostConnection,
|
host: SubjektivHostConnection,
|
||||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
behavior_state: Arc<Mutex<SubjectBehaviorRefreshState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resident_summary_contribution(
|
fn resident_summary_contribution(
|
||||||
@@ -816,44 +816,49 @@ impl WorkspaceSubjektivResidentSummarySource {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
|
|
||||||
let mut state = self
|
|
||||||
.behavior_state
|
|
||||||
.lock()
|
|
||||||
.expect("subject behavior state poisoned");
|
|
||||||
state.applied = Some(output.behavior_md.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect behavior changes against the state reflected in history, not the fetched state.
|
/// Detect behavior changes against the state reflected in history, not the fetched state.
|
||||||
///
|
///
|
||||||
/// Initial load builds a prompt contribution; change refresh does not advance applied on fetch/render alone.
|
/// Fetch/render never advances applied state. Initial preparation captures a
|
||||||
/// The interceptor confirms after durably appending a system item, allowing refetch if append fails.
|
/// source-local completion, invoked only after its prompt and active pointer commit.
|
||||||
/// Invalidate applied before rewrite so even identical bodies can be presented in the new history.
|
/// Later refreshes are acknowledged by the interceptor after durable append.
|
||||||
|
/// Invalidate applied before rewrite so identical bodies can be represented again.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||||
async fn load(&self) -> SystemPromptContribution {
|
async fn load(&self) -> SystemPromptContribution {
|
||||||
|
self.load_initial().await.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_initial(
|
||||||
|
&self,
|
||||||
|
) -> (
|
||||||
|
SystemPromptContribution,
|
||||||
|
Option<crate::worker::PromptContributionCompletion>,
|
||||||
|
) {
|
||||||
match self.fetch().await {
|
match self.fetch().await {
|
||||||
Ok(output) => match self.render(&output) {
|
Ok(output) => match self.render(&output) {
|
||||||
Ok((rendered, _)) => {
|
Ok((rendered, _)) => {
|
||||||
self.remember_behavior(&output);
|
let state = self.behavior_state.clone();
|
||||||
SystemPromptContribution::Ready(rendered)
|
let behavior = output.behavior_md;
|
||||||
|
(
|
||||||
|
SystemPromptContribution::Ready(rendered),
|
||||||
|
Some(Box::new(move || {
|
||||||
|
state
|
||||||
|
.lock()
|
||||||
|
.expect("subject behavior state poisoned")
|
||||||
|
.applied = Some(behavior);
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(%error, "subject resident context render failed");
|
tracing::error!(%error, "subject resident context render failed");
|
||||||
SystemPromptContribution::Ready(
|
(SystemPromptContribution::Ready("The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None)
|
||||||
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(%error, "subject resident context fetch failed");
|
tracing::error!(%error, "subject resident context fetch failed");
|
||||||
SystemPromptContribution::Ready(
|
(SystemPromptContribution::Ready("The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None)
|
||||||
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1218,7 +1223,7 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
|||||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||||
host: host.expect("checked explicit connection"),
|
host: host.expect("checked explicit connection"),
|
||||||
prompts,
|
prompts,
|
||||||
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
behavior_state: Arc::new(Mutex::new(SubjectBehaviorRefreshState::default())),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1835,8 +1840,16 @@ permission = "write"
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let initial = source.load().await;
|
let (initial, completion) = source.load_initial().await;
|
||||||
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
||||||
|
assert!(
|
||||||
|
source
|
||||||
|
.load_changed_resident_context()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
completion.expect("successful initial contribution must provide completion")();
|
||||||
assert!(
|
assert!(
|
||||||
source
|
source
|
||||||
.load_changed_resident_context()
|
.load_changed_resident_context()
|
||||||
@@ -1907,7 +1920,7 @@ permission = "write"
|
|||||||
// target-side bounded resident request contract.
|
// target-side bounded resident request contract.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.timeouts.lock().unwrap().as_slice(),
|
client.timeouts.lock().unwrap().as_slice(),
|
||||||
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
|
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 8]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod hook;
|
|||||||
pub(crate) mod in_flight;
|
pub(crate) mod in_flight;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
|
mod metadata_lifecycle;
|
||||||
pub mod model_client;
|
pub mod model_client;
|
||||||
mod paste_artifact_tool;
|
mod paste_artifact_tool;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
@@ -26,6 +27,7 @@ mod session_history;
|
|||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
mod shutdown_after_idle;
|
mod shutdown_after_idle;
|
||||||
mod subject_release;
|
mod subject_release;
|
||||||
|
mod subjektiv_metadata;
|
||||||
pub use subject_release::SubjectReleaseReservation;
|
pub use subject_release::SubjectReleaseReservation;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! Atomic metadata transitions with Host-owned Session lifecycle participants.
|
||||||
|
//!
|
||||||
|
//! Participants inspect domain evidence inside the metadata update, not after the
|
||||||
|
//! active pointer has been published. The generic writer never interprets it.
|
||||||
|
|
||||||
|
use session_store::worker_metadata::{
|
||||||
|
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerStoreError,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub(crate) trait SessionMetadataLifecycle: Send + Sync {
|
||||||
|
/// Runs under the metadata update lock. Only compare/transform the supplied
|
||||||
|
/// value: no I/O, reentrant store calls, lock waits, or Feature refresh.
|
||||||
|
fn active_changing(&self, metadata: &mut WorkerMetadata, next: Option<&WorkerActiveSegmentRef>);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type WorkerMetadataWriter =
|
||||||
|
Arc<dyn Fn(WorkerMetadata) -> Result<(), WorkerStoreError> + Send + Sync>;
|
||||||
|
pub(crate) type WorkerMetadataSegmentCas = Arc<
|
||||||
|
dyn Fn(&str, &WorkerActiveSegmentRef, WorkerActiveSegmentRef) -> Result<bool, WorkerStoreError>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
|
pub(crate) fn worker_metadata_writer_for_store<St>(store: &St) -> WorkerMetadataWriter
|
||||||
|
where
|
||||||
|
St: WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let store = store.clone();
|
||||||
|
let participants = crate::feature::builtin::session_metadata_lifecycles();
|
||||||
|
Arc::new(move |next| {
|
||||||
|
store
|
||||||
|
.update_by_name(&next.worker_name, |metadata| {
|
||||||
|
for participant in &participants {
|
||||||
|
participant.active_changing(metadata, next.active.as_ref());
|
||||||
|
}
|
||||||
|
metadata.active = next.active;
|
||||||
|
metadata.resolved_manifest_snapshot = next.resolved_manifest_snapshot;
|
||||||
|
if next.workspace_id.is_some() {
|
||||||
|
metadata.workspace_id = next.workspace_id;
|
||||||
|
}
|
||||||
|
if next.workspace_root.is_some() {
|
||||||
|
metadata.workspace_root = next.workspace_root;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_metadata_segment_cas_for_store<St>(store: &St) -> WorkerMetadataSegmentCas
|
||||||
|
where
|
||||||
|
St: WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let store = store.clone();
|
||||||
|
let participants = crate::feature::builtin::session_metadata_lifecycles();
|
||||||
|
Arc::new(move |worker_name, expected, replacement| {
|
||||||
|
let mut matched = false;
|
||||||
|
store.update_by_name(worker_name, |metadata| {
|
||||||
|
if metadata.active.as_ref() == Some(expected) {
|
||||||
|
for participant in &participants {
|
||||||
|
participant.active_changing(metadata, Some(&replacement));
|
||||||
|
}
|
||||||
|
metadata.active = Some(replacement);
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(matched)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! Host-owned attribution transitions. Metadata callbacks are synchronous, local
|
||||||
|
//! comparisons only: no network I/O, Feature lookup, or nested store access.
|
||||||
|
|
||||||
|
use crate::metadata_lifecycle::SessionMetadataLifecycle;
|
||||||
|
use session_store::{
|
||||||
|
SessionId, SubjektivSessionAttributionState, WorkerActiveSegmentRef, WorkerMetadata,
|
||||||
|
WorkerMetadataStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) struct SessionAttributionMetadata;
|
||||||
|
|
||||||
|
impl SessionMetadataLifecycle for SessionAttributionMetadata {
|
||||||
|
fn active_changing(
|
||||||
|
&self,
|
||||||
|
metadata: &mut WorkerMetadata,
|
||||||
|
next: Option<&WorkerActiveSegmentRef>,
|
||||||
|
) {
|
||||||
|
if metadata.active.as_ref().map(|active| active.session_id)
|
||||||
|
!= next.map(|active| active.session_id)
|
||||||
|
{
|
||||||
|
metadata.subjektiv_session_attribution = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compare_and_swap_attribution<St: WorkerMetadataStore>(
|
||||||
|
store: &St,
|
||||||
|
worker_name: &str,
|
||||||
|
session_id: SessionId,
|
||||||
|
expected: Option<&SubjektivSessionAttributionState>,
|
||||||
|
replacement: SubjektivSessionAttributionState,
|
||||||
|
) -> Result<(), crate::WorkerError> {
|
||||||
|
let mut matched = false;
|
||||||
|
store.update_by_name(worker_name, |metadata| {
|
||||||
|
if metadata.active.as_ref().map(|active| active.session_id) == Some(session_id)
|
||||||
|
&& metadata.subjektiv_session_attribution.as_ref() == expected
|
||||||
|
&& replacement.session_id() == session_id
|
||||||
|
{
|
||||||
|
metadata.subjektiv_session_attribution = Some(replacement);
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if !matched {
|
||||||
|
return Err(crate::WorkerError::SubjektivSessionAttribution {
|
||||||
|
state: "attribution_conflict",
|
||||||
|
message: "active Session or observed attribution changed during Host confirmation"
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! Connect the production metadata writer/CAS and Host lifecycle to a real
|
||||||
|
//! filesystem store; no metadata or persistence boundary is substituted.
|
||||||
|
use super::*;
|
||||||
|
use crate::metadata_lifecycle::{
|
||||||
|
worker_metadata_segment_cas_for_store, worker_metadata_writer_for_store,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_transition_preserves_same_session_and_invalidates_new_session_atomically() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = session_store::FsWorkerStore::new(dir.path()).unwrap();
|
||||||
|
let session = session_store::new_session_id();
|
||||||
|
let mut metadata = WorkerMetadata::new(
|
||||||
|
"body",
|
||||||
|
Some(WorkerActiveSegmentRef::pending_segment(session)),
|
||||||
|
);
|
||||||
|
metadata.workspace_id = Some("workspace".into());
|
||||||
|
metadata.subjektiv_session_attribution =
|
||||||
|
Some(SubjektivSessionAttributionState::Confirmed {
|
||||||
|
session_id: session,
|
||||||
|
subject_id: "subject".into(),
|
||||||
|
});
|
||||||
|
store.write(&metadata).unwrap();
|
||||||
|
let writer = worker_metadata_writer_for_store(&store);
|
||||||
|
let cas = worker_metadata_segment_cas_for_store(&store);
|
||||||
|
let segment =
|
||||||
|
WorkerActiveSegmentRef::active_segment(session, session_store::new_segment_id());
|
||||||
|
let mut next = WorkerMetadata::new("body", Some(segment.clone()));
|
||||||
|
writer(next.clone()).unwrap();
|
||||||
|
let materialized = store.read_by_name("body").unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
materialized.subjektiv_session_attribution,
|
||||||
|
metadata.subjektiv_session_attribution
|
||||||
|
);
|
||||||
|
assert_eq!(materialized.workspace_id, metadata.workspace_id);
|
||||||
|
let compacted =
|
||||||
|
WorkerActiveSegmentRef::active_segment(session, session_store::new_segment_id());
|
||||||
|
assert!(cas("body", &segment, compacted.clone()).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.read_by_name("body")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.subjektiv_session_attribution,
|
||||||
|
metadata.subjektiv_session_attribution
|
||||||
|
);
|
||||||
|
let new_session = WorkerActiveSegmentRef::pending_segment(session_store::new_session_id());
|
||||||
|
assert!(!cas("body", &segment, new_session.clone()).unwrap());
|
||||||
|
assert!(cas("body", &compacted, new_session.clone()).unwrap());
|
||||||
|
let switched = store.read_by_name("body").unwrap().unwrap();
|
||||||
|
assert_eq!(switched.active, Some(new_session));
|
||||||
|
assert!(switched.subjektiv_session_attribution.is_none());
|
||||||
|
assert_eq!(switched.workspace_id, metadata.workspace_id);
|
||||||
|
// Returning to the old Session cannot recover the invalidated evidence.
|
||||||
|
next.active = metadata.active;
|
||||||
|
writer(next).unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.read_by_name("body")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.subjektiv_session_attribution
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+309
-68
@@ -1676,41 +1676,10 @@ pub struct SegmentLocation {
|
|||||||
pub segment_id: SegmentId,
|
pub segment_id: SegmentId,
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerMetadataWriter =
|
use crate::metadata_lifecycle::{
|
||||||
Arc<dyn Fn(WorkerMetadata) -> Result<(), WorkerStoreError> + Send + Sync>;
|
WorkerMetadataSegmentCas, WorkerMetadataWriter, worker_metadata_segment_cas_for_store,
|
||||||
type WorkerMetadataSegmentCas = Arc<
|
worker_metadata_writer_for_store,
|
||||||
dyn Fn(&str, &WorkerActiveSegmentRef, WorkerActiveSegmentRef) -> Result<bool, WorkerStoreError>
|
};
|
||||||
+ Send
|
|
||||||
+ Sync,
|
|
||||||
>;
|
|
||||||
|
|
||||||
fn worker_metadata_writer_for_store<St>(store: &St) -> WorkerMetadataWriter
|
|
||||||
where
|
|
||||||
St: WorkerMetadataStore + Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let store = store.clone();
|
|
||||||
Arc::new(move |metadata| {
|
|
||||||
store
|
|
||||||
.set_active_with_workspace_context(
|
|
||||||
&metadata.worker_name,
|
|
||||||
metadata.active,
|
|
||||||
metadata.resolved_manifest_snapshot,
|
|
||||||
metadata.workspace_id,
|
|
||||||
metadata.workspace_root,
|
|
||||||
)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worker_metadata_segment_cas_for_store<St>(store: &St) -> WorkerMetadataSegmentCas
|
|
||||||
where
|
|
||||||
St: WorkerMetadataStore + Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let store = store.clone();
|
|
||||||
Arc::new(move |worker_name, expected, replacement| {
|
|
||||||
store.compare_and_swap_active(worker_name, expected, replacement)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lock-free shared session/segment pointer.
|
/// Lock-free shared session/segment pointer.
|
||||||
///
|
///
|
||||||
@@ -2938,6 +2907,8 @@ impl WorkerSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) type PromptContributionCompletion = Box<dyn FnOnce() + Send + Sync>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum SystemPromptContribution {
|
pub(crate) enum SystemPromptContribution {
|
||||||
Ready(String),
|
Ready(String),
|
||||||
@@ -2969,8 +2940,21 @@ pub(crate) trait SessionContextSource: Send + Sync {
|
|||||||
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
||||||
async fn load(&self) -> SystemPromptContribution;
|
async fn load(&self) -> SystemPromptContribution;
|
||||||
|
|
||||||
/// Returns a durable model-visible refresh only when user-managed resident
|
/// Prepare an initial contribution with an optional source-bound completion.
|
||||||
/// context changed since the last successful load.
|
/// Completion runs only after its exact prompt and active pointer commit,
|
||||||
|
/// under the publication/release fence. It must perform bounded local updates
|
||||||
|
/// only; no I/O, store reentry, or authority grants. Restore never replays it.
|
||||||
|
async fn load_initial(
|
||||||
|
&self,
|
||||||
|
) -> (
|
||||||
|
SystemPromptContribution,
|
||||||
|
Option<PromptContributionCompletion>,
|
||||||
|
) {
|
||||||
|
(self.load().await, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a model-visible refresh only when user-managed resident context
|
||||||
|
/// changed since its last committed representation.
|
||||||
async fn load_changed_resident_context(
|
async fn load_changed_resident_context(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Option<ResidentContextRefresh>, String> {
|
) -> Result<Option<ResidentContextRefresh>, String> {
|
||||||
@@ -3157,6 +3141,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
/// Exact complete seed retained across ambiguous Store responses. The committed
|
/// Exact complete seed retained across ambiguous Store responses. The committed
|
||||||
/// segment remains authoritative; metadata must be repaired before accepting input.
|
/// segment remains authoritative; metadata must be repaired before accepting input.
|
||||||
prepared_initial_seed: Option<Vec<LogEntry>>,
|
prepared_initial_seed: Option<Vec<LogEntry>>,
|
||||||
|
prepared_initial_prompt_completion: Option<(String, PromptContributionCompletion)>,
|
||||||
subjektiv_host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>,
|
subjektiv_host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>,
|
||||||
/// One-shot generic Feature lifecycle notification required after restore.
|
/// One-shot generic Feature lifecycle notification required after restore.
|
||||||
restore_feature_lifecycle_pending: bool,
|
restore_feature_lifecycle_pending: bool,
|
||||||
@@ -3427,6 +3412,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
session_context_source: None,
|
session_context_source: None,
|
||||||
prepared_initial_seed: None,
|
prepared_initial_seed: None,
|
||||||
|
prepared_initial_prompt_completion: None,
|
||||||
subjektiv_host: None,
|
subjektiv_host: None,
|
||||||
restore_feature_lifecycle_pending: false,
|
restore_feature_lifecycle_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
@@ -3482,6 +3468,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||||
system_prompt_override: Option<String>,
|
system_prompt_override: Option<String>,
|
||||||
) {
|
) {
|
||||||
|
self.prepared_initial_prompt_completion = None;
|
||||||
self.feature_resident_summary_source = resident_summary_source;
|
self.feature_resident_summary_source = resident_summary_source;
|
||||||
self.feature_system_prompt_override = system_prompt_override;
|
self.feature_system_prompt_override = system_prompt_override;
|
||||||
}
|
}
|
||||||
@@ -4671,16 +4658,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut completion = None;
|
||||||
let resident_summary = if self.inject_resident_summary {
|
let resident_summary = if self.inject_resident_summary {
|
||||||
match &self.feature_resident_summary_source {
|
match &self.feature_resident_summary_source {
|
||||||
Some(source) => match source.load().await {
|
Some(source) => {
|
||||||
SystemPromptContribution::Ready(summary) if !summary.is_empty() => {
|
let (value, prepared) = source.load_initial().await;
|
||||||
Some(summary)
|
match value {
|
||||||
|
SystemPromptContribution::Ready(summary) if !summary.is_empty() => {
|
||||||
|
completion = prepared;
|
||||||
|
Some(summary)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
SystemPromptContribution::Ready(_) | SystemPromptContribution::Unavailable => {
|
}
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => None,
|
None => None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -4707,6 +4697,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
let rendered = template
|
let rendered = template
|
||||||
.render(&ctx)
|
.render(&ctx)
|
||||||
.map_err(|source| WorkerError::SystemPromptRender { source })?;
|
.map_err(|source| WorkerError::SystemPromptRender { source })?;
|
||||||
|
self.prepared_initial_prompt_completion = completion.and_then(|complete| {
|
||||||
|
resident_summary
|
||||||
|
.as_ref()
|
||||||
|
.filter(|body| rendered.contains(body.as_str()))
|
||||||
|
.map(|_| (rendered.clone(), complete))
|
||||||
|
});
|
||||||
self.engine
|
self.engine
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.expect("worker present")
|
.expect("worker present")
|
||||||
@@ -5959,6 +5955,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
self.system_prompt_template = None;
|
self.system_prompt_template = None;
|
||||||
self.feature_system_prompt_override = None;
|
self.feature_system_prompt_override = None;
|
||||||
self.prepared_initial_seed = None;
|
self.prepared_initial_seed = None;
|
||||||
|
if let Some((prepared, complete)) = self.prepared_initial_prompt_completion.take() {
|
||||||
|
if system_prompt.as_ref() == Some(&prepared) {
|
||||||
|
complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7999,6 +8000,7 @@ where
|
|||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
session_context_source: None,
|
session_context_source: None,
|
||||||
prepared_initial_seed: None,
|
prepared_initial_seed: None,
|
||||||
|
prepared_initial_prompt_completion: None,
|
||||||
subjektiv_host: None,
|
subjektiv_host: None,
|
||||||
restore_feature_lifecycle_pending: false,
|
restore_feature_lifecycle_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
@@ -8029,6 +8031,7 @@ where
|
|||||||
subjektiv_attached: bool,
|
subjektiv_attached: bool,
|
||||||
lifecycle: SubjektivSessionAttributionLifecycle,
|
lifecycle: SubjektivSessionAttributionLifecycle,
|
||||||
) -> Result<(), WorkerError> {
|
) -> Result<(), WorkerError> {
|
||||||
|
let _admission = self.segment_state.mutation_authority.enter()?;
|
||||||
let worker_name = self.manifest.worker.name.as_str();
|
let worker_name = self.manifest.worker.name.as_str();
|
||||||
let metadata = self.store.read_by_name(worker_name)?.ok_or_else(|| {
|
let metadata = self.store.read_by_name(worker_name)?.ok_or_else(|| {
|
||||||
WorkerError::SubjektivSessionAttribution {
|
WorkerError::SubjektivSessionAttribution {
|
||||||
@@ -8107,11 +8110,15 @@ where
|
|||||||
} else {
|
} else {
|
||||||
"legacy Session has no local attribution evidence; immutable Backend verification started"
|
"legacy Session has no local attribution evidence; immutable Backend verification started"
|
||||||
};
|
};
|
||||||
self.persist_subjektiv_session_attribution(incomplete_subjektiv_attribution_state(
|
let pending_attribution = incomplete_subjektiv_attribution_state(
|
||||||
session_id,
|
session_id,
|
||||||
create_if_missing,
|
create_if_missing,
|
||||||
initial_diagnostic.to_string(),
|
initial_diagnostic.to_string(),
|
||||||
))?;
|
);
|
||||||
|
self.persist_subjektiv_session_attribution(
|
||||||
|
metadata.subjektiv_session_attribution.as_ref(),
|
||||||
|
pending_attribution.clone(),
|
||||||
|
)?;
|
||||||
let output = if self.subjektiv_host.is_some() {
|
let output = if self.subjektiv_host.is_some() {
|
||||||
let connection = self
|
let connection = self
|
||||||
.subjektiv_host_connection()
|
.subjektiv_host_connection()
|
||||||
@@ -8144,7 +8151,7 @@ where
|
|||||||
message.clone(),
|
message.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
self.persist_subjektiv_session_attribution(state)?;
|
self.persist_subjektiv_session_attribution(Some(&pending_attribution), state)?;
|
||||||
return Err(WorkerError::SubjektivSessionAttribution {
|
return Err(WorkerError::SubjektivSessionAttribution {
|
||||||
state: if create_if_missing && rejected {
|
state: if create_if_missing && rejected {
|
||||||
"failed"
|
"failed"
|
||||||
@@ -8170,6 +8177,7 @@ where
|
|||||||
"Session attribution transport outcome is unknown: {error}"
|
"Session attribution transport outcome is unknown: {error}"
|
||||||
));
|
));
|
||||||
self.persist_subjektiv_session_attribution(
|
self.persist_subjektiv_session_attribution(
|
||||||
|
Some(&pending_attribution),
|
||||||
incomplete_subjektiv_attribution_state(
|
incomplete_subjektiv_attribution_state(
|
||||||
session_id,
|
session_id,
|
||||||
create_if_missing,
|
create_if_missing,
|
||||||
@@ -8198,7 +8206,7 @@ where
|
|||||||
diagnostic: message.clone(),
|
diagnostic: message.clone(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.persist_subjektiv_session_attribution(state)?;
|
self.persist_subjektiv_session_attribution(Some(&pending_attribution), state)?;
|
||||||
return Err(WorkerError::SubjektivSessionAttribution {
|
return Err(WorkerError::SubjektivSessionAttribution {
|
||||||
state: if create_if_missing {
|
state: if create_if_missing {
|
||||||
"failed"
|
"failed"
|
||||||
@@ -8216,6 +8224,7 @@ where
|
|||||||
"Session attribution response outcome is unknown: {error}"
|
"Session attribution response outcome is unknown: {error}"
|
||||||
));
|
));
|
||||||
self.persist_subjektiv_session_attribution(
|
self.persist_subjektiv_session_attribution(
|
||||||
|
Some(&pending_attribution),
|
||||||
incomplete_subjektiv_attribution_state(
|
incomplete_subjektiv_attribution_state(
|
||||||
session_id,
|
session_id,
|
||||||
create_if_missing,
|
create_if_missing,
|
||||||
@@ -8237,20 +8246,26 @@ where
|
|||||||
let message =
|
let message =
|
||||||
"Session attribution response did not confirm the requested immutable identity"
|
"Session attribution response did not confirm the requested immutable identity"
|
||||||
.to_string();
|
.to_string();
|
||||||
self.persist_subjektiv_session_attribution(incomplete_subjektiv_attribution_state(
|
self.persist_subjektiv_session_attribution(
|
||||||
session_id,
|
Some(&pending_attribution),
|
||||||
create_if_missing,
|
incomplete_subjektiv_attribution_state(
|
||||||
message.clone(),
|
session_id,
|
||||||
))?;
|
create_if_missing,
|
||||||
|
message.clone(),
|
||||||
|
),
|
||||||
|
)?;
|
||||||
return Err(WorkerError::SubjektivSessionAttribution {
|
return Err(WorkerError::SubjektivSessionAttribution {
|
||||||
state: incomplete_subjektiv_attribution_label(create_if_missing),
|
state: incomplete_subjektiv_attribution_label(create_if_missing),
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.persist_subjektiv_session_attribution(SubjektivSessionAttributionState::Confirmed {
|
self.persist_subjektiv_session_attribution(
|
||||||
session_id,
|
Some(&pending_attribution),
|
||||||
subject_id: output.subject_id.clone(),
|
SubjektivSessionAttributionState::Confirmed {
|
||||||
})?;
|
session_id,
|
||||||
|
subject_id: output.subject_id.clone(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
self.retain_journal_source_before_publication(session_id, &output.subject_id)
|
self.retain_journal_source_before_publication(session_id, &output.subject_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8291,11 +8306,16 @@ where
|
|||||||
|
|
||||||
fn persist_subjektiv_session_attribution(
|
fn persist_subjektiv_session_attribution(
|
||||||
&self,
|
&self,
|
||||||
|
expected: Option<&SubjektivSessionAttributionState>,
|
||||||
state: SubjektivSessionAttributionState,
|
state: SubjektivSessionAttributionState,
|
||||||
) -> Result<(), WorkerError> {
|
) -> Result<(), WorkerError> {
|
||||||
self.store
|
crate::subjektiv_metadata::compare_and_swap_attribution(
|
||||||
.set_subjektiv_session_attribution(&self.manifest.worker.name, state)?;
|
&self.store,
|
||||||
Ok(())
|
&self.manifest.worker.name,
|
||||||
|
self.session_id(),
|
||||||
|
expected,
|
||||||
|
state,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an in-process Internal Worker without machine-wide allocation or durable Worker metadata.
|
/// Build an in-process Internal Worker without machine-wide allocation or durable Worker metadata.
|
||||||
@@ -8380,6 +8400,7 @@ where
|
|||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
session_context_source: None,
|
session_context_source: None,
|
||||||
prepared_initial_seed: None,
|
prepared_initial_seed: None,
|
||||||
|
prepared_initial_prompt_completion: None,
|
||||||
subjektiv_host: None,
|
subjektiv_host: None,
|
||||||
restore_feature_lifecycle_pending: false,
|
restore_feature_lifecycle_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
@@ -8512,6 +8533,7 @@ where
|
|||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
session_context_source: None,
|
session_context_source: None,
|
||||||
prepared_initial_seed: None,
|
prepared_initial_seed: None,
|
||||||
|
prepared_initial_prompt_completion: None,
|
||||||
subjektiv_host: None,
|
subjektiv_host: None,
|
||||||
restore_feature_lifecycle_pending: false,
|
restore_feature_lifecycle_pending: false,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
@@ -8923,6 +8945,7 @@ where
|
|||||||
feature_resident_summary_source: None,
|
feature_resident_summary_source: None,
|
||||||
session_context_source: None,
|
session_context_source: None,
|
||||||
prepared_initial_seed: None,
|
prepared_initial_seed: None,
|
||||||
|
prepared_initial_prompt_completion: None,
|
||||||
subjektiv_host: None,
|
subjektiv_host: None,
|
||||||
restore_feature_lifecycle_pending: true,
|
restore_feature_lifecycle_pending: true,
|
||||||
feature_system_prompt_override: None,
|
feature_system_prompt_override: None,
|
||||||
@@ -11358,9 +11381,9 @@ mod build_summary_prompt_tests {
|
|||||||
TransportUnknown,
|
TransportUnknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct AttributionLifecycleClient {
|
struct AttributionLifecycleClient {
|
||||||
outcomes: Mutex<VecDeque<AttributionOutcome>>,
|
outcomes: Mutex<VecDeque<AttributionOutcome>>,
|
||||||
|
before_response: Mutex<Option<Box<dyn FnOnce() + Send>>>,
|
||||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11368,11 +11391,18 @@ mod build_summary_prompt_tests {
|
|||||||
fn new(outcomes: impl IntoIterator<Item = AttributionOutcome>) -> Self {
|
fn new(outcomes: impl IntoIterator<Item = AttributionOutcome>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||||
|
before_response: Mutex::new(None),
|
||||||
requests: Mutex::new(Vec::new()),
|
requests: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AttributionLifecycleClient {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("AttributionLifecycleClient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl WorkspaceClient for AttributionLifecycleClient {
|
impl WorkspaceClient for AttributionLifecycleClient {
|
||||||
fn workspace_id(&self) -> Option<&str> {
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
Some("workspace-test")
|
Some("workspace-test")
|
||||||
@@ -11391,6 +11421,9 @@ mod build_summary_prompt_tests {
|
|||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
self.requests.lock().unwrap().push(request.clone());
|
self.requests.lock().unwrap().push(request.clone());
|
||||||
|
if let Some(change) = self.before_response.lock().unwrap().take() {
|
||||||
|
change();
|
||||||
|
}
|
||||||
match self
|
match self
|
||||||
.outcomes
|
.outcomes
|
||||||
.lock()
|
.lock()
|
||||||
@@ -11638,6 +11671,110 @@ mod build_summary_prompt_tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Production lifecycle, atomic Host CAS, and filesystem metadata store. Only
|
||||||
|
// the external Workspace transport is substituted to interleave a newer owner.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delayed_attribution_response_cannot_overwrite_new_session_or_subject_evidence() {
|
||||||
|
for switch_session in [true, false] {
|
||||||
|
let client = Arc::new(AttributionLifecycleClient::new([
|
||||||
|
AttributionOutcome::Success,
|
||||||
|
]));
|
||||||
|
let (worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
|
||||||
|
let worker_name = worker.manifest().worker.name.clone();
|
||||||
|
let replacement_session = if switch_session {
|
||||||
|
session_store::new_session_id()
|
||||||
|
} else {
|
||||||
|
worker.session_id()
|
||||||
|
};
|
||||||
|
let changed = store.clone();
|
||||||
|
let name = worker_name.clone();
|
||||||
|
*client.before_response.lock().unwrap() = Some(Box::new(move || {
|
||||||
|
if switch_session {
|
||||||
|
worker_metadata_writer_for_store(&changed)(WorkerMetadata::new(
|
||||||
|
name,
|
||||||
|
Some(WorkerActiveSegmentRef::pending_segment(replacement_session)),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
changed
|
||||||
|
.update_by_name(&name, |metadata| {
|
||||||
|
metadata.subjektiv_session_attribution =
|
||||||
|
Some(SubjektivSessionAttributionState::Confirmed {
|
||||||
|
session_id: replacement_session,
|
||||||
|
subject_id: "new-subject".into(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let error = worker
|
||||||
|
.finalize_subjektiv_session_attribution(
|
||||||
|
true,
|
||||||
|
SubjektivSessionAttributionLifecycle::NewSession,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("attribution_conflict"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
let saved = store.read_by_name(&worker_name).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
saved.active.as_ref().unwrap().session_id,
|
||||||
|
replacement_session
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
saved.subjektiv_session_attribution,
|
||||||
|
if switch_session {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(SubjektivSessionAttributionState::Confirmed {
|
||||||
|
session_id: replacement_session,
|
||||||
|
subject_id: "new-subject".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn released_body_cannot_reconfirm_attribution_or_contact_host() {
|
||||||
|
let client = Arc::new(AttributionLifecycleClient::new([
|
||||||
|
AttributionOutcome::Success,
|
||||||
|
]));
|
||||||
|
let (worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
|
||||||
|
let before = store
|
||||||
|
.read_by_name(&worker.manifest().worker.name)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let control = crate::subject_release::ReleaseControl::new(worker.mutation_authority());
|
||||||
|
let request = tokio::spawn({
|
||||||
|
let control = control.clone();
|
||||||
|
async move { control.request().await }
|
||||||
|
});
|
||||||
|
control.notified().await;
|
||||||
|
control.respond(|| Ok(()));
|
||||||
|
let mut reservation = request.await.unwrap().unwrap();
|
||||||
|
reservation.commit_disconnect().unwrap();
|
||||||
|
drop(reservation);
|
||||||
|
assert!(
|
||||||
|
worker
|
||||||
|
.finalize_subjektiv_session_attribution(
|
||||||
|
true,
|
||||||
|
SubjektivSessionAttributionLifecycle::NewSession
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.read_by_name(&worker.manifest().worker.name)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unknown_and_failed_attribution_remain_incomplete_until_lifecycle_retry() {
|
async fn unknown_and_failed_attribution_remain_incomplete_until_lifecycle_retry() {
|
||||||
for (first, expected_state) in [
|
for (first, expected_state) in [
|
||||||
@@ -11750,13 +11887,13 @@ mod build_summary_prompt_tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.set_subjektiv_session_attribution(
|
.update_by_name(&manifest.worker.name, |metadata| {
|
||||||
&manifest.worker.name,
|
metadata.subjektiv_session_attribution =
|
||||||
SubjektivSessionAttributionState::OutcomeUnknown {
|
Some(SubjektivSessionAttributionState::OutcomeUnknown {
|
||||||
session_id,
|
session_id,
|
||||||
diagnostic: "response lost before restart".to_string(),
|
diagnostic: "response lost before restart".to_string(),
|
||||||
},
|
});
|
||||||
)
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let client = Arc::new(AttributionLifecycleClient::new([
|
let client = Arc::new(AttributionLifecycleClient::new([
|
||||||
AttributionOutcome::Success,
|
AttributionOutcome::Success,
|
||||||
@@ -17024,6 +17161,110 @@ permission = "write"
|
|||||||
assert_eq!(worker.history().len(), 1);
|
assert_eq!(worker.history().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PreparedPromptSource {
|
||||||
|
loads: Arc<AtomicUsize>,
|
||||||
|
completions: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SystemPromptContributionSource for PreparedPromptSource {
|
||||||
|
async fn load(&self) -> SystemPromptContribution {
|
||||||
|
panic!("initial load must use its prepared completion")
|
||||||
|
}
|
||||||
|
async fn load_initial(
|
||||||
|
&self,
|
||||||
|
) -> (
|
||||||
|
SystemPromptContribution,
|
||||||
|
Option<PromptContributionCompletion>,
|
||||||
|
) {
|
||||||
|
self.loads.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let completed = self.completions.clone();
|
||||||
|
(
|
||||||
|
SystemPromptContribution::Ready("PREPARED_RESIDENT_BODY".into()),
|
||||||
|
Some(Box::new(move || {
|
||||||
|
completed.fetch_add(1, Ordering::SeqCst);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production initial-head preparation/create/reconciliation and real FsStore.
|
||||||
|
// Only returned write failures and the Feature completion counter are injected.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn initial_prompt_completion_waits_for_commit_and_reconciliation_and_never_replays_to_replacement()
|
||||||
|
{
|
||||||
|
for failure in [3, 4, 0] {
|
||||||
|
for replace_source in [false, true] {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FailRunTerminalStore {
|
||||||
|
inner: session_store::FsStore::new(temp.path().join("sessions")).unwrap(),
|
||||||
|
fail_run_terminal: Arc::new(AtomicBool::new(false)),
|
||||||
|
invocation_failure: Arc::new(AtomicUsize::new(failure)),
|
||||||
|
};
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
minimal_manifest(),
|
||||||
|
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
|
||||||
|
store.clone(),
|
||||||
|
WorkerWorkspaceContext::no_workspace(),
|
||||||
|
WorkerFilesystemAuthority::None,
|
||||||
|
Scope::empty(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
worker.set_system_prompt_template(
|
||||||
|
SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only())
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let loads = Arc::new(AtomicUsize::new(0));
|
||||||
|
let completed = Arc::new(AtomicUsize::new(0));
|
||||||
|
worker.install_system_prompt_contribution(
|
||||||
|
Some(Arc::new(PreparedPromptSource {
|
||||||
|
loads: loads.clone(),
|
||||||
|
completions: completed.clone(),
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
if failure == 0 {
|
||||||
|
let fail = Arc::new(AtomicBool::new(true));
|
||||||
|
worker.worker_metadata_writer = Some(Arc::new(move |_| {
|
||||||
|
if fail.swap(false, Ordering::SeqCst) {
|
||||||
|
Err(std::io::Error::other("initial active pointer write failed").into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
assert!(worker.materialize_durable_session_head().await.is_err());
|
||||||
|
assert_eq!(completed.load(Ordering::SeqCst), 0);
|
||||||
|
let new_completed = Arc::new(AtomicUsize::new(0));
|
||||||
|
let new_loads = Arc::new(AtomicUsize::new(0));
|
||||||
|
if replace_source {
|
||||||
|
worker.install_system_prompt_contribution(
|
||||||
|
Some(Arc::new(PreparedPromptSource {
|
||||||
|
loads: new_loads.clone(),
|
||||||
|
completions: new_completed.clone(),
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
worker.materialize_durable_session_head().await.unwrap();
|
||||||
|
worker.materialize_durable_session_head().await.unwrap();
|
||||||
|
assert_eq!(loads.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(
|
||||||
|
completed.load(Ordering::SeqCst),
|
||||||
|
usize::from(!replace_source)
|
||||||
|
);
|
||||||
|
assert_eq!(new_completed.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(new_loads.load(Ordering::SeqCst), 0);
|
||||||
|
let saved = store
|
||||||
|
.read_all(worker.session_id(), worker.segment_id())
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&saved[0], LogEntry::AnnotatedSegmentStart { system_prompt: Some(prompt), .. } if prompt.contains("PREPARED_RESIDENT_BODY"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn initial_metadata_failure_repairs_pointer_without_refetch_or_user_append() {
|
async fn initial_metadata_failure_repairs_pointer_without_refetch_or_user_append() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user