feat: record confirmed public Session history through durable Journal Jobs
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":[]})));
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())),
|
||||
®istry,
|
||||
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}})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+35
-31
@@ -7778,12 +7778,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(());
|
||||
@@ -11108,7 +11108,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()
|
||||
);
|
||||
@@ -11116,35 +11117,38 @@ 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,
|
||||
] {
|
||||
worker
|
||||
.finalize_subjektiv_session_attribution(true, lifecycle)
|
||||
.unwrap();
|
||||
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()
|
||||
);
|
||||
}
|
||||
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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user