feat: freeze Host Journal context across compaction

This commit is contained in:
2026-10-17 12:19:37 +09:00
parent 10d200d9ba
commit 956aeed6b2
18 changed files with 357 additions and 15 deletions
@@ -3,6 +3,12 @@
//! Hooks request Host reconciliation, never infer source ranges from in-memory history.
//! Hosts retain exact operation inputs/receipts and validate live Job authority on every
//! operation. Successful operations are never rolled back by Job finalization.
//!
//! Compaction freezes a read-only Host snapshot independently of admission/Job progress.
//! Its rendered reference is shared by mandatory compactor input and the replacement seed;
//! no restore/turn hook refreshes it. Previous references are only historical compactor
//! material so fallback can recover information intentionally omitted from an earlier
//! complementary summary, without accumulating stale references in the next segment.
use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
@@ -79,6 +85,60 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
}
}
/// Read once without waiting for recording. The returned body is immutable mandatory
/// compactor input and the exact replacement-segment reference, including fallback status.
/// Selection and authorization belong to the Host, not the core compaction engine.
pub(crate) fn freeze_compaction_journal(
connection: std::io::Result<Option<SubjektivHostConnection>>,
prompts: &crate::prompt::catalog::PromptCatalog,
) -> Result<String, crate::prompt::catalog::CatalogError> {
let workspace_id = connection.as_ref().ok().and_then(|connection| {
connection
.as_ref()
.and_then(|connection| connection.host.workspace_id())
});
let read = match connection {
Ok(Some(connection)) if connection.host.journal_compaction_granted() => {
Some(connection.journal_compaction_context().map_err(|_| ()))
}
Ok(_) => None,
Err(_) => Some(Err(())),
};
let (status, json) = match read {
None => ("disabled", String::new()),
Some(Ok(snapshot)) => {
let status = if snapshot.journals.is_empty() {
"absent"
} else {
"available"
};
match serde_json::to_string(&snapshot) {
Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => {
(status, json)
}
_ => ("unavailable", String::new()),
}
}
Some(Err(())) => ("unavailable", String::new()),
};
if status == "unavailable" {
tracing::warn!("Journal compaction snapshot unavailable; continuing without Journal");
}
use sha2::{Digest, Sha256};
let snapshot_sha256: Option<String> = (!json.is_empty()).then(|| {
Sha256::digest(json.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
});
prompts.subjektiv_journal_compaction(
status,
&json,
snapshot_sha256.as_deref(),
workspace_id.as_deref(),
)
}
#[derive(Clone)]
pub struct JournalRecordingFeature {
host: SubjektivHostConnection,
+22
View File
@@ -103,6 +103,7 @@ pub enum WorkerPrompt {
ResidentMemoryRestoreSection,
SubjektivResidentContext,
SubjektivJournalStartup,
SubjektivJournalCompaction,
WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription,
}
@@ -128,6 +129,7 @@ impl WorkerPrompt {
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::SubjektivJournalStartup => "internal.subjektiv_journal_startup",
Self::SubjektivJournalCompaction => "internal.subjektiv_journal_compaction",
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
@@ -154,6 +156,7 @@ impl WorkerPrompt {
WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::SubjektivResidentContext,
WorkerPrompt::SubjektivJournalStartup,
WorkerPrompt::SubjektivJournalCompaction,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
@@ -543,6 +546,25 @@ impl PromptCatalog {
},
)
}
pub(crate) fn subjektiv_journal_compaction(
&self,
status: &str,
snapshot_json: &str,
snapshot_sha256: Option<&str>,
workspace_id: Option<&str>,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivJournalCompaction,
minijinja::context! {
status => status,
snapshot_json => snapshot_json,
snapshot_sha256 => snapshot_sha256,
workspace_id => workspace_id,
source_digest => self.projection.source_digest,
projection_digest => self.projection.catalog_digest,
},
)
}
pub fn subjektiv_journal_startup(&self, snapshot_json: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivJournalStartup,
+3 -3
View File
@@ -736,9 +736,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
}
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => {
SessionEntryProvenance::DerivedSummary
}
WorkerHistoryProvenance::CompactionSummary
| WorkerHistoryProvenance::DerivedSummary
| WorkerHistoryProvenance::JournalContext => SessionEntryProvenance::DerivedSummary,
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
+11 -4
View File
@@ -156,10 +156,17 @@ pub(crate) fn restore_history_entries(
}
*target = from_logged(entry);
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
LogEntry::AnnotatedSystemItem { entry, .. } => {
history.push(HistoryEntry::new(entry.item.to_history_item(), {
let mut metadata = entry.metadata.clone();
// Typed startup records predate the dedicated Journal origin.
// Classify only this trusted event, never a plaintext marker.
if matches!(entry.item, session_store::SystemItem::JournalStartup { .. }) {
metadata.origin = WorkerHistoryProvenance::JournalContext;
}
metadata
}))
}
_ => {}
}
}
+47
View File
@@ -80,6 +80,17 @@ pub trait SubjektivHost: Send + Sync + 'static {
"Journal startup not granted".into(),
))
}
fn journal_compaction_granted(&self) -> bool {
false
}
fn journal_compaction_context(
&self,
_context: &SubjektivHostContext,
) -> Result<JournalStartupSnapshot, SubjektivHostError> {
Err(SubjektivHostError::Unavailable(
"Journal compaction not granted".into(),
))
}
/// Recording admission is separate from both Memory and Journal Job operation grants.
fn journal_recording_granted(&self) -> bool {
false
@@ -267,6 +278,29 @@ impl SubjektivHost for BackendSubjektivHost {
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
)
}
fn journal_compaction_granted(&self) -> bool {
true
}
fn journal_compaction_context(
&self,
context: &SubjektivHostContext,
) -> Result<JournalStartupSnapshot, SubjektivHostError> {
decode(
self.client
.execute_with_timeout(
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/journal/compaction", self.workspace_id),
serde_json::to_string(&transport::JournalCompactionRequest {
session_id: context.session_id.clone(),
})
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
),
Duration::from_secs(5),
)
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
)
}
fn journal_recording_granted(&self) -> bool {
// This adapter exists only for explicitly enabled, authenticated Host connections.
// The Backend still verifies the main Worker/Subject binding on every request.
@@ -488,6 +522,19 @@ impl SubjektivHostConnection {
}
self.host.journal_startup_context(&self.context)
}
pub fn journal_compaction_granted(&self) -> bool {
self.host.journal_compaction_granted()
}
pub fn journal_compaction_context(&self) -> Result<JournalStartupSnapshot, SubjektivHostError> {
if !self.host.journal_compaction_granted() {
return Err(SubjektivHostError::Unavailable(
"Journal compaction not granted".into(),
));
}
self.host.journal_compaction_context(&self.context)
}
pub fn new(
host: Arc<dyn SubjektivHost>,
context: SubjektivHostContext,
+58 -4
View File
@@ -6918,6 +6918,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// within `retained_tokens`. Item-granular, turn boundaries ignored.
let cut = self.split_for_retained(retained_tokens);
// Freeze one Host-owned read before starting the compactor. Background recording
// remains independent; never re-read when building the replacement segment.
let journal_context = crate::feature::builtin::journal::freeze_compaction_journal(
self.subjektiv_host_connection(),
&self.prompts.load_full(),
)
.map_err(WorkerError::PromptCatalog)?;
let history_entries = self.session.history().entries();
let retain_from = cut.index.min(history_entries.len());
let initial_summary = initial_compaction_summary(history_entries)?;
@@ -6926,7 +6933,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// it as well would duplicate inherited context on every compact.
let retained_history_entries = history_entries[retain_from..]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.filter(|entry| {
!matches!(
entry.annotation.origin,
WorkerHistoryProvenance::CompactionSummary
| WorkerHistoryProvenance::JournalContext
)
})
.cloned()
.collect::<Vec<_>>();
let mut retained_items = retained_history_entries
@@ -6943,7 +6956,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// never needs durable image bodies; relevant images are nominated with add_reference.
let mut entries_to_summarise = history_entries[..retain_from]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.filter(|entry| {
!matches!(
entry.annotation.origin,
WorkerHistoryProvenance::CompactionSummary
| WorkerHistoryProvenance::JournalContext
)
})
.cloned()
.collect::<Vec<_>>();
project_compactor_capture_attachments(&mut entries_to_summarise);
@@ -7013,7 +7032,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// The full Host summary is mandatory input, separate from the bounded
// prefix overview and current TaskStore snapshot.
let task_snapshot_text = self.task_feature.snapshot_text();
let summary_input = build_summary_input(
let mut summary_input = build_summary_input(
&items_to_summarise,
initial_summary.as_deref(),
&default_refs,
@@ -7025,6 +7044,28 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
summary_target_tokens,
},
);
// Previous references are historical source material, not additional latest
// Journals. In particular, a failed/empty read must not silently lose context
// that the previous summary intentionally delegated to its injected Journal.
// Carry them only into the compactor, never alongside the new reference seed.
for entry in history_entries
.iter()
.filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::JournalContext)
{
if let Some(body) = entry.item.as_text() {
summary_input.text.push_str(
"\n\n## Previous Journal reference (historical, not automatically retained)\n",
);
summary_input.text.push_str(body);
summary_input
.text
.push_str("\n## End previous Journal reference\n");
}
}
// Mandatory context, outside overview truncation. The internal Worker's normal
// request budget checks include this exact body and the full previous summary.
summary_input.text.push_str("\n\n");
summary_input.text.push_str(&journal_context);
if summary_input.warning_exceeded {
self.alert(
AlertLevel::Warn,
@@ -7340,6 +7381,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
crate::session_history::COMPACTION_SUMMARY_HEADER
));
compact_introduced_system_messages.push(summary_message.clone());
let journal_message = Item::system_message(journal_context);
compact_introduced_system_messages.push(journal_message.clone());
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
if let Some(msg) = reference_message.as_ref() {
compact_introduced_system_messages.push(msg.clone());
@@ -7352,6 +7395,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
compact_introduced_system_messages.push(task_snapshot_message.clone());
new_history.push(summary_message);
new_history.push(journal_message);
new_history.extend(auto_read_messages);
if let Some(msg) = reference_message {
new_history.push(msg);
@@ -7407,6 +7451,16 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}),
),
)
} else if index == 1 {
HistoryEntry::new(
item,
new_history_metadata(
WorkerHistoryProvenance::JournalContext,
Some(SessionHistoryDerivation {
sources: derived_sources.clone(),
}),
),
)
} else if let Some((offset, original)) = original_entries[original_cursor..]
.iter()
.enumerate()
@@ -16732,7 +16786,7 @@ permission = "write"
assert_eq!(replay[0].item.as_text(), Some("PRIOR_JOURNAL_REFERENCE"));
assert_eq!(
replay[0].annotation.origin,
WorkerHistoryProvenance::DerivedSummary
WorkerHistoryProvenance::JournalContext
);
let view =
crate::session_capture::SessionCapture::from_history_entries("startup", replay);