refactor: bind Subject metadata and prompt confirmation to Host lifecycle

This commit is contained in:
2026-10-20 03:52:55 +09:00
parent 6e729ed06c
commit 86602240fa
7 changed files with 569 additions and 136 deletions
+21 -43
View File
@@ -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]
+10
View File
@@ -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;
+38 -25
View File
@@ -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]
); );
} }
+2
View File
@@ -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;
+69
View File
@@ -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)
})
}
+120
View File
@@ -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
View File
@@ -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();