chore: merge develop into hare/develop

This commit is contained in:
2026-10-16 05:52:36 +09:00
85 changed files with 21016 additions and 329 deletions
+1
View File
@@ -43,6 +43,7 @@ libc = { workspace = true }
schemars = { workspace = true }
ticket = { workspace = true }
memory = { workspace = true }
subjektiv = { workspace = true }
uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true }
arc-swap = "1.9.1"
+32
View File
@@ -13,9 +13,16 @@ pub struct BackendJobExecutionBinding {
pub input_digest: Option<String>,
#[serde(default)]
pub subjektiv_consolidation: bool,
/// Trusted Host grant, independent of reusable Profile policy and Job purpose.
#[serde(default)]
pub subjektiv_journal: bool,
}
impl BackendJobExecutionBinding {
/// Subject maintenance Jobs are not the Subject's main conversation Session.
pub(crate) fn has_subjektiv_job_grant(&self) -> bool {
self.subjektiv_consolidation || self.subjektiv_journal
}
pub fn validate(&self) -> Result<(), &'static str> {
if [&self.job_id, &self.attempt_id]
.into_iter()
@@ -24,6 +31,31 @@ impl BackendJobExecutionBinding {
{
return Err("Backend Job binding contains an invalid identity or input digest");
}
if self.subjektiv_consolidation && self.subjektiv_journal {
return Err("Backend Job cannot combine Memory consolidation and Journal grants");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn omitted_journal_grant_is_disabled_and_mixed_subject_grants_are_rejected() {
let mut binding: BackendJobExecutionBinding = serde_json::from_value(serde_json::json!({
"job_id":"job", "attempt_id":"attempt", "subjektiv_consolidation":true,
}))
.unwrap();
assert!(!binding.subjektiv_journal);
binding.subjektiv_journal = true;
assert!(binding.validate().is_err());
assert!(
serde_json::from_value::<BackendJobExecutionBinding>(serde_json::json!({
"job_id":"job", "attempt_id":"attempt", "purpose":"journal"
}))
.is_err(),
"purpose is not a grant field"
);
}
}
+29 -1
View File
@@ -1439,7 +1439,19 @@ where
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new();
let journal_grant = backend_job
.as_ref()
.filter(|binding| binding.subjektiv_journal)
.map(|binding| {
crate::subjektiv::BackendJournalJobHost::new(worker.workspace_client_handle(), binding)
.map(|host| host.into_grant())
})
.transpose()?;
let (mut feature_registry, journal_finalizer) = if let Some(grant) = journal_grant {
(grant.features, grant.finalizer)
} else {
(FeatureRegistryBuilder::new(), None)
};
feature_registry
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
if let Some(binding) = backend_job.as_ref() {
@@ -1461,6 +1473,13 @@ where
)?;
result_feature = result_feature.with_surface(surface);
}
if let Some(finalizer) = journal_finalizer {
result_feature = result_feature.with_finalizer(
finalizer,
spawner_manifest.clone(),
worker.llm_client_handle(),
)?;
}
feature_registry.add_module(result_feature);
}
let memory_profile = &worker.manifest().feature.memory.profile;
@@ -1542,6 +1561,15 @@ where
{
feature_registry.add_module(subjektiv_sessions);
}
if ordinary_subjektiv_features_enabled
&& lifecycle_enabled
&& let Some(host) = subjektiv_host.as_ref()
&& host.host.journal_recording_granted()
{
feature_registry.add_module(crate::subjektiv::JournalRecordingFeature::new(
host.clone(),
)?);
}
if let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
lifecycle_enabled,
+1
View File
@@ -8,6 +8,7 @@ pub mod backend_job_result;
pub mod chat_invocation;
pub mod drive;
pub mod flow_transition;
pub mod journal;
pub mod manage_workdir;
pub mod manage_worker;
pub mod memory;
@@ -55,10 +55,18 @@ struct SealedResult {
completed: Value,
}
#[derive(Clone)]
struct ResultFinalizer {
finalizer: Arc<dyn crate::job::JobResultFinalizer>,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
}
#[derive(Clone)]
struct SubmitResultTool {
client: Arc<dyn WorkspaceClient>,
surface: Option<SubjektivSurfaceLifecycleFeature>,
finalizer: Option<ResultFinalizer>,
submission: Arc<tokio::sync::Mutex<ResultSubmissionState>>,
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
}
@@ -80,8 +88,7 @@ impl Drop for ActiveResultGuard {
fn cancelled_output() -> ToolError {
ToolError::Cancelled(ToolOutput {
summary: "Job result submission cancelled; committed candidate decisions are preserved"
.into(),
summary: "Job result submission cancelled; saved domain operations are preserved".into(),
content: None,
attachments: Vec::new(),
})
@@ -157,6 +164,17 @@ impl Tool for SubmitResultTool {
submission.reusable_surface = Some(outcome.clone());
object.insert("surface".into(), outcome);
}
if let Some(finalizer) = &self.finalizer {
result = finalizer
.finalizer
.finalize(
result,
finalizer.manifest.clone(),
finalizer.client.clone_boxed(),
cancellation.clone(),
)
.await?;
}
result
};
if cancellation.is_cancelled() {
@@ -238,6 +256,7 @@ impl Tool for SubmitResultTool {
fn definition(
client: Arc<dyn WorkspaceClient>,
surface: Option<SubjektivSurfaceLifecycleFeature>,
finalizer: Option<ResultFinalizer>,
) -> ToolDefinition {
let submission = Arc::new(tokio::sync::Mutex::new(ResultSubmissionState::default()));
let active = Arc::new(Mutex::new(HashMap::new()));
@@ -255,6 +274,7 @@ fn definition(
Arc::new(SubmitResultTool {
client: client.clone(),
surface: surface.clone(),
finalizer: finalizer.clone(),
submission: submission.clone(),
active: active.clone(),
}) as Arc<dyn Tool>,
@@ -266,6 +286,7 @@ fn definition(
pub struct BackendJobResultFeature {
client: Arc<dyn WorkspaceClient>,
surface: Option<SubjektivSurfaceLifecycleFeature>,
finalizer: Option<ResultFinalizer>,
}
impl BackendJobResultFeature {
@@ -273,11 +294,28 @@ impl BackendJobResultFeature {
Self {
client,
surface: None,
finalizer: None,
}
}
}
impl BackendJobResultFeature {
pub(crate) fn with_finalizer(
mut self,
finalizer: Arc<dyn crate::job::JobResultFinalizer>,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
) -> std::io::Result<Self> {
finalizer
.validate_profile(&manifest)
.map_err(std::io::Error::other)?;
self.finalizer = Some(ResultFinalizer {
finalizer,
manifest,
client,
});
Ok(self)
}
pub(crate) fn with_surface(mut self, surface: SubjektivSurfaceLifecycleFeature) -> Self {
self.surface = Some(surface);
self
@@ -294,7 +332,11 @@ impl FeatureModule for BackendJobResultFeature {
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
TOOL_NAME,
definition(self.client.clone(), self.surface.clone()),
definition(
self.client.clone(),
self.surface.clone(),
self.finalizer.clone(),
),
))
}
}
@@ -364,7 +406,7 @@ mod tests {
#[tokio::test]
async fn cancellation_before_execution_never_submits_a_result() {
let client = Arc::new(RecordingClient::default());
let (_, tool) = definition(client.clone(), None)();
let (_, tool) = definition(client.clone(), None, None)();
let context = ToolExecutionContext::new("result-call", "batch", 0);
tool.cancel_execution(&context).await.unwrap();
let outcome = tool.execute(r#"{"result":{"valid":true}}"#, context).await;
@@ -0,0 +1,637 @@
//! Journal admission and attenuated sequential Job tools, independent of Memory extraction.
//!
//! 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.
use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
HookErrorCategory, HookExecutionPolicy, HookPostToolAction, PostToolCall, RunCommitted,
RunCommittedContext, SessionRewriteKind, ToolResultSummary, WorkerRestored,
WorkerRestoredContext,
};
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
use subjektiv::journal::transport::*;
use subjektiv::journal::{
JournalAction, JournalOperation, MAX_JOURNAL_PAGE, MAX_JOURNAL_READ_BYTES,
MAX_JOURNAL_RESPONSE_BYTES,
};
#[derive(Clone)]
pub struct JournalRecordingFeature {
host: SubjektivHostConnection,
}
impl JournalRecordingFeature {
pub fn new(host: SubjektivHostConnection) -> std::io::Result<Self> {
if !host.host.journal_recording_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Journal recording requires an explicit Host grant",
));
}
Ok(Self { host })
}
fn request(&self, reason: JournalRecordingReason) -> Result<(), HookError> {
self.host
.host
.request_journal_recording(&self.host.context, reason)
.map(|_| ())
.map_err(|_| {
HookError::new(
HookErrorCategory::Dependency,
"Journal admission unconfirmed; Host must reconcile saved public history",
)
})
}
}
impl FeatureModule for JournalRecordingFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("journal-recording", "Journal recording admission")
.with_hook(HookDeclaration::new(
"record-task",
FeatureHookPoint::PostToolCall,
))
.with_hook(HookDeclaration::new(
"record-run",
FeatureHookPoint::RunCommitted,
))
.with_hook(HookDeclaration::new(
"record-compact",
FeatureHookPoint::BeforeSessionRewrite,
))
.with_hook(HookDeclaration::new(
"record-restore",
FeatureHookPoint::WorkerRestored,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.hooks().add_post_tool_call(
"record-task",
HookExecutionPolicy::new(crate::hook::HookFailurePolicy::AttentionRequired, 30_000),
self.clone(),
)?;
context.hooks().add_run_committed(
"record-run",
HookExecutionPolicy::new(crate::hook::HookFailurePolicy::AttentionRequired, 30_000),
self.clone(),
)?;
context.hooks().add_before_session_rewrite(
"record-compact",
HookExecutionPolicy::fail_closed(),
self.clone(),
)?;
context.hooks().add_worker_restored(
"record-restore",
HookExecutionPolicy::new(crate::hook::HookFailurePolicy::AttentionRequired, 30_000),
self.clone(),
)?;
Ok(())
}
}
#[async_trait]
impl Hook<PostToolCall> for JournalRecordingFeature {
async fn call(&self, result: &ToolResultSummary) -> Result<HookPostToolAction, HookError> {
if result.tool_name == "TaskUpdate"
&& !result.is_error
&& result.arguments.get("status").and_then(Value::as_str) == Some("completed")
{
self.request(JournalRecordingReason::TaskCompleted)?;
}
Ok(HookPostToolAction::Continue)
}
}
#[async_trait]
impl Hook<RunCommitted> for JournalRecordingFeature {
async fn call(&self, _: &RunCommittedContext) -> Result<(), HookError> {
self.request(JournalRecordingReason::RunCommitted)
}
}
#[async_trait]
impl Hook<BeforeSessionRewrite> for JournalRecordingFeature {
async fn call(
&self,
context: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
if context.kind == SessionRewriteKind::Compact {
// Only admission/retention is awaited, not Journal generation.
self.request(JournalRecordingReason::BeforeCompact)?;
}
Ok(BeforeSessionRewriteAction::Continue)
}
}
#[async_trait]
impl Hook<WorkerRestored> for JournalRecordingFeature {
async fn call(&self, _: &WorkerRestoredContext) -> Result<(), HookError> {
self.request(JournalRecordingReason::WorkerRestored)
}
}
/// A distinct Host capability, bound to one Journal/generation and frozen public input.
/// Profile names and main-Worker recording grants cannot construct this authority.
/// Implementations must preserve exact request_id/full input and reject stale leases.
pub trait JournalJobHost: Send + Sync + 'static {
fn execute(&self, request: JournalJobRequest)
-> Result<JournalJobResponse, SubjektivHostError>;
/// Verify saved checkpoints and remaining input; model prose is not completion evidence.
fn finish(&self) -> Result<JournalJobCompletion, SubjektivHostError>;
}
#[derive(Clone)]
pub struct JournalJobToolsFeature {
host: Arc<dyn JournalJobHost>,
serial: Arc<tokio::sync::Mutex<()>>,
}
impl JournalJobToolsFeature {
pub fn new(host: Arc<dyn JournalJobHost>) -> Self {
Self {
host,
serial: Arc::default(),
}
}
}
const TOOLS: &[(&str, &str)] = &[
(
"CreateJournalEntry",
"Save a new open or fixed event. Existing open must first be fixed; fixed needs source_end. Retain exact request_id/full input on retry; carry returned state_token into the next new operation.",
),
(
"RewriteJournalEntry",
"Rewrite only an open entry's title/body/ongoing. Never rewrite fixed history. Retain exact request_id/full input on retry.",
),
(
"FixJournalEntry",
"Fix an open event with an allowed original source endpoint. Task/Job/turn/compaction completion is NOT evidence that an event ended.",
),
(
"UpdateJournalPosition",
"Save a short current-position memo. This alone is never evidence for checkpointing source bytes.",
),
(
"ConfirmJournalUnchanged",
"Save an explicit checked no-entry-change conclusion for actually read source bytes. This receipt can support a checkpoint.",
),
(
"CheckpointJournal",
"Advance only the contiguous actually reflected source prefix backed by saved record/no-change request IDs. Never infer coverage from final prose or position updates.",
),
(
"ReadJournalState",
"Read bounded initial/current Journal metadata and immutable selected_ranges descriptors, not source bodies or proof of delivery. Fetch every selected Source range with ReadJournalText before any record, position, no-change or checkpoint mutation.",
),
(
"QueryJournalOperation",
"Reconcile a saved request_id after unknown outcome before any new create. Absence is not evidence of an execution failure.",
),
(
"ReadJournalOperationCoverage",
"Read a bounded page of exact saved source ranges for a request_id. Reconcile old receipts against selected bytes before reusing them in a replacement Job checkpoint; a metadata count alone is not coverage evidence.",
),
(
"ListJournalOperations",
"Page saved operation receipts for recovery without replaying record creation.",
),
(
"ReadJournalText",
"Read a bounded UTF-8 chunk of Host-bound initial/current position/open text or source text addressed by a frozen Source descriptor. Job input contains no raw source bodies: fetch every selected Source byte range, continuing with next_byte and content_token, before any mutation. Only successful Source delivery counts; failed fetches and previews do not. Do not mix later input into this generation.",
),
];
impl FeatureModule for JournalJobToolsFeature {
fn descriptor(&self) -> FeatureDescriptor {
TOOLS.iter().fold(
FeatureDescriptor::builtin("journal-job-tools", "Journal Job tools")
.with_description("Descriptor-only Journal Jobs fetch immutable selected Source bytes through bounded tools before sequential mutations; selection metadata is not delivery evidence."),
|descriptor, (name, description)| {
descriptor.with_tool(ToolDeclaration::new(*name, *description))
},
)
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
for &(name, description) in TOOLS {
let state = self.clone();
let definition: ToolDefinition = Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(tool_schema(name)),
Arc::new(JournalTool {
name,
state: state.clone(),
}) as Arc<dyn Tool>,
)
});
context
.tools()
.register(ToolContribution::new(name, definition))?;
}
Ok(())
}
}
struct JournalTool {
name: &'static str,
state: JournalJobToolsFeature,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Empty {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Query {
request_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Coverage {
request_id: String,
offset: usize,
limit: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct List {
after: Option<String>,
limit: usize,
}
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
#[async_trait]
impl Tool for JournalTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
let request = match self.name {
"ReadJournalState" => {
let _: Empty = parse(input)?;
JournalJobRequest::ReadState
}
"QueryJournalOperation" => {
let query: Query = parse(input)?;
JournalJobRequest::QueryOperation {
request_id: query.request_id,
}
}
"ReadJournalOperationCoverage" => {
let coverage: Coverage = parse(input)?;
if !(1..=MAX_JOURNAL_PAGE).contains(&coverage.limit) {
return Err(ToolError::InvalidArgument(
"limit outside Journal page budget".into(),
));
}
JournalJobRequest::ReadOperationCoverage {
request_id: coverage.request_id,
offset: coverage.offset,
limit: coverage.limit,
}
}
"ListJournalOperations" => {
let list: List = parse(input)?;
if !(1..=MAX_JOURNAL_PAGE).contains(&list.limit) {
return Err(ToolError::InvalidArgument(
"limit outside Journal page budget".into(),
));
}
JournalJobRequest::ListOperations {
after: list.after,
limit: list.limit,
}
}
"ReadJournalText" => {
let read: JournalTextRead = parse(input)?;
// A selected Source range may begin at a nonzero byte. Only the
// bound Host knows that first-chunk offset and enforces its token
// condition; position/open reads always start at zero.
if !(1..=MAX_JOURNAL_READ_BYTES).contains(&read.limit)
|| (read.offset > 0
&& read.content_token.is_none()
&& !matches!(&read.target, JournalTextTarget::Source { .. }))
{
return Err(ToolError::InvalidArgument(
"invalid bounded text read or missing continuation content_token".into(),
));
}
JournalJobRequest::ReadText { read }
}
_ => {
let operation: JournalOperation = parse(input)?;
let correct = matches!(
(self.name, &operation.action),
("CreateJournalEntry", JournalAction::Create { .. })
| ("RewriteJournalEntry", JournalAction::Rewrite { .. })
| ("FixJournalEntry", JournalAction::Fix { .. })
| (
"UpdateJournalPosition",
JournalAction::UpdatePosition { .. }
)
| ("ConfirmJournalUnchanged", JournalAction::ConfirmUnchanged)
| ("CheckpointJournal", JournalAction::Checkpoint { .. })
);
if !correct {
return Err(ToolError::InvalidArgument(
"action does not match Journal tool".into(),
));
}
JournalJobRequest::Execute { operation }
}
};
let _serial = self.state.serial.lock().await;
let response = self.state.host.execute(request)?;
let content = serde_json::to_string(&response)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if content.len() > MAX_JOURNAL_RESPONSE_BYTES {
return Err(ToolError::ExecutionFailed(
"Journal response exceeds bounded output; reconcile saved operation before retry"
.into(),
));
}
Ok(ToolOutput {
summary: format!("{}: Host response", self.name),
content: Some(content),
attachments: vec![],
})
}
}
/// Checks Host-saved state at the existing Job result boundary, not a whole-Job write.
pub struct JournalJobFinalizer {
host: Arc<dyn JournalJobHost>,
}
impl JournalJobFinalizer {
pub fn new(host: Arc<dyn JournalJobHost>) -> Self {
Self { host }
}
}
#[async_trait]
impl crate::job::JobResultFinalizer for JournalJobFinalizer {
fn validate_profile(&self, manifest: &manifest::WorkerManifest) -> Result<(), String> {
// Host-selected source bytes must reach the model intact, not be silently
// truncated by the generic Engine output limiter after domain delivery.
if TOOLS.iter().any(|(name, _)| {
manifest.engine.tool_output.limit_for(name) < MAX_JOURNAL_RESPONSE_BYTES
}) {
return Err(
"Journal tool output limits must preserve the complete bounded Host response"
.into(),
);
}
Ok(())
}
async fn finalize(
&self,
result: Value,
_: manifest::WorkerManifest,
_: Box<dyn agen::llm_client::LlmClient>,
cancellation: crate::feature::background::BackgroundTaskCancellation,
) -> Result<Value, ToolError> {
if cancellation.is_cancelled() {
return Err(ToolError::ExecutionFailed("Journal Job cancelled".into()));
}
if result != json!({}) {
return Err(ToolError::InvalidArgument(
"Submit empty result {}; Journal changes must already be saved by sequential tools"
.into(),
));
}
let completion = self.host.finish()?;
serde_json::to_value(completion)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))
}
}
fn object(properties: Value, required: &[&str]) -> Value {
json!({"type":"object", "properties":properties, "required":required, "additionalProperties":false})
}
fn tool_schema(name: &str) -> Value {
let string = json!({"type":"string"});
let pointer = object(
json!({"segment_id":{"type":"string","format":"uuid"},"entry_ref":string}),
&["segment_id", "entry_ref"],
);
let range = object(
json!({"source":object(json!({"session_id":string,"pointer":pointer,"order":{"type":"integer","minimum":0},"content_sha256":string,"total_bytes":{"type":"integer","minimum":1}}), &["session_id","pointer","order","content_sha256","total_bytes"]),"bytes":object(json!({"start":{"type":"integer","minimum":0},"end":{"type":"integer","minimum":1}}), &["start","end"])}),
&["source", "bytes"],
);
match name {
"ReadJournalState" => object(json!({}), &[]),
"QueryJournalOperation" => object(json!({"request_id":string}), &["request_id"]),
"ReadJournalOperationCoverage" => object(
json!({"request_id":string,"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":MAX_JOURNAL_PAGE}}),
&["request_id", "offset", "limit"],
),
"ListJournalOperations" => object(
json!({"after":{"type":["string","null"]},"limit":{"type":"integer","minimum":1,"maximum":MAX_JOURNAL_PAGE}}),
&["limit"],
),
"ReadJournalText" => {
let snapshot = json!({"type":"string","enum":["initial","current"]});
let targets = ["position", "open_body", "open_ongoing"].map(|target| {
object(
json!({"target":{"const":target},"snapshot":snapshot}),
&["target", "snapshot"],
)
});
object(
json!({"target":{"oneOf":[targets[0],targets[1],targets[2],object(json!({"target":{"const":"source"},"pointer":pointer}), &["target","pointer"])]},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":MAX_JOURNAL_READ_BYTES},"content_token":{"type":["string","null"]}}),
&["target", "offset", "limit"],
)
}
_ => {
let strings = json!({"type":"array","items":{"type":"string"}});
let action = match name {
"CreateJournalEntry" => object(
json!({"action":{"const":"create"},"title":string,"body":string,"ongoing":string,"state":{"enum":["open","fixed"]},"source_end":{"anyOf":[pointer,{"type":"null"}]},"correction_refs":strings}),
&[
"action",
"title",
"body",
"ongoing",
"state",
"correction_refs",
],
),
"RewriteJournalEntry" => object(
json!({"action":{"const":"rewrite"},"entry_id":string,"title":string,"body":string,"ongoing":string}),
&["action", "entry_id", "title", "body", "ongoing"],
),
"FixJournalEntry" => object(
json!({"action":{"const":"fix"},"entry_id":string,"source_end":pointer}),
&["action", "entry_id", "source_end"],
),
"UpdateJournalPosition" => object(
json!({"action":{"const":"update_position"},"current_position":string}),
&["action", "current_position"],
),
"ConfirmJournalUnchanged" => {
object(json!({"action":{"const":"confirm_unchanged"}}), &["action"])
}
"CheckpointJournal" => object(
json!({"action":{"const":"checkpoint"},"record_operation_ids":strings}),
&["action", "record_operation_ids"],
),
_ => unreachable!(),
};
object(
json!({"request_id":string,"expected_state":string,"action":action,"covered_ranges":{"type":"array","items":range,"maxItems":64}}),
&["request_id", "expected_state", "action", "covered_ranges"],
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use subjektiv::journal::JournalReceipt;
#[derive(Default)]
struct Host {
requests: Mutex<Vec<JournalJobRequest>>,
}
impl JournalJobHost for Host {
fn execute(
&self,
request: JournalJobRequest,
) -> Result<JournalJobResponse, SubjektivHostError> {
self.requests.lock().unwrap().push(request.clone());
match request {
JournalJobRequest::Execute { operation } => {
Ok(JournalJobResponse::Saved(JournalReceipt {
request_id: operation.request_id,
generation_id: "generation".into(),
entry_id: None,
state_token: "saved-token".into(),
process_order: 0,
process_byte: 0,
}))
}
_ => Ok(JournalJobResponse::Operation(None)),
}
}
fn finish(&self) -> Result<JournalJobCompletion, SubjektivHostError> {
Ok(JournalJobCompletion {
generation_id: "generation".into(),
state_token: "saved-token".into(),
process_order: 1,
process_byte: 0,
})
}
}
async fn call(
host: Arc<Host>,
name: &'static str,
input: Value,
) -> Result<ToolOutput, ToolError> {
JournalTool {
name,
state: JournalJobToolsFeature::new(host),
}
.execute(
&input.to_string(),
ToolExecutionContext::new("call", "batch", 0),
)
.await
}
#[tokio::test]
async fn operation_retry_keeps_complete_original_input_and_exposes_saved_token() {
let host = Arc::new(Host::default());
let input = json!({"request_id":"stable-id","expected_state":"old-token", "action":{"action":"confirm_unchanged"},"covered_ranges":[]});
for _ in 0..2 {
let output = call(host.clone(), "ConfirmJournalUnchanged", input.clone())
.await
.unwrap();
let output: Value = serde_json::from_str(output.content.as_ref().unwrap()).unwrap();
assert_eq!(output["value"]["state_token"], "saved-token");
}
let requests = host.requests.lock().unwrap();
assert_eq!(requests[0], requests[1]);
let JournalJobRequest::Execute { operation } = &requests[0] else {
panic!("expected operation")
};
assert_eq!(serde_json::to_value(operation).unwrap(), input);
}
#[tokio::test]
async fn first_source_chunk_can_start_at_nonzero_selected_offset_without_a_token() {
let host = Arc::new(Host::default());
let input = json!({
"target":{"target":"source","pointer":{"segment_id":uuid::Uuid::nil(),"entry_ref":"Eselected"}},
"offset":32,"limit":16,"content_token":null
});
let schema = tool_schema("ReadJournalText");
assert!(jsonschema::validator_for(&schema).unwrap().is_valid(&input));
call(host.clone(), "ReadJournalText", input.clone())
.await
.unwrap();
let requests = host.requests.lock().unwrap();
let JournalJobRequest::ReadText { read } = &requests[0] else {
panic!("expected Source read")
};
assert_eq!(serde_json::to_value(read).unwrap(), input);
// Source membership, first offset and later token conditions remain Host checks.
}
#[tokio::test]
async fn model_cannot_select_subject_or_cross_tool_action_or_unbounded_read() {
let host = Arc::new(Host::default());
for (name, input) in [
("ReadJournalState", json!({"subject_id":"foreign"})),
(
"FixJournalEntry",
json!({"request_id":"id","expected_state":"state", "action":{"action":"confirm_unchanged"},"covered_ranges":[]}),
),
(
"ReadJournalText",
json!({"target":{"target":"position","snapshot":"current"},"offset":0,"limit":MAX_JOURNAL_READ_BYTES+1}),
),
(
"ReadJournalText",
json!({"target":{"target":"position","snapshot":"current"},"offset":1,"limit":10}),
),
(
"ReadJournalOperationCoverage",
json!({"request_id":"old-record","offset":0,"limit":MAX_JOURNAL_PAGE+1}),
),
(
"ReadJournalOperationCoverage",
json!({"request_id":"old-record","offset":0,"limit":1,"journal_id":"foreign"}),
),
("ListJournalOperations", json!({"limit":MAX_JOURNAL_PAGE+1})),
] {
assert!(call(host.clone(), name, input).await.is_err(), "{name}");
}
assert!(host.requests.lock().unwrap().is_empty());
}
#[test]
fn truncating_profile_output_budget_is_rejected_before_source_delivery() {
use crate::job::JobResultFinalizer;
let finalizer = JournalJobFinalizer::new(Arc::new(Host::default()));
let mut manifest = manifest::WorkerManifest::from_toml("[worker]\nname='journal-budget'\n[model]\nscheme='anthropic'\nmodel_id='scripted'\n[engine]\n[scope]\nallow=[]").unwrap();
assert!(finalizer.validate_profile(&manifest).is_ok());
manifest
.engine
.tool_output
.per_tool
.insert("ReadJournalText".into(), MAX_JOURNAL_RESPONSE_BYTES - 1);
assert!(finalizer.validate_profile(&manifest).is_err());
}
#[test]
fn model_schemas_compile_and_constrain_action_and_scope() {
for &(name, _) in TOOLS {
let schema = tool_schema(name);
let validator = jsonschema::validator_for(&schema).unwrap();
assert!(
!validator.is_valid(&json!({"subject_id":"foreign"})),
"{name}"
);
}
let schema = tool_schema("CheckpointJournal");
let validator = jsonschema::validator_for(&schema).unwrap();
assert!(validator.is_valid(&json!({"request_id":"checkpoint", "expected_state":"saved-token", "action":{"action":"checkpoint","record_operation_ids":["saved-record"]},"covered_ranges":[]})));
assert!(!validator.is_valid(&json!({"request_id":"checkpoint", "expected_state":"saved-token", "action":{"action":"confirm_unchanged"},"covered_ranges":[]})));
}
}
+4 -1
View File
@@ -405,12 +405,15 @@ pub struct ToolCallSummary {
pub arguments: Value,
}
/// Information passed to `PostToolCall` hooks.
/// Information passed to `PostToolCall` hooks after the terminal result's
/// annotation/storage callback succeeds. Failure to persist prevents this callback.
pub struct ToolResultSummary {
/// Provider-assigned tool call id this result corresponds to.
pub call_id: String,
/// Registered tool name.
pub tool_name: String,
/// Original call arguments, read-only and never an authorization grant.
pub arguments: Value,
/// Whether the tool reported an error.
pub is_error: bool,
/// Tool output (`summary` always present, `content` may be `None`).
+1
View File
@@ -747,6 +747,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
let summary = ToolResultSummary {
call_id: info.result.tool_use_id.clone(),
tool_name: info.call.name.clone(),
arguments: info.call.input.clone(),
is_error: info.result.is_error,
output: ToolOutput {
summary: info.result.summary.clone(),
+157
View File
@@ -102,6 +102,163 @@ fn request(profile: &str) -> ::job::JobRequest {
limits: Default::default(),
}
}
#[derive(Default)]
struct JournalHostFixture(Mutex<Vec<subjektiv::journal::JournalOperation>>);
impl crate::subjektiv::JournalJobHost for JournalHostFixture {
fn execute(
&self,
request: subjektiv::journal::transport::JournalJobRequest,
) -> Result<
subjektiv::journal::transport::JournalJobResponse,
crate::subjektiv::SubjektivHostError,
> {
use subjektiv::journal::{JournalReceipt, transport::*};
let JournalJobRequest::Execute { operation } = request else {
panic!("script only writes")
};
let mut saved = self.0.lock().unwrap();
assert_eq!(operation.expected_state, format!("state-{}", saved.len()));
let request_id = operation.request_id.clone();
saved.push(operation);
Ok(JournalJobResponse::Saved(JournalReceipt {
request_id,
generation_id: "generation".into(),
entry_id: None,
state_token: format!("state-{}", saved.len()),
process_order: 0,
process_byte: 0,
}))
}
fn finish(
&self,
) -> Result<
subjektiv::journal::transport::JournalJobCompletion,
crate::subjektiv::SubjektivHostError,
> {
let saved = self.0.lock().unwrap();
assert!(
matches!(saved.last().map(|op| &op.action), Some(subjektiv::journal::JournalAction::Checkpoint { record_operation_ids }) if record_operation_ids == &["record".to_string()])
);
Ok(subjektiv::journal::transport::JournalJobCompletion {
generation_id: "generation".into(),
state_token: format!("state-{}", saved.len()),
process_order: 0,
process_byte: 0,
})
}
}
// Real built-in registry/Profile/Prompt resolution, Job runner, Feature tools and result finalizer.
// Only external model transport and the domain Host are replaced; store rules belong to Subjektiv.
#[tokio::test]
async fn builtin_journal_profile_installs_only_bound_tools_and_finalizes_saved_sequential_progress()
{
use crate::subjektiv::{JournalJobFinalizer, JournalJobToolsFeature};
let root = tempfile::tempdir().unwrap();
let registry = ProfileDiscovery::with_sources(None, None)
.discover()
.unwrap();
let host = Arc::new(JournalHostFixture::default());
let client = ScriptedClient::new(vec![
call(
"ConfirmJournalUnchanged",
json!({"request_id":"record","expected_state":"state-0","action":{"action":"confirm_unchanged"},"covered_ranges":[]}),
),
call(
"UpdateJournalPosition",
json!({"request_id":"position","expected_state":"state-1","action":{"action":"update_position","current_position":"Still in progress; no confirmed outcome."},"covered_ranges":[]}),
),
call(
"CheckpointJournal",
json!({"request_id":"checkpoint","expected_state":"state-2","action":{"action":"checkpoint","record_operation_ids":["record"]},"covered_ranges":[]}),
),
call(
SUBMIT_JOB_RESULT_TOOL,
json!({"result":{"fabricated_aggregate":true}}),
),
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{}})),
prose(),
]);
// This runner/Feature fixture has no selected sources; descriptor delivery gates
// for nonempty ranges are owned and tested by the Subjektiv service.
let mut journal_request = request("builtin:subjektiv-journal-recording");
journal_request.input = json!({"selected_ranges":[],"origins":[]});
journal_request.instruction = "Read immutable selected_ranges through ReadJournalText Source before recording; descriptors are not source bodies.".into();
let job = prepare_job_from_registry_with_grant(
&journal_request,
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
features: FeatureRegistryBuilder::new()
.with_module(JournalJobToolsFeature::new(host.clone())),
satisfied_requirements: vec![],
finalizer: Some(Arc::new(JournalJobFinalizer::new(host.clone()))),
},
)
.unwrap();
assert!(!job.manifest.feature.subjektiv.profile.enabled);
assert!(job.manifest.compaction.is_none());
assert!(job.manifest.scope.allow.is_empty());
assert!(job.manifest.delegation_scope.allow.is_empty());
assert_eq!(
job.manifest.engine.instruction,
"internal.subjektiv_journal_system"
);
let sink = Arc::new(RecordingSink::default());
job.run("attempt".into(), sink.clone(), |_| {})
.await
.unwrap();
assert_eq!(host.0.lock().unwrap().len(), 3);
let accepted = sink.0.lock().unwrap();
assert_eq!(accepted.len(), 1);
assert_eq!(accepted[0].result["state_token"], "state-3");
let sent = client.requests.lock().unwrap();
assert!(!sent.is_empty());
for request in sent.iter() {
let names = request
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
for name in [
"CreateJournalEntry",
"RewriteJournalEntry",
"FixJournalEntry",
"ReadJournalText",
SUBMIT_JOB_RESULT_TOOL,
] {
assert!(names.contains(&name), "missing {name}");
}
for forbidden in [
"TaskCreate",
"SubjektivMemoryRemember",
"SubjektivSessionRead",
"Bash",
"Write",
"JournalRecordResult",
] {
assert!(!names.contains(&forbidden), "unexpected {forbidden}");
}
let prompt = request.system_prompt.as_deref().unwrap();
for contract in [
"OutcomeUnknown",
"selected_ranges",
"origins",
"ReadJournalState",
"ReadJournalText",
"Source",
"content_token",
"Conflict",
] {
assert!(
prompt.contains(contract),
"missing Journal input contract {contract}"
);
}
}
}
fn artifact(instruction: &str, tokens: u32, turns: u32) -> Value {
json!({"model":{"scheme":"anthropic", "model_id":"scripted-model", "auth":{"kind":"none"}},
"engine":{"instruction":instruction, "language":"English", "max_tokens":tokens, "max_turns":turns}})
+1
View File
@@ -88,6 +88,7 @@ mod tests {
ToolResultSummary {
call_id: "tool-1".to_string(),
tool_name: name.to_string(),
arguments: serde_json::json!({}),
is_error,
output: ToolOutput {
summary: "result".to_string(),
+398
View File
@@ -7,6 +7,12 @@
pub use crate::feature::builtin::memory::SubjektivConsolidationToolsFeature;
pub use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
pub use crate::feature::builtin::journal::{
JournalJobFinalizer, JournalJobHost, JournalJobToolsFeature, JournalRecordingFeature,
};
pub use subjektiv::journal::transport;
use transport::{JournalRecordingReason, JournalRecordingRequest, JournalRecordingResponse};
use std::sync::Arc;
use std::time::Duration;
@@ -60,6 +66,19 @@ impl From<SubjektivHostError> for ToolError {
/// identifier is not a grant. No method grants ambient filesystem access.
pub trait SubjektivHost: Send + Sync + 'static {
fn settings(&self) -> SubjektivHostSettings;
/// Recording admission is separate from both Memory and Journal Job operation grants.
fn journal_recording_granted(&self) -> bool {
false
}
fn request_journal_recording(
&self,
_context: &SubjektivHostContext,
_reason: JournalRecordingReason,
) -> Result<JournalRecordingResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable(
"Journal recording not granted".into(),
))
}
/// Separate explicit grant for decision/surface execution; Profile policy
/// alone must never authorize consolidation tools.
fn consolidation_granted(&self) -> bool {
@@ -188,6 +207,33 @@ fn decode<T: serde::de::DeserializeOwned>(
}
impl SubjektivHost for BackendSubjektivHost {
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.
true
}
fn request_journal_recording(
&self,
_context: &SubjektivHostContext,
reason: JournalRecordingReason,
) -> Result<JournalRecordingResponse, SubjektivHostError> {
if !self.journal_recording_granted() {
return Err(SubjektivHostError::Unavailable(
"Journal recording not granted".into(),
));
}
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/journal/recording", self.workspace_id),
serde_json::to_string(&JournalRecordingRequest { reason })
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
decode(
self.client
.execute(request)
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
)
}
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: self.settings.language.clone(),
@@ -292,6 +338,79 @@ impl SubjektivHost for BackendSubjektivHost {
}
}
/// Authenticated Backend Journal Job adapter, issued only from an explicit Host binding.
/// The Backend resolves Subject, Journal, generation and current attempt from that
/// authenticated Worker; no identity is copied into model input or request bodies.
pub struct BackendJournalJobHost {
client: Arc<dyn WorkspaceClient>,
workspace_id: String,
}
impl BackendJournalJobHost {
pub fn new(
client: Arc<dyn WorkspaceClient>,
binding: &crate::BackendJobExecutionBinding,
) -> std::io::Result<Self> {
binding.validate().map_err(std::io::Error::other)?;
if !binding.subjektiv_journal || !client.is_available() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Journal Job requires explicit Host grant and available Backend authority",
));
}
let workspace_id = client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Journal Job requires authenticated Workspace identity",
)
})?
.to_string();
Ok(Self {
client,
workspace_id,
})
}
pub fn into_grant(self) -> crate::job::JobFeatureGrant {
let host = Arc::new(self);
crate::job::JobFeatureGrant {
features: crate::feature::FeatureRegistryBuilder::new()
.with_module(JournalJobToolsFeature::new(host.clone())),
satisfied_requirements: vec![],
finalizer: Some(Arc::new(JournalJobFinalizer::new(host))),
}
}
fn post<T: serde::de::DeserializeOwned>(
&self,
suffix: &str,
body: String,
) -> Result<T, SubjektivHostError> {
decode(
self.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/journal/job{suffix}", self.workspace_id),
body,
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
)
}
}
impl JournalJobHost for BackendJournalJobHost {
fn execute(
&self,
request: transport::JournalJobRequest,
) -> Result<transport::JournalJobResponse, SubjektivHostError> {
let body = serde_json::to_string(&request)
.map_err(|error| SubjektivHostError::InvalidArgument(error.to_string()))?;
self.post("", body)
}
fn finish(&self) -> Result<transport::JournalJobCompletion, SubjektivHostError> {
self.post("/finish", "{}".into())
}
}
/// Live connection with Worker-supplied authority. Not serializable and never
/// inherited by an ordinary SubWorker.
#[derive(Clone)]
@@ -429,8 +548,27 @@ mod tests {
struct LocalHost {
contexts: Mutex<Vec<SubjektivHostContext>>,
attributed: Mutex<Vec<(SubjektivHostContext, bool)>>,
journal_observer:
Option<Arc<dyn Fn(&SubjektivHostContext, JournalRecordingReason) + Send + Sync>>,
}
impl SubjektivHost for LocalHost {
fn journal_recording_granted(&self) -> bool {
self.journal_observer.is_some()
}
fn request_journal_recording(
&self,
context: &SubjektivHostContext,
reason: JournalRecordingReason,
) -> Result<JournalRecordingResponse, SubjektivHostError> {
if let Some(observer) = &self.journal_observer {
observer(context, reason);
Ok(JournalRecordingResponse::Accepted)
} else {
Err(SubjektivHostError::Unavailable(
"test Host did not grant Journal recording".into(),
))
}
}
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: "English".into(),
@@ -521,6 +659,266 @@ mod tests {
}
}
#[derive(Clone, Default)]
struct TaskScriptClient(Arc<std::sync::atomic::AtomicUsize>);
#[async_trait]
impl LlmClient for TaskScriptClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let step = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let call = match step {
0 => Some((
"create",
"TaskCreate",
r#"{"subject":"verify boundary","description":"persist before admission"}"#,
)),
1 => Some((
"failed",
"TaskUpdate",
r#"{"taskid":999,"status":"completed"}"#,
)),
2 => Some((
"progress",
"TaskUpdate",
r#"{"taskid":1,"status":"inprogress"}"#,
)),
3 => Some((
"complete",
"TaskUpdate",
r#"{"taskid":1,"status":"completed"}"#,
)),
_ => None,
};
if step > 4 {
return Err(ClientError::Config("scripted interruption".into()));
}
let mut events = if let Some((id, name, input)) = call {
vec![
LlmEvent::tool_use_start(0, id, name),
LlmEvent::tool_input_delta(0, input),
LlmEvent::tool_use_stop(0),
]
} else {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "Done."),
LlmEvent::text_block_stop(0, None),
]
};
events.push(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}));
Ok(Box::pin(futures_util::stream::iter(
events.into_iter().map(Ok),
)))
}
}
// Integration boundary: real Bootstrap/controller, Task tools, Agen, annotation writer,
// and FsStore. Only the external Journal Host and LLM transport are replaced.
#[tokio::test]
async fn journal_task_admission_observes_saved_success_before_run_finishes_and_each_run_requests()
{
use session_store::{LogEntry, LoggedItem, Store};
let temp = tempfile::tempdir().unwrap();
let _sandbox =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let mut manifest = WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "journal-boundary-{}"
[model]
scheme = "anthropic"
model_id = "scripted-model"
[engine]
[scope]
allow = []
[feature.task]
enabled = true
"#,
uuid::Uuid::now_v7()
))
.unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = false;
let store = CombinedStore::new(
FsStore::new(temp.path().join("sessions")).unwrap(),
FsWorkerStore::new(temp.path().join("workers")).unwrap(),
);
let read_store = store.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let host = Arc::new(LocalHost {
journal_observer: Some(Arc::new(move |context, reason| {
let session_id = context.session_id.parse().unwrap();
let logs = read_store
.list_segments(session_id)
.unwrap()
.into_iter()
.flat_map(|segment| read_store.read_all(session_id, segment).unwrap())
.collect::<Vec<_>>();
let complete_saved = logs.iter().any(|entry| matches!(entry,
LogEntry::AnnotatedToolResult { entry, .. }
if matches!(&entry.item, LoggedItem::ToolResult { call_id, is_error: false, .. } if call_id == "complete")));
tx.send((reason, complete_saved)).unwrap();
})),
..LocalHost::default()
});
let prepared = WorkerBootstrap::new(
manifest,
store,
crate::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash-output"),
},
WorkerControllerTransport::InProcess,
)
.with_model_client(TaskScriptClient::default())
.with_subjektiv_host(host)
.prepare()
.await
.unwrap();
let controller = prepared.start().await.unwrap();
controller
.handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Use the task tools.",
))
.await
.unwrap();
let first = tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(
first,
(JournalRecordingReason::TaskCompleted, true),
"Task failed/inprogress must not request admission; completed result must already be saved"
);
let second = tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(second, (JournalRecordingReason::RunCommitted, true));
controller
.handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Short interrupted turn.",
))
.await
.unwrap();
let third = tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(
third.0,
JournalRecordingReason::RunCommitted,
"interrupted short runs also request Host reconciliation"
);
controller
.handle
.send(Method::Shutdown {
command: protocol::WorkerCommandEnvelope::new(1),
})
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(5), controller.shutdown)
.await
.unwrap()
.unwrap();
controller.controller_task.await.unwrap();
}
#[tokio::test]
async fn journal_boundaries_request_without_threshold_and_only_compact_rewrite_requests() {
use crate::hook::*;
let reasons = Arc::new(Mutex::new(Vec::new()));
let observed = reasons.clone();
let host = Arc::new(LocalHost {
journal_observer: Some(Arc::new(move |_, reason| {
observed.lock().unwrap().push(reason);
})),
..LocalHost::default()
});
let connection = SubjektivHostConnection::new(
host,
SubjektivHostContext {
worker_id: "worker".into(),
session_id: "session".into(),
},
)
.unwrap();
let feature = JournalRecordingFeature::new(connection).unwrap();
for exit in [
RunCommittedExit::Finished,
RunCommittedExit::Interrupted,
RunCommittedExit::Paused,
RunCommittedExit::Yielded,
] {
<JournalRecordingFeature as Hook<RunCommitted>>::call(
&feature,
&RunCommittedContext {
invocation: HookInvocationContext::default(),
exit,
committed_history: HookHistoryRange::default(),
},
)
.await
.unwrap();
}
for kind in [
SessionRewriteKind::Rewind,
SessionRewriteKind::Compact,
SessionRewriteKind::Fork,
SessionRewriteKind::Restore,
] {
assert_eq!(
<JournalRecordingFeature as Hook<BeforeSessionRewrite>>::call(
&feature,
&BeforeSessionRewriteContext {
invocation: HookInvocationContext::default(),
kind,
current_history: HookHistoryRange::default(),
}
)
.await
.unwrap(),
BeforeSessionRewriteAction::Continue
);
}
<JournalRecordingFeature as Hook<WorkerRestored>>::call(
&feature,
&WorkerRestoredContext::new(
HookInvocationContext::default(),
RestoreSystemItemAppendHandle::new(Arc::default()),
),
)
.await
.unwrap();
assert_eq!(
*reasons.lock().unwrap(),
vec![
JournalRecordingReason::RunCommitted,
JournalRecordingReason::RunCommitted,
JournalRecordingReason::RunCommitted,
JournalRecordingReason::RunCommitted,
JournalRecordingReason::BeforeCompact,
JournalRecordingReason::WorkerRestored,
]
);
}
async fn launch_case(enabled: bool, attach: bool) {
let temp = tempfile::tempdir().unwrap();
let _runtime_sandbox =
+151 -32
View File
@@ -7781,12 +7781,12 @@ where
if self
.backend_job()
.is_some_and(|binding| binding.subjektiv_consolidation)
.is_some_and(crate::BackendJobExecutionBinding::has_subjektiv_job_grant)
{
if metadata.subjektiv_session_attribution.is_some() {
return Err(WorkerError::SubjektivSessionAttribution {
state: "job_attribution_forbidden",
message: "consolidation Job execution must not carry subject-body Session attribution".into(),
message: "Subject maintenance Job execution must not carry subject-body Session attribution".into(),
});
}
return Ok(());
@@ -7815,7 +7815,10 @@ where
});
}
match state {
SubjektivSessionAttributionState::Confirmed { .. } => return Ok(()),
SubjektivSessionAttributionState::Confirmed { subject_id, .. } => {
return self
.retain_journal_source_before_publication(session_id, subject_id);
}
SubjektivSessionAttributionState::OutcomeUnknown { .. }
| SubjektivSessionAttributionState::Failed { .. } => true,
SubjektivSessionAttributionState::LegacyUnknown { .. } => false,
@@ -7970,8 +7973,44 @@ where
}
self.persist_subjektiv_session_attribution(SubjektivSessionAttributionState::Confirmed {
session_id,
subject_id: output.subject_id,
})
subject_id: output.subject_id.clone(),
})?;
self.retain_journal_source_before_publication(session_id, &output.subject_id)
}
fn retain_journal_source_before_publication(
&self,
session_id: SessionId,
subject_id: &str,
) -> Result<(), WorkerError> {
let Some(connection) = self.subjektiv_host_connection().map_err(|error| {
WorkerError::SubjektivSessionAttribution {
state: "retention_unconfirmed",
message: error.to_string(),
}
})?
else {
return Ok(());
};
if !connection.host.journal_recording_granted() {
return Ok(());
}
let pin = session_store::retention::SessionRetentionPin(
match self.workspace_client().workspace_id() {
Some(workspace) => subjektiv::journal::source_retention_pin(
workspace,
subject_id,
&session_id.to_string(),
),
None => session_id,
},
);
// Confirmation can be replayed after response loss. Persist this intent before
// exposing the Worker to input, not after the first RunCommitted notification.
// It does not claim that an original Session has materialized yet.
self.store
.retain_session_before_publication(session_id, pin)?;
Ok(())
}
fn persist_subjektiv_session_attribution(
@@ -11111,7 +11150,8 @@ mod build_summary_prompt_tests {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_digest: None,
subjektiv_consolidation: false
subjektiv_consolidation: false,
subjektiv_journal: false,
})
.is_err()
);
@@ -11119,35 +11159,114 @@ mod build_summary_prompt_tests {
}
#[tokio::test]
async fn consolidation_job_binding_is_immutable_and_has_no_subject_body_attribution() {
let client = Arc::new(AttributionLifecycleClient::new([]));
let (mut worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
let binding = crate::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_digest: Some("input-content-digest".into()),
subjektiv_consolidation: true,
};
worker.bind_backend_job(binding.clone()).unwrap();
assert_eq!(worker.backend_job(), Some(&binding));
assert!(worker.bind_backend_job(binding).is_err());
for lifecycle in [
SubjektivSessionAttributionLifecycle::NewSession,
SubjektivSessionAttributionLifecycle::RestoredSession,
] {
async fn journal_attribution_protects_first_publication_before_any_admission_notification() {
for enabled in [false, true] {
let client = Arc::new(AttributionLifecycleClient::new([]));
let (mut worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
if enabled {
worker.manifest.feature.subjektiv.profile.enabled = true;
worker
.manifest
.feature
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".into(),
language: "English".into(),
})
.unwrap();
}
let session = worker.session_id();
assert!(store.session_store.list_sessions().unwrap().is_empty());
worker
.finalize_subjektiv_session_attribution(true, lifecycle)
.finalize_subjektiv_session_attribution(
true,
SubjektivSessionAttributionLifecycle::NewSession,
)
.unwrap();
}
assert!(client.requests.lock().unwrap().is_empty());
assert!(
let pin = session_store::retention::SessionRetentionPin(
subjektiv::journal::source_retention_pin(
"workspace-test",
"subject-test",
&session.to_string(),
),
);
let expected = if enabled { vec![pin] } else { vec![] };
assert_eq!(
store.session_store.retention().pins(session).unwrap(),
expected
);
// Reopen/replayed confirmation does not depend on a notification or add an owner.
worker
.finalize_subjektiv_session_attribution(
true,
SubjektivSessionAttributionLifecycle::RestoredSession,
)
.unwrap();
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(
store.session_store.retention().pins(session).unwrap(),
expected
);
worker.materialize_durable_session_head().await.unwrap();
let segment = worker.segment_id();
store
.read_by_name(&worker.manifest().worker.name)
.unwrap()
.unwrap()
.subjektiv_session_attribution
.is_none()
);
.append(
session,
segment,
&LogEntry::TurnEnd {
ts: 1,
turn_count: 1,
},
)
.unwrap();
// Simulate stop after the public commit but before RunCommitted/Host admission.
if enabled {
assert!(
matches!(store.session_store.delete_session(session), Err(StoreError::SessionPinned(id)) if id == session)
);
assert!(store.session_store.exists(session, segment).unwrap());
store
.session_store
.release_session_pin(session, pin)
.unwrap();
}
assert!(store.session_store.delete_session(session).unwrap());
}
}
#[tokio::test]
async fn subject_maintenance_job_binding_is_immutable_and_has_no_subject_body_attribution() {
for (subjektiv_consolidation, subjektiv_journal) in [(true, false), (false, true)] {
let client = Arc::new(AttributionLifecycleClient::new([]));
let (mut worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
let binding = crate::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_digest: Some("input-content-digest".into()),
subjektiv_consolidation,
subjektiv_journal,
};
worker.bind_backend_job(binding.clone()).unwrap();
assert_eq!(worker.backend_job(), Some(&binding));
assert!(worker.bind_backend_job(binding).is_err());
for lifecycle in [
SubjektivSessionAttributionLifecycle::NewSession,
SubjektivSessionAttributionLifecycle::RestoredSession,
] {
worker
.finalize_subjektiv_session_attribution(true, lifecycle)
.unwrap();
}
assert!(client.requests.lock().unwrap().is_empty());
assert!(
store
.read_by_name(&worker.manifest().worker.name)
.unwrap()
.unwrap()
.subjektiv_session_attribution
.is_none()
);
}
}
#[tokio::test]
+182
View File
@@ -869,6 +869,7 @@ async fn backend_job_capability_is_explicit_and_preserves_profile_features() {
attempt_id: "attempt-1".into(),
input_digest: Some("input-1".into()),
subjektiv_consolidation: false,
subjektiv_journal: false,
})
.unwrap();
}
@@ -892,6 +893,186 @@ async fn backend_job_capability_is_explicit_and_preserves_profile_features() {
}
}
#[derive(Debug, Default)]
struct JournalWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
result_calls: AtomicUsize,
}
impl WorkspaceClient for JournalWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"journal-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request.clone());
let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") {
(
200,
serde_json::json!({"generation_id":"generation", "state_token":"saved-state", "process_order":1,"process_byte":0}),
)
} else if request.path.ends_with("/subjektiv/journal/job") {
let input: subjektiv::journal::transport::JournalJobRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(
matches!(input, subjektiv::journal::transport::JournalJobRequest::ReadOperationCoverage { request_id, offset: 0, limit: 2 } if request_id == "prior-record")
);
(
200,
serde_json::json!({"result":"coverage","value":{"items":[{
"source":{"session_id":"original-session","pointer":{"segment_id":"00000000-0000-0000-0000-000000000001","entry_ref":"Eoriginal"},"order":0,"content_sha256":"a".repeat(64),"total_bytes":4096},
"bytes":{"start":0,"end":32}
}],"next":"1"}}),
)
} else if request.path.ends_with("/workers/self/backend-job-result") {
if self.result_calls.fetch_add(1, Ordering::SeqCst) == 0 {
(503, serde_json::json!({"message":"acceptance unknown"}))
} else {
(200, serde_json::json!({"accepted":true}))
}
} else {
panic!("unexpected Journal request {}", request.path)
};
Ok(WorkspaceResponse {
status,
body: body.to_string(),
})
}
}
// Real controller/Job Feature assembly, tools, finalizer and Backend adapter.
// The authenticated Workspace client transport and LLM are replaced; server authorization/store
// semantics are tested by the Backend owner, not by this request-contract fixture.
#[tokio::test]
async fn journal_backend_grant_installs_bound_tools_and_seals_saved_finish_before_uncertain_result_retry()
{
for granted in [false, true] {
let workspace = tempfile::tempdir().unwrap();
let manifest = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(
ProfileRegistrySource::Builtin,
"subjektiv-journal-recording",
),
ProfileResolveOptions::with_worker_name("journal-backend-worker"),
)
.unwrap()
.manifest;
let call = |id: &str, tool: &str, args: serde_json::Value| {
MockResponse::Complete(vec![
LlmEvent::tool_use_start(0, id, tool),
LlmEvent::tool_input_delta(0, args.to_string()),
LlmEvent::tool_use_stop(0),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
])
};
let responses = if granted {
vec![
call(
"coverage",
"ReadJournalOperationCoverage",
serde_json::json!({"request_id":"prior-record","offset":0,"limit":2}),
),
call(
"result-unknown",
"SubmitBackendJobResult",
serde_json::json!({"result":{}}),
),
call(
"result-retry",
"SubmitBackendJobResult",
serde_json::json!({"result":{}}),
),
MockResponse::Complete(simple_text_events()),
]
} else {
vec![MockResponse::Complete(simple_text_events())]
};
let client = MockClient::sequential(responses);
let captured = client.clone();
let backend = Arc::new(JournalWorkspaceClient::default());
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
manifest,
WorkerWorkspaceContext::with_client(None, backend.clone()),
)
.await;
worker
.bind_backend_job(worker::BackendJobExecutionBinding {
job_id: "job".into(),
attempt_id: "attempt".into(),
input_digest: Some("digest".into()),
subjektiv_consolidation: false,
subjektiv_journal: granted,
})
.unwrap();
let handle = spawn_controller(worker).await;
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Record only admitted public input.",
))
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
let request = wait_for_captured_request(&captured).await;
let tools = request_tool_names(&request);
assert_eq!(
tools.iter().any(|name| name == "CreateJournalEntry"),
granted,
"Profile alone must not grant Journal operations"
);
assert!(!tools.iter().any(|name| name == "SubjektivMemoryRemember"));
let requests = backend.requests.lock().unwrap();
if granted {
let history = history_from_sink(&handle);
let coverage = history
.iter()
.find_map(|item| match item {
Item::ToolResult {
call_id,
content: Some(content),
..
} if call_id == "coverage" => {
Some(serde_json::from_str::<serde_json::Value>(content).unwrap())
}
_ => None,
})
.expect("coverage must reach model-visible committed history");
assert_eq!(coverage["value"]["items"][0]["bytes"]["end"], 32);
assert_eq!(
coverage["value"]["items"][0]["source"]["pointer"]["entry_ref"],
"Eoriginal"
);
assert_eq!(coverage["value"]["next"], "1");
assert_eq!(requests.len(), 4);
assert!(requests[0].path.ends_with("/subjektiv/journal/job"));
assert!(requests[1].path.ends_with("/subjektiv/journal/job/finish"));
assert_eq!(requests[1].body.as_deref(), Some("{}"));
assert_eq!(
requests[2].body, requests[3].body,
"unknown acceptance retries identical sealed completion without rerunning finish"
);
let result: serde_json::Value =
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
assert_eq!(result["result"]["state_token"], "saved-state");
assert_eq!(result["result"]["generation_id"], "generation");
} else {
assert!(requests.is_empty());
}
}
}
#[tokio::test]
async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy() {
let workspace = tempfile::tempdir().unwrap();
@@ -933,6 +1114,7 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy
attempt_id: "attempt-1".into(),
input_digest: None,
subjektiv_consolidation: true,
subjektiv_journal: false,
})
.unwrap();
let handle = spawn_controller(worker).await;