feat: replace revision counters with domain state contracts

Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
2026-10-10 15:55:20 +09:00
parent 72ce817de4
commit c78f7f7d80
358 changed files with 25965 additions and 12069 deletions
+5 -4
View File
@@ -68,13 +68,14 @@ commands and generic POSIX operations are not exposed. The adapter uses the type
an owner config API as a bypass.
Client-managed PATH validators are compared against freshly resolved Host
metadata. Each mutation captures the ROOT validator, whole-tree revision/digest,
metadata. Each mutation captures the ROOT validator, whole-tree content digest,
canonical entrypoints and node state from that same observation. Backend resolves
the live grant/link and validates canonical changes/CAS under its execution lock.
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
post-call path validators are refreshed from the exact committed revision, not
substituted with root validators or later unrelated state. UI/Worker edits and
same-bytes replacement revisions reject stale calls. Old descriptors cannot
post-call path validators are refreshed from the exact committed content, not
substituted with root validators or later unrelated state. Content changes and
reattachment to a new connection lifetime reject stale calls, even when the new
attachment exposes the same bytes. Old descriptors cannot
bypass read-only restrictions, detach, revocation or Workspace boundaries.
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
+3 -3
View File
@@ -10,7 +10,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String,
pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>,
pub input_digest: Option<String>,
#[serde(default)]
pub subjektiv_consolidation: bool,
}
@@ -19,10 +19,10 @@ impl BackendJobExecutionBinding {
pub fn validate(&self) -> Result<(), &'static str> {
if [&self.job_id, &self.attempt_id]
.into_iter()
.chain(self.input_revision.iter())
.chain(self.input_digest.iter())
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
{
return Err("Backend Job binding contains an invalid identity or input revision");
return Err("Backend Job binding contains an invalid identity or input digest");
}
Ok(())
}
+33 -31
View File
@@ -164,6 +164,12 @@ impl WorkerHandle {
))
}
/// Hold through protocol subscription and initial snapshot capture so child
/// events cannot overtake the parent's snapshot publication boundary.
pub fn protocol_snapshot_publish_guard(&self) -> std::sync::MutexGuard<'_, ()> {
self.spawned_registry.protocol_snapshot_publish_guard()
}
pub fn snapshot_event(&self) -> Event {
self.snapshot_event_with_entry_subscription().0
}
@@ -546,7 +552,7 @@ fn notification_coalesce_remaining<St: Store + Clone>(
fn prepare_pending_run<St: Store + Clone>(
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
notify_buffer: &NotifyBuffer,
fence: Option<(u64, &str)>,
fence: Option<&str>,
allow_notification_run: bool,
) -> Result<Option<PendingRun>, crate::worker::PendingSubmissionError> {
Ok(match pending_submissions.prepare_next_activation(fence)? {
@@ -2619,24 +2625,8 @@ async fn controller_loop<C, St>(
pending: pending_submissions.snapshot(),
});
}
Method::CancelPendingSubmission {
submission_id,
expected_revision,
} => match pending_submissions.cancel(&submission_id, expected_revision) {
Ok(pending_snapshot) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_snapshot,
});
}
Err(error) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: error.to_string(),
});
}
},
Method::ClearPendingSubmissions { expected_revision } => {
match pending_submissions.clear(expected_revision) {
Method::CancelPendingSubmission { submission_id } => {
match pending_submissions.cancel(&submission_id) {
Ok(pending_snapshot) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_snapshot,
@@ -2650,10 +2640,22 @@ async fn controller_loop<C, St>(
}
}
}
Method::ContinuePending {
expected_revision,
expected_head_id,
} => {
Method::ClearPendingSubmissions {
expected_submission_ids,
} => match pending_submissions.clear(&expected_submission_ids) {
Ok(pending_snapshot) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_snapshot,
});
}
Err(error) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: error.to_string(),
});
}
},
Method::ContinuePending { expected_head_id } => {
if shared_state.catalog_status() != WorkerStatus::Idle {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
@@ -2664,7 +2666,7 @@ async fn controller_loop<C, St>(
match prepare_pending_run(
&pending_submissions,
&notify_buffer,
Some((expected_revision, &expected_head_id)),
Some(&expected_head_id),
true,
) {
Ok(Some(next)) => pending = Some(next),
@@ -3023,8 +3025,9 @@ async fn controller_loop<C, St>(
},
Method::Shutdown { command } => {
// Shutdown ignores the state-revision fence but remains bound to the
// current execution generation and command payload identity.
// Shutdown applies to this controller at admission time, not
// a caller-observed Run. Exact command-id/kind retries remain
// valid so every shutdown path can reach the cleanup barrier.
if let Err(disposition) = validate_shutdown_command(command, &shared_state) {
acknowledge_command(
&working_event_tx,
@@ -3619,9 +3622,8 @@ where
}
Some(Method::CancelPendingSubmission {
submission_id,
expected_revision,
}) => {
match pending_submissions.cancel(&submission_id, expected_revision) {
match pending_submissions.cancel(&submission_id) {
Ok(pending) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
}
@@ -3633,8 +3635,8 @@ where
}
}
}
Some(Method::ClearPendingSubmissions { expected_revision }) => {
match pending_submissions.clear(expected_revision) {
Some(Method::ClearPendingSubmissions { expected_submission_ids }) => {
match pending_submissions.clear(&expected_submission_ids) {
Ok(pending) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
}
@@ -4009,7 +4011,7 @@ mod tests {
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
"parent": null, "name": "", "kind": "folder", "revision": "1",
"parent": null, "name": "", "kind": "folder", "last_mutation_id": "",
"size": null, "content_type": null, "updated_by": "test",
"updated_at": "2026-10-08T00:00:00Z",
"latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1"
+1 -1
View File
@@ -756,7 +756,7 @@ mod tests {
workspace_id: Some("workspace".into()),
worker_id: "worker".into(),
session_id: "session".into(),
session_revision: 3,
run_id: Some("run".into()),
turn_index: Some(2),
call_id: None,
@@ -2,7 +2,7 @@
//!
//! The tool intentionally accepts only the result value. Workspace and Worker
//! identity come from the authenticated Workspace client, while Job, attempt,
//! and input revision are resolved from the Backend-owned binding.
//! and input digest are resolved from the Backend-owned binding.
use crate::feature::background::BackgroundTaskCancellation;
use std::collections::HashMap;
@@ -22,7 +22,7 @@ use crate::feature::{
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
const TOOL_NAME: &str = "SubmitBackendJobResult";
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input revision, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input digest, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -194,7 +194,7 @@ impl Tool for SubmitResultTool {
let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
if definitive_rejection && !was_previously_sealed {
// Only a definitively unaccepted request can be corrected. Reuse
// a ready snapshot after a new preparation confirms its revision;
// a ready snapshot after a new preparation confirms its Memory fingerprint;
// a conflict invalidates even that snapshot. Failed outcomes are
// rebuilt, since another generation can replace their marker.
submission.reusable_surface = if response.status != 409 {
+17 -16
View File
@@ -188,7 +188,8 @@ impl DriveFeature {
return Err(DriveError::Limit);
}
let read = self.backend.read(&args.entry, args.max_bytes).await?;
if pinned.is_some_and(|entry| entry.revision != read.entry.revision) {
if pinned.is_some_and(|entry| entry.last_mutation_id != read.entry.last_mutation_id)
{
return Err(DriveError::Conflict);
}
self.observe(read.entry.clone())?;
@@ -260,7 +261,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::UpdateText {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
text,
content_type: entry.content_type.unwrap_or_else(default_media),
},
@@ -281,7 +282,7 @@ impl DriveFeature {
.backend
.read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32)
.await?;
if read.entry.revision != entry.revision {
if read.entry.last_mutation_id != entry.last_mutation_id {
return Err(DriveError::Conflict);
}
if read.truncated {
@@ -297,7 +298,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::UpdateText {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
text,
content_type: entry.content_type.unwrap_or_else(default_media),
},
@@ -316,7 +317,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::Relocate {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
parent: args.parent,
name: args.name,
},
@@ -334,7 +335,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::Delete {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
},
})
.await?,
@@ -385,7 +386,7 @@ impl DriveFeature {
parent_id: Some(args.parent.node_id),
name: Some(args.name),
id: None,
expected_revision: None,
expected_mutation_id: None,
content_type: args.content_type,
size: bytes.len() as u32,
sha256: digest(&bytes),
@@ -399,7 +400,7 @@ impl DriveFeature {
}
_ => return Err(DriveError::Invalid("unknown operation".into())),
};
// A successful mutation does not silently substitute its new revision
// A successful mutation does not silently substitute its new observation
// into a prior observation. The caller observes explicitly next time.
if let Some(id) = mutated_id {
self.observations
@@ -640,13 +641,13 @@ pub(super) const SPECS: &[Spec] = &[
Spec {
name: "DriveMetadata",
operation: "metadata",
description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.",
description: "Inspect one Workspace-bound stable entry ID and retain its last committed request for mutation checks.",
schema: schema::<EntryArgs>,
},
Spec {
name: "DriveList",
operation: "list",
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.",
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or establish mutation preconditions.",
schema: schema::<ListArgs>,
},
Spec {
@@ -658,7 +659,7 @@ pub(super) const SPECS: &[Spec] = &[
Spec {
name: "DriveRead",
operation: "read",
description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.",
description: "Read up to 64 KiB UTF-8 text and retain the entry’s last committed request. Past Session text remains an immutable observation.",
schema: schema::<ReadArgs>,
},
Spec {
@@ -676,31 +677,31 @@ pub(super) const SPECS: &[Spec] = &[
Spec {
name: "DriveWrite",
operation: "write",
description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.",
description: "Replace <=64 KiB text using a previously observed committed request. Conflicts are not retried with a newer observation.",
schema: schema::<WriteArgs>,
},
Spec {
name: "DriveEdit",
operation: "edit",
description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
description: "Partial text edit using previously observed committed request and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
schema: schema::<EditArgs>,
},
Spec {
name: "DriveRelocate",
operation: "relocate",
description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.",
description: "Rename and/or move an observed entry only if its last committed request still matches the observation; stable ID and URL remain unchanged.",
schema: schema::<RelocateArgs>,
},
Spec {
name: "DriveDelete",
operation: "delete",
description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.",
description: "Delete one observed entry only if its last committed request still matches the observation. A recreated name has a different ID; stale observations cannot target it.",
schema: schema::<EntryArgs>,
},
Spec {
name: "DriveViewImage",
operation: "view_image",
description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
description: "Attach committed-request-bound PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
schema: schema::<EntryArgs>,
},
Spec {
@@ -22,7 +22,9 @@ pub enum DriveError {
Denied,
#[error("Drive resource not found")]
NotFound,
#[error("Drive revision or name conflict; observe explicitly before another operation")]
#[error(
"Drive content, location, or name conflict; observe explicitly before another operation"
)]
Conflict,
#[error("Invalid Drive arguments: {0}")]
Invalid(String),
@@ -54,7 +56,11 @@ impl DriveBackend {
if let Some(parent) = &entry.parent {
self.validate_ref(parent)?;
}
if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) {
if (entry.last_mutation_id.len() > 128
|| entry.last_mutation_id.chars().any(char::is_control)
|| (entry.parent.is_some() && entry.last_mutation_id.is_empty()))
|| entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES)
{
return Err(DriveError::Unavailable);
}
Ok(())
@@ -238,14 +244,14 @@ impl DriveBackend {
return Err(DriveError::Limit);
}
let mut bytes = Vec::with_capacity(total as usize);
// Even an empty file gets a revision-fixed read and current authorization.
// Even an empty file gets a read bound to the observed committed mutation and current authorization.
loop {
let offset = bytes.len() as u32;
let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES);
let query = query_string(&DriveReadChunkQuery {
entry_workspace_id: entry.entry.workspace_id.clone(),
id: entry.entry.node_id.clone(),
expected_revision: entry.revision.clone(),
expected_mutation_id: entry.last_mutation_id.clone(),
offset,
length,
})?;
@@ -144,9 +144,8 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
fn current_prompt_projection(
&self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
self.inner.current_prompt_projection(minimum_revision)
self.inner.current_prompt_projection()
}
fn list_workspace_workers(
@@ -181,7 +180,7 @@ mod tests {
enum Call {
Json(WorkspaceRequest, Option<Duration>),
Binary(WorkspaceBinaryRequest, Option<Duration>),
Projection(Option<u64>),
Projection,
Discovery(WorkspaceWorkerDiscoveryRequest),
Remove(String, String, String),
Server,
@@ -250,9 +249,8 @@ mod tests {
}
fn current_prompt_projection(
&self,
revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
self.calls.lock().unwrap().push(Call::Projection(revision));
self.calls.lock().unwrap().push(Call::Projection);
Err(WorkspaceClientError::Request("projection marker".into()))
}
fn list_workspace_workers(
@@ -390,7 +388,7 @@ mod tests {
expected_bytes
);
assert!(
matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
matches!(client.current_prompt_projection(), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
);
let discovery = WorkspaceWorkerDiscoveryRequest {
cursor: Some("next".into()),
@@ -422,7 +420,7 @@ mod tests {
Call::Json(request, Some(timeout)),
Call::Binary(bytes.clone(), None),
Call::Binary(bytes, Some(timeout)),
Call::Projection(Some(42)),
Call::Projection,
Call::Discovery(discovery),
Call::Remove("runtime".into(), "worker".into(), "reason".into()),
Call::Server,
@@ -44,7 +44,7 @@ fn response(value: impl Serialize) -> WorkspaceResponse {
body: serde_json::to_string(&value).unwrap(),
}
}
fn entry(id: &str, revision: &str) -> DriveEntry {
fn entry(id: &str, last_mutation_id: &str) -> DriveEntry {
DriveEntry {
entry: DriveEntryRef {
workspace_id: "workspace".into(),
@@ -56,7 +56,7 @@ fn entry(id: &str, revision: &str) -> DriveEntry {
}),
name: "note.md".into(),
kind: DriveEntryKind::File,
revision: revision.into(),
last_mutation_id: last_mutation_id.into(),
size: Some(5),
content_type: Some("text/markdown".into()),
updated_by: "worker".into(),
@@ -83,7 +83,7 @@ async fn execute(
}
#[test]
fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() {
fn drive_feature_activation_never_creates_authority_and_schemas_hide_last_mutation_ids() {
let client = Client::new(Vec::new());
let router = Arc::new(WorkdirSessionRouter::new());
assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none());
@@ -100,7 +100,7 @@ fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions()
let (meta, _) = definition();
assert_eq!(name, meta.name);
let schema = meta.input_schema.to_string();
assert!(!schema.contains("expected_revision"), "{name}");
assert!(!schema.contains("expected_mutation_id"), "{name}");
assert!(!schema.contains("grant_id"), "{name}");
}
assert_eq!(feature.descriptor().instructions.len(), 1);
@@ -139,7 +139,7 @@ async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() {
}
#[tokio::test]
async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() {
async fn stale_edit_returns_conflict_without_upgrading_last_mutation_id_or_dispatching_write() {
let old = entry("2", "1");
let current = entry("2", "2");
let client = Client::new(vec![response(DriveReadTextResponse {
@@ -157,7 +157,10 @@ async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_w
.await;
assert!(matches!(result, Err(DriveError::Conflict)));
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1");
assert_eq!(
feature.observed(&old.entry, None).unwrap().last_mutation_id,
"1"
);
}
#[tokio::test]
@@ -192,8 +195,8 @@ async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_muta
}
#[tokio::test]
async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation()
{
async fn successful_mutation_uses_observed_committed_request_id_and_requires_next_explicit_observation()
{
let old = entry("9007199254740993", "9007199254740993");
let updated = entry("9007199254740993", "9007199254740994");
let client = Client::new(Vec::new());
@@ -220,7 +223,7 @@ async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_ex
.unwrap();
let requests = client.requests.lock().unwrap();
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
assert_eq!(body["mutation"]["expected_revision"], "9007199254740993");
assert_eq!(body["mutation"]["expected_mutation_id"], "9007199254740993");
assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993");
drop(requests);
assert!(feature.observed(&old.entry, None).is_err());
@@ -296,7 +299,7 @@ async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retr
fn invalid_completions(id: &str) -> Vec<DriveMutationResponse> {
let mut invalid_entry = entry("2", "2");
invalid_entry.revision = "not-a-revision".into();
invalid_entry.last_mutation_id = "\ninvalid-request-id".into();
vec![
DriveMutationResponse {
request_id: "another-request".into(),
@@ -340,7 +343,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
.await
.unwrap();
assert_eq!(output.value["request_id"], id);
assert_eq!(output.value["entry"]["metadata"]["revision"], "2");
assert_eq!(output.value["entry"]["metadata"]["last_mutation_id"], "2");
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(
@@ -354,7 +357,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
assert_eq!(body["request_id"], id);
assert_eq!(body["mutation"]["expected_revision"], "1");
assert_eq!(body["mutation"]["expected_mutation_id"], "1");
}
}
@@ -499,7 +502,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
!operations
.iter()
.flat_map(|op| &op.parameters)
.any(|parameter| parameter.name == "expected_revision")
.any(|parameter| parameter.name == "expected_mutation_id")
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
@@ -556,7 +559,7 @@ fn chunk(bytes: Vec<u8>) -> crate::worker::WorkspaceBinaryResponse {
}
}
#[tokio::test]
async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() {
async fn multi_chunk_image_uses_one_last_mutation_id_and_never_exposes_binary_in_text_content() {
let mut metadata = entry("2", "9007199254740993");
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42);
@@ -578,7 +581,11 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
for request in &*requests {
assert!(request.path.contains("expected_revision=9007199254740993"));
assert!(
request
.path
.contains("expected_mutation_id=9007199254740993")
);
assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize);
}
assert!(requests[0].path.split('&').any(|pair| pair == "offset=0"));
@@ -590,7 +597,7 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co
);
}
#[tokio::test]
async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
async fn expired_last_mutation_id_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
for next in [
crate::worker::WorkspaceBinaryResponse {
status: 409,
@@ -618,12 +625,12 @@ async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_ret
assert!(
requests
.iter()
.all(|request| request.path.contains("expected_revision=3&"))
.all(|request| request.path.contains("expected_mutation_id=3&"))
);
assert_eq!(
client.json.requests.lock().unwrap().len(),
1,
"must not fetch a new revision"
"must not fetch a new last_mutation_id"
);
}
}
@@ -642,7 +649,7 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
execute(
&feature,
"DriveRead",
json!({"entry":metadata.entry,"expected_revision":"1"})
json!({"entry":metadata.entry,"expected_mutation_id":"1"})
)
.await
.is_err()
@@ -1,5 +1,5 @@
//! Native non-indexable Drive entries. Publication resolves just one ID through
//! current Backend authorization; the Client owns identity/revision validators.
//! current Backend authorization; the Client owns entry/committed-request validators.
use super::{
DriveFeature, SPECS, Spec,
backend::{DriveError, decimal},
@@ -101,7 +101,7 @@ impl WipSubtreeProvider for DriveProvider {
};
let descriptor = descriptor(&specs, root);
let reference = contextual_reference(INTERFACE, path);
let validator = entry.revision.as_bytes().to_vec();
let validator = entry.last_mutation_id.as_bytes().to_vec();
let object = Object {
name: if root {
"drive".into()
@@ -183,7 +183,7 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
}
}
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
@@ -230,7 +230,7 @@ impl WipOperationHandler for DriveHandler {
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
{
return Err(map_error(DriveError::Invalid(
"unrecognized arguments; revision metadata is Client-managed".into(),
"unrecognized arguments; committed-request observations are Client-managed".into(),
)));
}
let mut input = wip_to_json(&Value::Record(arguments.clone()))
@@ -296,12 +296,14 @@ impl WipOperationHandler for DriveHandler {
// Attachments use the existing ToolOutput side channel and durable Engine
// capture, not transient request injection or URL-only success.
let result = if matches!(operation, "write" | "edit" | "relocate") {
let revision = output.value["entry"]["metadata"]["revision"]
let last_mutation_id = output.value["entry"]["metadata"]["last_mutation_id"]
.as_str()
.ok_or_else(|| {
WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into())
WipOperationError::OutcomeUnknown(
"Committed Drive request identity unavailable".into(),
)
})?;
WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec())
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
} else {
WipOperationOutput::native(value)
};
@@ -557,7 +557,7 @@ where
input,
cache_key: Some(format!(
"flow:{}:{}",
attempt.instance_id, attempt.checked_state_revision
attempt.instance_id, attempt.attempt_id
)),
max_turns: Some(12),
engine_configurator: None,
@@ -653,9 +653,7 @@ mod tests {
FlowTransitionAttempt {
attempt_id: id.to_string(),
instance_id: "instance-1".to_string(),
definition_revision: 1,
definition_digest: "sha256:test".to_string(),
checked_state_revision: 0,
from_state: StateId::new("work").unwrap(),
reason: "ready".to_string(),
transitions: vec![
@@ -711,7 +709,6 @@ mod tests {
selector: "workspace:test-flow".parse().unwrap(),
workspace_id: "workspace-1".to_string(),
flow_id: "flow-1".to_string(),
revision: 1,
content_digest: definition.content_digest.clone(),
definition,
},
@@ -419,7 +419,7 @@ impl WipOperationHandler for CollectionHandler {
let mut digest = Sha256::new();
digest.update(self.domain.route());
if matches!(self.domain, Domain::Workdir | Domain::Attachment) {
// The complete-set revision is computed by the existing Backend
// The complete-set digest is computed by the existing Backend
// registry/ledger in the same snapshot as a bounded page. Never infer
// collection freshness from just the first page's items.
let path = if matches!(self.domain, Domain::Workdir) {
@@ -431,13 +431,13 @@ impl WipOperationHandler for CollectionHandler {
.provider
.request(path)
.ok()
.and_then(|p| p.get("revision").and_then(Json::as_str).map(str::to_owned))
.and_then(|p| p.get("digest").and_then(Json::as_str).map(str::to_owned))
{
Some(revision) => {
Some(inventory_digest) => {
digest.update([0]);
digest.update(revision);
digest.update(inventory_digest);
}
None => digest.update(b"inventory-revision-unavailable"),
None => digest.update(b"inventory-digest-unavailable"),
}
return Some(digest.finalize().to_vec());
}
@@ -533,9 +533,9 @@ impl WipOperationHandler for CollectionHandler {
"invalid bounded inventory page".into(),
)
})?;
if page.get("revision").and_then(Json::as_str).is_none() {
if page.get("digest").and_then(Json::as_str).is_none() {
return Err(WipOperationError::OutcomeUnknown(
"inventory page revision missing".into(),
"inventory page digest missing".into(),
));
}
let mut items = Vec::new();
@@ -1162,7 +1162,7 @@ pub(crate) mod tests {
let cursor = query.get("cursor").copied();
let mut all: Vec<_> = state.workdirs.iter().collect();
all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap());
let revision = Sha256::digest(serde_json::to_vec(&all).unwrap())
let digest = Sha256::digest(serde_json::to_vec(&all).unwrap())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
@@ -1179,7 +1179,7 @@ pub(crate) mod tests {
} else {
None
};
json!({"items":items, "next_cursor":next, "revision":revision})
json!({"items":items, "next_cursor":next, "digest":digest})
}
(WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => {
let query: BTreeMap<_, _> = path
@@ -1191,12 +1191,12 @@ pub(crate) mod tests {
.collect();
let mut all: Vec<_> = state.attachments.iter().collect();
all.sort_by_key(|v| v["alias"].as_str().unwrap());
let revision = Sha256::digest(serde_json::to_vec(&all).unwrap())
let digest = Sha256::digest(serde_json::to_vec(&all).unwrap())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
if let Some(expected) = query.get("connection_id") {
json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null, "revision":revision})
json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null, "digest":digest})
} else {
let limit = query
.get("limit")
@@ -1209,7 +1209,7 @@ pub(crate) mod tests {
let count = all.len();
let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect();
let next = (offset + limit < count).then_some(offset + limit);
json!({"items":items, "next_offset":next, "revision":revision})
json!({"items":items, "next_offset":next, "digest":digest})
}
}
(WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => {
+100 -58
View File
@@ -5,7 +5,6 @@
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(test)]
use std::time::Duration;
@@ -335,21 +334,21 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
[
(
"SubjektivMemoryQuery",
"Search current Memory revisions for the delegated subject.",
"Search current Memory changes for the delegated subject.",
schema_for::<server_api::SubjektivMemoryQueryRequest>(),
Operation::Query,
),
(
"SubjektivMemoryRead",
"Read one current or historical Memory revision for the delegated subject.",
"Read one current or historical Memory change for the delegated subject.",
schema_for::<server_api::SubjektivMemoryReadRequest>(),
Operation::Read,
),
(
"SubjektivMemoryListRevisions",
"List immutable revisions of one Memory for the delegated subject.",
schema_for::<server_api::SubjektivMemoryListRevisionsRequest>(),
Operation::ListRevisions,
"SubjektivMemoryListChanges",
"List immutable changes of one Memory for the delegated subject.",
schema_for::<server_api::SubjektivMemoryListChangesRequest>(),
Operation::ListChanges,
),
(
"MemoryStagingList",
@@ -359,13 +358,13 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
),
(
"MemoryStagingRead",
"Read one pending candidate, including typed revision proposal metadata and provenance.",
"Read one pending candidate, including typed change proposal metadata and provenance.",
schema_for::<server_api::SubjektivMemoryCandidateReadRequest>(),
Operation::ReadCandidate,
),
(
"MemoryApplyCandidate",
"Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact revision proposal metadata and returns concrete affected revision refs.",
"Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact change proposal metadata and returns concrete affected change refs.",
schema_for::<server_api::SubjektivMemoryCandidateDecisionRequest>(),
Operation::DecideCandidate,
),
@@ -392,7 +391,7 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
enum SubjectConsolidationOperation {
Query,
Read,
ListRevisions,
ListChanges,
ListCandidates,
ReadCandidate,
DecideCandidate,
@@ -418,8 +417,8 @@ impl Tool for SubjectConsolidationTool {
Operation::Read => {
server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?)
}
Operation::ListRevisions => {
server_api::SubjektivMemoryBackendOperation::ListRevisions(parse_input(input_json)?)
Operation::ListChanges => {
server_api::SubjektivMemoryBackendOperation::ListChanges(parse_input(input_json)?)
}
Operation::ListCandidates => {
server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input(
@@ -452,12 +451,12 @@ impl Tool for SubjectConsolidationTool {
}
server_api::SubjektivMemoryBackendResponse::Read(value) => {
format!(
"Read Memory {} revision {}.",
value.memory_id, value.revision
"Read Memory {} change {}.",
value.memory_id, value.change_id
)
}
server_api::SubjektivMemoryBackendResponse::ListRevisions(value) => {
format!("Listed {} revision(s).", value.items.len())
server_api::SubjektivMemoryBackendResponse::ListChanges(value) => {
format!("Listed {} change(s).", value.items.len())
}
server_api::SubjektivMemoryBackendResponse::Candidates(value) => {
format!("Listed {} pending candidate(s).", value.items.len())
@@ -640,8 +639,7 @@ struct WorkspaceResidentSummarySource {
#[derive(Default)]
struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
applied: Option<String>,
}
struct WorkspaceSubjektivResidentSummarySource {
@@ -727,8 +725,7 @@ impl WorkspaceSubjektivResidentSummarySource {
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
state.pending.remove(&output.behavior_revision);
state.applied = Some(output.behavior_md.clone());
}
}
@@ -769,33 +766,22 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
.expect("subject behavior state poisoned")
.applied
.as_ref()
.is_none_or(|(revision, body)| {
*revision != output.behavior_revision || body != &output.behavior_md
});
.is_none_or(|body| body != &output.behavior_md);
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let revision = output.behavior_revision;
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.pending
.insert(revision, output.behavior_md);
Ok(Some(ResidentContextRefresh {
body: rendered,
revision,
behavior_md: output.behavior_md,
}))
}
fn confirm_resident_context_revision(&self, revision: u64) {
let mut state = self
.behavior_state
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned");
if let Some(body) = state.pending.remove(&revision) {
state.applied = Some((revision, body));
}
.expect("subject behavior state poisoned")
.applied = Some(behavior_md.to_owned());
}
fn invalidate_resident_context_representation(&self) {
@@ -942,7 +928,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md);
}
Ok(HookPreRequestAction::Continue)
}
@@ -971,7 +957,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
body,
session_store::PromptRenderProvenance {
workspace_id: self.workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(),
@@ -1270,7 +1256,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
behavior: Mutex<String>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
}
@@ -1284,7 +1270,7 @@ mod tests {
availability,
content: content.map(str::to_string),
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
behavior: Mutex::new("Be deliberate.".to_string()),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
@@ -1295,7 +1281,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
behavior: Mutex::new("must not be injected".to_string()),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
@@ -1333,14 +1319,13 @@ mod tests {
body: "subject scope unavailable".into(),
});
}
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
let behavior_md = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
@@ -1483,7 +1468,7 @@ mod tests {
enabled
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1543,7 +1528,7 @@ mod tests {
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -1586,7 +1571,7 @@ mod tests {
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1643,7 +1628,7 @@ permission = "write"
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1712,7 +1697,7 @@ permission = "write"
}
#[tokio::test]
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
async fn subject_resident_source_emits_only_changed_behavior() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1732,15 +1717,15 @@ permission = "write"
.is_none()
);
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
*client.behavior.lock().unwrap() = "Ask when uncertain.".to_string();
let refresh = source
.load_changed_resident_context()
.await
.unwrap()
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.revision, 4);
assert_eq!(refresh.behavior_md, "Ask when uncertain.");
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision);
source.confirm_resident_context_behavior(&refresh.behavior_md);
assert!(
source
.load_changed_resident_context()
@@ -1749,15 +1734,15 @@ permission = "write"
.is_none()
);
*client.behavior.lock().unwrap() = (5, String::new());
*client.behavior.lock().unwrap() = String::new();
let cleared = source
.load_changed_resident_context()
.await
.unwrap()
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.revision, 5);
assert_eq!(cleared.behavior_md, "");
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_revision(cleared.revision);
source.confirm_resident_context_behavior(&cleared.behavior_md);
assert!(
source
.load_changed_resident_context()
@@ -1772,7 +1757,7 @@ permission = "write"
.await
.unwrap()
.expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5);
assert_eq!(replay_after_rewrite.behavior_md, "");
assert!(
replay_after_rewrite
.body
@@ -1786,6 +1771,63 @@ permission = "write"
);
}
#[tokio::test]
async fn resident_behavior_ack_tracks_exact_committed_content_not_latest_fetch() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
source.load().await;
*client.behavior.lock().unwrap() = "first behavior".into();
let first = source
.load_changed_resident_context()
.await
.unwrap()
.unwrap();
let retry = source
.load_changed_resident_context()
.await
.unwrap()
.unwrap();
assert_eq!(retry, first); // Fetch alone never acknowledges model representation.
*client.behavior.lock().unwrap() = "second behavior".into();
let second = source
.load_changed_resident_context()
.await
.unwrap()
.unwrap();
source.confirm_resident_context_behavior(&first.behavior_md);
assert_eq!(
source
.load_changed_resident_context()
.await
.unwrap()
.unwrap(),
second
);
source.confirm_resident_context_behavior(&second.behavior_md);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
source.invalidate_resident_context_representation();
assert_eq!(
source
.load_changed_resident_context()
.await
.unwrap()
.unwrap(),
second
);
}
#[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
@@ -2001,14 +2043,14 @@ permission = "write"
"MemoryApplyCandidate",
"MemoryStagingList",
"MemoryStagingRead",
"SubjektivMemoryListRevisions",
"SubjektivMemoryListChanges",
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
]
);
let decision = tool_meta(tools, "MemoryApplyCandidate").to_string();
assert!(decision.contains("request_id"));
assert!(decision.contains("expected_revision"));
assert!(decision.contains("expected_change_id"));
assert!(decision.contains("already_covered"));
assert!(!decision.contains("subject_id"));
assert!(!decision.contains("workspace_id"));
@@ -972,7 +972,7 @@ impl WorkerAuditBase {
) -> Self {
self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit {
workspace_id: snapshot.workspace_id.clone(),
settings_revision: snapshot.settings_revision,
language: snapshot.language.clone(),
});
self
@@ -1292,7 +1292,7 @@ permission = "write"
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1365,7 +1365,7 @@ permission = "write"
.start_run_committed(HookInvocationContext {
worker_id: "worker-1".to_string(),
session_id: "session-1".to_string(),
session_revision: 1,
run_id: Some("run-1".to_string()),
..Default::default()
})
@@ -1456,7 +1456,7 @@ permission = "write"
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -2002,7 +2002,7 @@ permission = "write"
CommittedSessionCapture {
session_id: "session-1".to_string(),
segment_id: "segment-1".to_string(),
session_revision: history_len.try_into().unwrap(),
entry_count: history_len,
run_exit: CommittedRunExit::Finished,
history: (0..history_len)
@@ -386,7 +386,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
flow_definition_fingerprint: None,
}
}
@@ -161,18 +161,23 @@ impl SubjektivSurfaceLifecycleFeature {
impl FeatureModule for SubjektivSurfaceLifecycleFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle")
.with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.")
.with_background_task(BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
))
FeatureDescriptor::builtin(
"subjektiv-memory-surface-lifecycle",
"subjektiv Memory Surface Lifecycle",
)
.with_description(
"Rebuilds one bounded subject Memory surface after each committed consolidation run.",
)
.with_background_task(BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed changes.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let declaration = BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
"Generate and publish a current subject Memory surface from bounded confirmed changes.",
);
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
spec.trigger = BackgroundTaskTrigger::RunCommitted;
@@ -240,16 +245,17 @@ impl SubjektivSurfaceLifecycleTask {
if let Some(snapshot_id) = &generation.current_snapshot_id {
return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id,
"store_revision": generation.store_revision, "snapshot_id": snapshot_id,
"memory_fingerprint": generation.memory_fingerprint, "snapshot_id": snapshot_id,
}));
}
}
// Preparation is Backend-fenced to the live attempt and immutable batch.
// Only a ready snapshot is immutable within its store revision. Never
// Only a ready snapshot is immutable within its Memory fingerprint. Never
// reuse a failed marker, which a later generation can replace.
if let Some(surface) = reusable_surface.filter(|surface| {
surface["availability"] == "ready"
&& surface["store_revision"].as_u64() == Some(generation.store_revision)
&& surface["memory_fingerprint"].as_str()
== Some(generation.memory_fingerprint.as_str())
}) {
return Ok(surface.clone());
}
@@ -307,7 +313,7 @@ impl SubjektivSurfaceLifecycleTask {
Ok(output) => {
return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id,
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
"memory_fingerprint": output.built_from_memory_fingerprint, "snapshot_id": output.snapshot_id,
}));
}
Err(SubjektivHostError::Conflict { .. })
@@ -418,23 +424,24 @@ impl SubjektivSurfaceLifecycleTask {
.await
.map_err(surface_hook_error)?;
// Success must attest the actual persisted marker, not merely receipt of
// a failure notification. A protected ready surface or moved revision
// a failure notification. A protected ready surface or moved fingerprint
// must yield a Backend rejection/non-failed status, never a failed claim.
let confirmed_status = if job_confirmation_required {
"failed_confirmed"
} else {
"failed"
};
if failure.status != confirmed_status || failure.store_revision != generation.store_revision
if failure.status != confirmed_status
|| failure.memory_fingerprint != generation.memory_fingerprint
{
return Err(HookError::new(
HookErrorCategory::Internal,
"Backend did not confirm a failed surface for this generation's revision; no Job result was submitted",
"Backend did not confirm a failed surface for this generation's fingerprint; no Job result was submitted",
));
}
Ok(serde_json::json!({
"availability": "failed", "generation_id": generation.generation_id,
"store_revision": failure.store_revision, "reason_code": reason_code,
"memory_fingerprint": failure.memory_fingerprint, "reason_code": reason_code,
}))
}
}
@@ -604,7 +611,7 @@ mod tests {
) -> server_api::SubjektivSurfaceMaterial {
server_api::SubjektivSurfaceMaterial {
memory_id: id.into(),
revision: 1,
change_id: "change-1".into(),
kind,
body_md,
why_useful: "budget fixture".into(),
@@ -619,7 +626,7 @@ mod tests {
server_api::SubjektivSurfacePrepareResponse {
generation_id: "generation-1".into(),
current_snapshot_id: None,
store_revision: 1,
memory_fingerprint: "memory-set-1".into(),
active_memory_count: materials.len(),
materials,
body_token_budget: 1_024,
@@ -646,9 +653,9 @@ mod tests {
job_results: Mutex<Vec<serde_json::Value>>,
result_responses:
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
current_revision: AtomicUsize,
current_fingerprint: Mutex<Option<String>>,
failure_status: Mutex<String>,
failure_revision: AtomicUsize,
failure_fingerprint: Mutex<Option<String>>,
ready_snapshot: Mutex<Option<String>>,
}
@@ -662,9 +669,9 @@ mod tests {
failure_reasons: Mutex::new(Vec::new()),
job_results: Mutex::new(Vec::new()),
result_responses: Mutex::new(Default::default()),
current_revision: AtomicUsize::new(0),
current_fingerprint: Mutex::new(None),
failure_status: Mutex::new("failed".into()),
failure_revision: AtomicUsize::new(0),
failure_fingerprint: Mutex::new(None),
ready_snapshot: Mutex::new(None),
}
}
@@ -676,13 +683,12 @@ mod tests {
}
}
fn revision(&self, fallback: usize) -> u64 {
let current = self.current_revision.load(Ordering::SeqCst);
if current == 0 {
fallback as u64
} else {
current as u64
}
fn fingerprint(&self, fallback: usize) -> String {
self.current_fingerprint
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| format!("memory-set-{fallback}"))
}
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
@@ -741,7 +747,7 @@ mod tests {
let mut prepared =
generation(self.input_token_budget, vec![material("memory-1", 32)]);
prepared.generation_id = format!("generation-{call}");
prepared.store_revision = self.revision(call);
prepared.memory_fingerprint = self.fingerprint(call);
prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
@@ -760,7 +766,7 @@ mod tests {
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
server_api::SubjektivSurfacePublishResponse {
snapshot_id: format!("surface-{call}"),
built_from_store_revision: self.revision(call),
built_from_memory_fingerprint: self.fingerprint(call),
empty: false,
},
),
@@ -771,12 +777,14 @@ mod tests {
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
server_api::SubjektivSurfaceFailureResponse {
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0
{
self.failure_revision.load(Ordering::SeqCst) as u64
} else {
self.revision(self.prepare_calls.load(Ordering::SeqCst))
},
memory_fingerprint: self
.failure_fingerprint
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| {
self.fingerprint(self.prepare_calls.load(Ordering::SeqCst))
}),
status: self.failure_status.lock().unwrap().clone(),
},
),
@@ -814,7 +822,7 @@ mod tests {
serde_json::json!({
"points": [{
"body_md": "- Keep the confirmed constraint.",
"memory_refs": [{"memory_id": "memory-1", "revision": 1}]
"memory_refs": [{"memory_id": "memory-1", "change_id": "change-1"}]
}]
})
.to_string(),
@@ -863,7 +871,7 @@ permission = "write"
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -899,7 +907,7 @@ permission = "write"
.start_run_committed(HookInvocationContext {
worker_id: "worker-1".into(),
session_id: "session-1".into(),
session_revision: 1,
run_id: Some("run-1".into()),
..Default::default()
})
@@ -1146,7 +1154,10 @@ permission = "write"
results[0]["result"]["surface"]["generation_id"],
"generation-1"
);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
assert_eq!(
results[0]["result"]["surface"]["memory_fingerprint"],
"memory-set-1"
);
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert!(
tool.execute(
@@ -1289,7 +1300,7 @@ permission = "write"
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
for status in [400, 403, 422] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace.current_revision.store(1, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
workspace
.result_responses
.lock()
@@ -1317,7 +1328,7 @@ permission = "write"
assert_eq!(
workspace.prepare_calls.load(Ordering::SeqCst),
2,
"reuse must verify current revision"
"reuse must verify current Memory fingerprint"
);
assert_eq!(
workspace.publish_calls.load(Ordering::SeqCst),
@@ -1338,7 +1349,7 @@ permission = "write"
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
for status in [400, 409] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace.current_revision.store(1, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
workspace
.result_responses
.lock()
@@ -1350,14 +1361,20 @@ permission = "write"
.await
.is_err()
);
workspace.current_revision.store(2, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-2".into());
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2);
assert_eq!(
results[0]["result"]["surface"]["memory_fingerprint"],
"memory-set-1"
);
assert_eq!(
results[1]["result"]["surface"]["memory_fingerprint"],
"memory-set-2"
);
assert_ne!(
results[0]["result"]["surface"]["generation_id"],
results[1]["result"]["surface"]["generation_id"]
@@ -1415,18 +1432,18 @@ permission = "write"
}
#[tokio::test]
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() {
for (status, revision) in [
("failed", 0),
("ready", 0),
("stale", 0),
("failed_confirmed", 2),
async fn job_result_never_submits_unconfirmed_failure_or_wrong_fingerprint() {
for (status, fingerprint) in [
("failed", "memory-set-1"),
("ready", "memory-set-1"),
("stale", "memory-set-1"),
("failed_confirmed", "memory-set-2"),
] {
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
workspace.current_revision.store(1, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
let tool = job_result_tool(workspace.clone());
*workspace.failure_status.lock().unwrap() = status.into();
workspace.failure_revision.store(revision, Ordering::SeqCst);
*workspace.failure_fingerprint.lock().unwrap() = Some(fingerprint.into());
assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
@@ -1435,7 +1452,7 @@ permission = "write"
assert!(workspace.job_results.lock().unwrap().is_empty());
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
workspace.failure_revision.store(0, Ordering::SeqCst);
*workspace.failure_fingerprint.lock().unwrap() = None;
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
@@ -64,7 +64,7 @@ impl MemorySurfaceOutputState {
let allowed = self
.materials
.iter()
.map(|material| (material.memory_id.as_str(), material.revision))
.map(|material| (material.memory_id.as_str(), material.change_id.as_str()))
.collect::<HashSet<_>>();
let mut bodies = Vec::with_capacity(points.len());
for point in points {
@@ -81,16 +81,20 @@ impl MemorySurfaceOutputState {
}
let mut point_refs = HashSet::new();
for reference in &point.memory_refs {
if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) {
if !point_refs.insert((reference.memory_id.as_str(), reference.change_id.as_str()))
{
return Err(ToolError::InvalidArgument(format!(
"duplicate surface point ref {}@{}",
reference.memory_id, reference.revision
reference.memory_id,
reference.change_id.as_str()
)));
}
if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) {
if !allowed.contains(&(reference.memory_id.as_str(), reference.change_id.as_str()))
{
return Err(ToolError::InvalidArgument(format!(
"surface ref {}@{} is not in the generation materials",
reference.memory_id, reference.revision
reference.memory_id,
reference.change_id.as_str()
)));
}
}
@@ -218,7 +222,7 @@ mod tests {
fn material(id: &str) -> server_api::SubjektivSurfaceMaterial {
server_api::SubjektivSurfaceMaterial {
memory_id: id.into(),
revision: 1,
change_id: "change-1".into(),
kind: CandidateKind::Constraint,
body_md: "Keep the boundary.".into(),
why_useful: "Avoid regression.".into(),
@@ -229,9 +233,9 @@ mod tests {
fn point(id: &str) -> server_api::SubjektivSurfacePoint {
server_api::SubjektivSurfacePoint {
body_md: "- Keep the boundary unless it changes.".into(),
memory_refs: vec![server_api::SubjektivMemoryRevisionRef {
memory_refs: vec![server_api::SubjektivMemoryChangeRef {
memory_id: id.into(),
revision: 1,
change_id: "change-1".into(),
}],
}
}
@@ -509,12 +509,11 @@ fn native_merge_request_tools(
}
#[derive(Default)]
struct MergeRequestRevisionState {
revisions: HashMap<String, String>,
mutation_sequence: u64,
struct MergeRequestObservationState {
content_digests: HashMap<String, String>,
}
type MergeRequestRevisions = Arc<Mutex<MergeRequestRevisionState>>;
type MergeRequestObservations = Arc<Mutex<MergeRequestObservationState>>;
/// Mount only the Merge Request operations enabled for this Worker as a native
/// collection plus route-bound item objects. The ordinary Tool definitions remain
@@ -542,7 +541,7 @@ pub fn mount_workspace_http_merge_request_wip(
.filter(|tool| tool.projection.surface == NativeMergeRequestSurface::Item)
.cloned()
.collect::<Vec<_>>();
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default()));
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let collection_descriptor = merge_request_descriptor(
&collection_tools,
@@ -574,7 +573,7 @@ pub fn mount_workspace_http_merge_request_wip(
tools: merge_request_operation_map(collection_tools),
permissions: permissions.clone(),
collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
}),
})?;
@@ -593,7 +592,7 @@ pub fn mount_workspace_http_merge_request_wip(
tools: merge_request_operation_map(item_tools),
permissions,
collection_route: collection_route.clone(),
revisions,
observations,
}),
})?;
registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
@@ -613,7 +612,7 @@ struct MergeRequestCollectionWipHandler {
tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: MergeRequestRevisions,
observations: MergeRequestObservations,
}
#[async_trait]
@@ -643,7 +642,7 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
)
})?
.to_string();
record_merge_request_observation(&self.revisions, &reference, &response, true);
record_merge_request_observation(&self.observations, &reference, &response, true);
if let Some(object) = response.as_object_mut() {
object.insert(
"path".into(),
@@ -663,7 +662,7 @@ struct MergeRequestItemResolver {
tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: MergeRequestRevisions,
observations: MergeRequestObservations,
}
impl WipDynamicItemResolver for MergeRequestItemResolver {
@@ -671,14 +670,13 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
if !is_merge_request_route_reference(item_reference) {
return None;
}
let revision = self
.revisions
let content_digest = self
.observations
.lock()
.unwrap_or_else(|error| error.into_inner())
.revisions
.content_digests
.get(item_reference)
.cloned()
.unwrap_or_else(|| "unobserved".into());
.cloned();
let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem {
object: Object {
@@ -686,13 +684,15 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
description: Some("Authoritative Merge Request bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
r#ref: Some(format!("merge-request:{item_reference}")),
validator: Some(merge_request_route_validator(&route, &revision)),
validator: content_digest
.as_deref()
.map(|digest| merge_request_route_validator(&route, digest)),
},
handler: Arc::new(MergeRequestItemWipHandler {
tools: self.tools.clone(),
permissions: self.permissions.clone(),
merge_request_id: item_reference.into(),
revisions: Arc::clone(&self.revisions),
observations: Arc::clone(&self.observations),
}),
})
}
@@ -702,7 +702,7 @@ struct MergeRequestItemWipHandler {
tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>,
merge_request_id: String,
revisions: MergeRequestRevisions,
observations: MergeRequestObservations,
}
#[async_trait]
@@ -737,7 +737,7 @@ impl WipOperationHandler for MergeRequestItemWipHandler {
tool.kind.mutating(),
)?;
record_merge_request_observation(
&self.revisions,
&self.observations,
&self.merge_request_id,
&response,
tool.kind.mutating(),
@@ -911,23 +911,22 @@ fn merge_request_reference(response: &Value) -> Option<&str> {
}
fn record_merge_request_observation(
revisions: &MergeRequestRevisions,
observations: &MergeRequestObservations,
reference: &str,
response: &Value,
mutation: bool,
) {
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
let revision = if mutation {
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
format!(
"{}#mutation-{}",
merge_request_response_fingerprint(response),
state.mutation_sequence
)
let mut state = observations
.lock()
.unwrap_or_else(|error| error.into_inner());
if mutation {
state.content_digests.remove(reference);
} else {
merge_request_response_fingerprint(response)
};
state.revisions.insert(reference.to_string(), revision);
state.content_digests.insert(
reference.to_string(),
merge_request_response_fingerprint(response),
);
}
}
fn merge_request_response_fingerprint(response: &Value) -> String {
@@ -947,11 +946,11 @@ fn is_merge_request_route_reference(reference: &str) -> bool {
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
}
fn merge_request_route_validator(route: &str, revision: &str) -> Vec<u8> {
fn merge_request_route_validator(route: &str, content_digest: &str) -> Vec<u8> {
let mut digest = Sha256::new();
digest.update(route.as_bytes());
digest.update([0]);
digest.update(revision.as_bytes());
digest.update(content_digest.as_bytes());
digest.finalize().to_vec()
}
@@ -1428,7 +1427,7 @@ mod tests {
tools: HashMap::new(),
permissions: None,
collection_route: "/merge-requests".into(),
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
observations: Arc::new(Mutex::new(MergeRequestObservationState::default())),
};
assert!(
resolver
@@ -1574,16 +1573,16 @@ mod tests {
#[test]
fn successful_observations_and_mutations_stale_only_affected_merge_requests() {
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default()));
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/merge-requests".into(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
};
let first = resolver.resolve("MR-1").unwrap().object.validator;
record_merge_request_observation(
&revisions,
&observations,
"MR-1",
&json!({"merge_request_id": "MR-1", "updated_at": "rev-1"}),
false,
@@ -1592,7 +1591,7 @@ mod tests {
assert_ne!(first, observed);
let other = resolver.resolve("MR-2").unwrap().object.validator;
record_merge_request_observation(
&revisions,
&observations,
"MR-1",
&json!({"event_id": "review-1"}),
true,
@@ -1601,6 +1600,35 @@ mod tests {
assert_eq!(other, resolver.resolve("MR-2").unwrap().object.validator);
}
#[test]
fn merge_request_observation_digest_survives_identical_reads_without_mutation_counters() {
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/merge-requests".into(),
observations: Arc::clone(&observations),
};
let response = json!({"merge_request_id": "MR-1", "state": "open", "thread": []});
assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none());
record_merge_request_observation(&observations, "MR-1", &response, false);
let observed = resolver.resolve("MR-1").unwrap().object.validator;
assert!(observed.is_some());
record_merge_request_observation(&observations, "MR-1", &response, false);
assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed);
for _ in 0..2 {
record_merge_request_observation(
&observations,
"MR-1",
&json!({"event_id": "same-review"}),
true,
);
assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none());
}
record_merge_request_observation(&observations, "MR-1", &response, false);
assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed);
}
#[test]
fn native_errors_preserve_backend_rejections_and_unknown_post_dispatch_outcomes() {
for (status, code) in [
@@ -1681,7 +1709,7 @@ mod tests {
}
#[test]
fn schemas_hide_revision_and_commit_authority() {
fn schemas_hide_content_digest_and_commit_authority() {
let schemas = [
schemars::schema_for!(OpenMergeRequestInput),
schemars::schema_for!(CompleteMergeRequestInput),
@@ -1689,7 +1717,7 @@ mod tests {
for s in schemas {
let j = serde_json::to_string(&s).unwrap();
for banned in [
"revision_id",
"content_digest_id",
"attempt_id",
"base_commit",
"head_commit",
+28 -100
View File
@@ -5,8 +5,10 @@
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
//! the scoped Workspace API.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
use std::collections::BTreeMap;
use std::sync::Arc;
#[cfg(test)]
use std::sync::Mutex;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
@@ -536,14 +538,6 @@ const OBJECTIVE_TOOL_NAMES: [&str; 7] = [
"ObjectiveUnlinkTicket",
];
#[derive(Default)]
struct ObjectiveRevisionState {
revisions: HashMap<String, String>,
aliases_by_canonical: HashMap<String, BTreeSet<String>>,
}
type ObjectiveRevisions = Arc<Mutex<ObjectiveRevisionState>>;
/// Contribute the Objective Feature's native collection and item projections to
/// the Host-owned WIP registry. Ordinary Objective tools remain registered for
/// Tool mode and are claimed only from the WIP compatibility projection.
@@ -555,14 +549,13 @@ pub fn mount_workspace_http_objective_wip(
) -> Result<(), WipMountError> {
let collection_route = namespace_route.root().to_string();
let backend = WorkspaceHttpObjectiveBackend::new(client);
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection_descriptor = objective_collection_descriptor();
let collection_validator = descriptor_validator(&collection_descriptor);
let collection_handler = Arc::new(ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: permissions.clone(),
collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
});
registry.mount(WipProjection {
route: collection_route.clone(),
@@ -600,7 +593,6 @@ pub fn mount_workspace_http_objective_wip(
backend,
permissions,
collection_route: collection_route.clone(),
revisions,
}),
})?;
registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?;
@@ -611,7 +603,6 @@ struct ObjectiveCollectionWipHandler {
backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: ObjectiveRevisions,
}
#[async_trait]
@@ -695,7 +686,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler {
.create_value(&input)
.await
.map_err(map_backend_error)?;
record_revision(&self.revisions, None, &response);
if let Some(reference) = response
.get("objective")
.and_then(Json::as_str)
@@ -723,7 +714,6 @@ struct ObjectiveItemResolver {
backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: ObjectiveRevisions,
}
impl WipDynamicItemResolver for ObjectiveItemResolver {
@@ -731,14 +721,6 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
if !is_objective_route_reference(item_reference) {
return None;
}
let revision = self
.revisions
.lock()
.unwrap_or_else(|error| error.into_inner())
.revisions
.get(item_reference)
.cloned()
.unwrap_or_else(|| "unobserved".into());
let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem {
object: Object {
@@ -746,13 +728,14 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
description: Some("Authoritative Objective bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
r#ref: Some(format!("objective:{item_reference}")),
validator: Some(route_validator(&route, &revision)),
// The Object publishes fixed operation signatures, not Objective content.
// Each operation reads/writes current Backend state under its authority.
validator: Some(route_validator(&route, "objective-item")),
},
handler: Arc::new(ObjectiveItemWipHandler {
backend: self.backend.clone(),
permissions: self.permissions.clone(),
objective_reference: item_reference.to_string(),
revisions: Arc::clone(&self.revisions),
}),
})
}
@@ -762,7 +745,6 @@ struct ObjectiveItemWipHandler {
backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>,
objective_reference: String,
revisions: ObjectiveRevisions,
}
#[async_trait]
@@ -832,7 +814,7 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
}
_ => return Err(operation_not_found()),
};
record_revision(&self.revisions, Some(&self.objective_reference), &response);
Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
))
@@ -1022,36 +1004,6 @@ fn string_list_argument(
}
}
fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) {
let (Some(revision), Some(canonical)) = (
response.get("revision").and_then(Json::as_str),
response.get("objective").and_then(Json::as_str),
) else {
return;
};
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
state
.aliases_by_canonical
.entry(canonical.to_string())
.or_default()
.insert(canonical.to_string());
if let Some(reference) = bound_reference {
state
.aliases_by_canonical
.entry(canonical.to_string())
.or_default()
.insert(reference.to_string());
}
let aliases = state
.aliases_by_canonical
.get(canonical)
.cloned()
.unwrap_or_default();
for alias in aliases {
state.revisions.insert(alias, revision.to_string());
}
}
fn is_objective_route_reference(reference: &str) -> bool {
is_canonical_resource_key(reference, "O-")
|| (reference.len() >= 8
@@ -1059,11 +1011,11 @@ fn is_objective_route_reference(reference: &str) -> bool {
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}
fn route_validator(route: &str, revision: &str) -> Vec<u8> {
fn route_validator(route: &str, signature: &str) -> Vec<u8> {
let mut digest = Sha256::new();
digest.update(route.as_bytes());
digest.update([0]);
digest.update(revision.as_bytes());
digest.update(signature.as_bytes());
digest.finalize().to_vec()
}
@@ -1115,7 +1067,7 @@ fn objective_collection_descriptor() -> InterfaceDescriptor {
parameter("state", false, TypeExpr::String),
parameter("linked_tickets", false, list_of(TypeExpr::String)),
],
returns: json_return("Created Objective with revision and bounded context"),
returns: json_return("Created Objective with bounded context"),
},
],
}
@@ -1182,7 +1134,7 @@ fn operation(
details: None,
}),
parameters,
returns: json_return("Authoritative Objective detail with revision and bounded context"),
returns: json_return("Authoritative Objective detail with bounded context"),
}
}
@@ -1293,7 +1245,7 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
}
const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata.";
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with full linked-Ticket context, bounded body, and paged event metadata.";
const CREATE_DESCRIPTION: &str =
"Create an Objective record through Backend Workspace API authority.";
const EDIT_DESCRIPTION: &str =
@@ -1549,17 +1501,16 @@ mod tests {
}
}
fn objective_response(revision: &str) -> crate::worker::WorkspaceResponse {
fn objective_response(title: &str) -> crate::worker::WorkspaceResponse {
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"id": "00001OBJECTIVE",
"resource_key": "O-3",
"title": "Objective",
"title": title,
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": revision,
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -1635,7 +1586,7 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": "rev-1",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -1704,7 +1655,6 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": format!("rev-{mutation}"),
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -1798,15 +1748,13 @@ mod tests {
}
#[test]
fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() {
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
fn dynamic_objective_routes_accept_only_objective_references() {
let resolver = ObjectiveItemResolver {
backend: WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let initial = resolver.resolve("O-3").unwrap();
assert_eq!(initial.object.name, "O-3");
@@ -1814,22 +1762,10 @@ mod tests {
assert!(resolver.resolve("T-3").is_none());
assert!(resolver.resolve("O-3/other").is_none());
record_revision(
&revisions,
Some("00001OBJECTIVE"),
&json!({"objective": "O-3", "revision": "rev-1"}),
assert_ne!(
initial.object.validator,
resolver.resolve("O-4").unwrap().object.validator
);
let canonical_rev_1 = resolver.resolve("O-3").unwrap().object.validator;
let internal_rev_1 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
record_revision(
&revisions,
Some("O-3"),
&json!({"objective": "O-3", "revision": "rev-2"}),
);
let canonical_rev_2 = resolver.resolve("O-3").unwrap().object.validator;
let internal_rev_2 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
assert_ne!(canonical_rev_1, canonical_rev_2);
assert_ne!(internal_rev_1, internal_rev_2);
}
#[test]
@@ -1948,7 +1884,6 @@ mod tests {
rules: Vec::new(),
}),
objective_reference: "O-3".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = denied
.call(
@@ -1972,7 +1907,6 @@ mod tests {
backend,
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = collection
.call(
@@ -2009,12 +1943,11 @@ mod tests {
objective_response("rev-4"),
]));
let backend = WorkspaceHttpObjectiveBackend::new(client.clone());
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection = ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let context = || WipCallContext {
execution: ToolExecutionContext::direct(),
@@ -2035,7 +1968,6 @@ mod tests {
backend,
permissions: None,
objective_reference: "O-3".into(),
revisions,
};
assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok());
assert!(
@@ -2100,7 +2032,6 @@ mod tests {
backend: WorkspaceHttpObjectiveBackend::new(client),
permissions: None,
objective_reference: "O-404".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
for (operation, arguments) in [
("read", BTreeMap::new()),
@@ -2171,7 +2102,6 @@ mod tests {
)),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let output = match handler
.call(
@@ -2198,7 +2128,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
async fn native_edit_binds_target_from_route_and_publishes_new_revision() {
async fn native_edit_returns_updated_content_without_changing_object_signatures() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
@@ -2217,7 +2147,7 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": "rev-2",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -2233,7 +2163,7 @@ mod tests {
)
.unwrap();
});
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
@@ -2241,14 +2171,12 @@ mod tests {
backend: backend.clone(),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let before = resolver.resolve("O-3").unwrap().object.validator;
let handler = ObjectiveItemWipHandler {
backend,
permissions: None,
objective_reference: "O-3".into(),
revisions,
};
let output = match handler
.call(
@@ -2267,9 +2195,9 @@ mod tests {
server.join().unwrap();
let output = crate::wip::wip_to_json(&output.value).unwrap();
assert_eq!(output["objective"], "O-3");
assert_eq!(output["revision"], "rev-2");
assert_eq!(output["title"], "Changed");
let after = resolver.resolve("O-3").unwrap().object.validator;
assert_ne!(before, after);
assert_eq!(before, after);
}
#[test]
@@ -35,7 +35,7 @@ struct ModelTicketQueryItem {
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
item_revision: String,
content_digest: String,
title: String,
body: String,
state: String,
@@ -82,7 +82,6 @@ pub(super) struct ModelObjectiveDetail {
body: String,
body_truncated: bool,
state: String,
revision: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>,
@@ -284,7 +283,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
Ok(ModelTicketDetail {
ticket: resource_ref(root, "resource_key", "T-")?,
item_revision: string_field(root, "item_revision")?,
content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
@@ -369,7 +368,6 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
body: string_field(root, "body")?,
body_truncated: bool_field(root, "body_truncated")?,
state: string_field(root, "state")?,
revision: string_field(root, "revision")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
linked_tickets: array_field(root, "linked_ticket_summaries")?
@@ -705,7 +703,7 @@ mod tests {
"closed",
] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "item_revision": "rev-1", "title": "No Git required", "body": "Decision",
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
@@ -735,7 +733,6 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": "rev-3",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
"linked_tickets": ["00001M0E82D1V"],
@@ -759,7 +756,6 @@ mod tests {
let text = json.to_string();
assert!(text.contains("O-543"));
assert!(text.contains("T-496"));
assert_eq!(json["revision"], "rev-3");
assert_eq!(json["body_truncated"], false);
assert!(!text.contains("00001M10HW6BV"));
assert!(!text.contains("00001M0E82D1V"));
@@ -845,7 +841,7 @@ mod tests {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"item_revision": "revision-1",
"content_digest": "revision-1",
"title": "Queued Submit",
"body": "Body",
"state": "planning",
@@ -896,7 +892,7 @@ mod tests {
.expect("current Ticket blocker shape must project");
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
assert_eq!(projected["item_revision"], "revision-1");
assert_eq!(projected["content_digest"], "revision-1");
assert_eq!(
projected["relations"]["blockers"],
json!([{
@@ -948,7 +944,7 @@ mod tests {
});
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"item_revision": "current-ticket-revision",
"content_digest": "current-ticket-revision",
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
@@ -986,7 +982,7 @@ mod tests {
Some(&json!(integration_error))
);
}
assert_eq!(projected["item_revision"], "current-ticket-revision");
assert_eq!(projected["content_digest"], "current-ticket-revision");
assert_eq!(projected["evidence"]["review_after_rescope"], attested);
assert_eq!(projected["evidence"]["complete_for_integration"], merged);
}
@@ -1,8 +1,8 @@
//! Host-scoped model tools for revisioned subjektiv Memory.
//! Host-scoped model tools for change-identified subjektiv Memory.
//!
//! Confirmed Memory is read-only here. Explicit remembering and revision requests
//! Confirmed Memory is read-only here. Explicit remembering and change requests
//! become immutable staging candidates; T-670 remains the only authority that may
//! adopt a candidate as a confirmed revision.
//! adopt a candidate as a confirmed change.
use std::collections::HashSet;
use std::sync::Arc;
@@ -13,10 +13,9 @@ use memory::extract::CandidateKind;
use schemars::JsonSchema;
use serde::Deserialize;
use server_api::{
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse,
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest,
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse, SubjektivMemoryChangeIntent,
SubjektivMemoryChangeProposal, SubjektivMemoryListChangesRequest, SubjektivMemoryQueryRequest,
SubjektivMemoryReadRequest, SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse,
SubjektivMemoryValidateProposalRequest,
};
@@ -41,19 +40,19 @@ use server_api::SubjektivMemoryBackendRequest;
const QUERY_TOOL: &str = "SubjektivMemoryQuery";
const READ_TOOL: &str = "SubjektivMemoryRead";
const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions";
const LIST_CHANGES_TOOL: &str = "SubjektivMemoryListChanges";
const REMEMBER_TOOL: &str = "SubjektivMemoryRemember";
const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision";
const PROPOSE_CHANGE_TOOL: &str = "SubjektivMemoryProposeChange";
const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit";
const RETRY_HOOK: &str = "retry-committed-explicit-memory";
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
const MAX_EVIDENCE_REFS: usize = 10;
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact revision, line offset, and byte offset.";
const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination.";
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory changes. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory change for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact change, line offset, and byte offset.";
const LIST_CHANGES_DESCRIPTION: &str = "List immutable changes of one connected-subject Memory in newest-first change order using snapshot-bound pagination.";
const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit.";
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision.";
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory change. This validates optimistic concurrency now but never creates a confirmed change.";
#[derive(Clone)]
pub(crate) struct SubjektivMemoryFeature {
@@ -73,7 +72,7 @@ struct PendingExplicit {
claim: String,
why_useful: String,
staleness: Option<String>,
proposal: Option<SubjektivMemoryRevisionProposal>,
proposal: Option<SubjektivMemoryChangeProposal>,
}
impl SubjektivMemoryFeature {
@@ -102,12 +101,12 @@ impl FeatureModule for SubjektivMemoryFeature {
.with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION))
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
.with_tool(ToolDeclaration::new(
LIST_REVISIONS_TOOL,
LIST_REVISIONS_DESCRIPTION,
LIST_CHANGES_TOOL,
LIST_CHANGES_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION))
.with_tool(ToolDeclaration::new(
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
PROPOSE_DESCRIPTION,
))
.with_hook(HookDeclaration::new(
@@ -129,9 +128,9 @@ impl FeatureModule for SubjektivMemoryFeature {
(QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query),
(READ_TOOL, READ_DESCRIPTION, ReadOperation::Read),
(
LIST_REVISIONS_TOOL,
LIST_REVISIONS_DESCRIPTION,
ReadOperation::ListRevisions,
LIST_CHANGES_TOOL,
LIST_CHANGES_DESCRIPTION,
ReadOperation::ListChanges,
),
] {
context.tools().register(ToolContribution::new(
@@ -149,9 +148,9 @@ impl FeatureModule for SubjektivMemoryFeature {
),
))?;
context.tools().register(ToolContribution::new(
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
explicit_tool_definition(
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
PROPOSE_DESCRIPTION,
self.state.clone(),
ExplicitOperation::Propose,
@@ -184,7 +183,7 @@ impl FeatureModule for SubjektivMemoryFeature {
enum ReadOperation {
Query,
Read,
ListRevisions,
ListChanges,
}
fn read_tool_definition(
@@ -197,7 +196,7 @@ fn read_tool_definition(
let schema = match operation {
ReadOperation::Query => schema_for::<SubjektivMemoryQueryRequest>(),
ReadOperation::Read => schema_for::<SubjektivMemoryReadRequest>(),
ReadOperation::ListRevisions => schema_for::<SubjektivMemoryListRevisionsRequest>(),
ReadOperation::ListChanges => schema_for::<SubjektivMemoryListChangesRequest>(),
};
let tool: Arc<dyn Tool> = Arc::new(SubjektivReadTool {
state: state.clone(),
@@ -231,10 +230,9 @@ impl Tool for SubjektivReadTool {
ReadOperation::Read => {
SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?)
}
ReadOperation::ListRevisions => SubjektivMemoryBackendOperation::ListRevisions(parse(
input_json,
LIST_REVISIONS_TOOL,
)?),
ReadOperation::ListChanges => {
SubjektivMemoryBackendOperation::ListChanges(parse(input_json, LIST_CHANGES_TOOL)?)
}
};
let response = match self.state.execute(operation) {
Ok(response) => response,
@@ -247,14 +245,14 @@ impl Tool for SubjektivReadTool {
),
SubjektivMemoryBackendResponse::Read(value) => (
format!(
"Read Memory {} revision {}.",
value.memory_id, value.revision
"Read Memory {} change {}.",
value.memory_id, value.change_id
),
serde_json::to_value(value),
),
SubjektivMemoryBackendResponse::ListRevisions(value) => (
SubjektivMemoryBackendResponse::ListChanges(value) => (
format!(
"Listed {} revision(s) for Memory {}.",
"Listed {} change(s) for Memory {}.",
value.items.len(),
value.memory_id
),
@@ -296,7 +294,7 @@ fn explicit_tool_definition(
Arc::new(move || {
let schema = match operation {
ExplicitOperation::Remember => schema_for::<RememberParams>(),
ExplicitOperation::Propose => schema_for::<ProposeRevisionParams>(),
ExplicitOperation::Propose => schema_for::<ProposeChangeParams>(),
};
let tool: Arc<dyn Tool> = Arc::new(SubjektivExplicitTool {
state: state.clone(),
@@ -325,11 +323,11 @@ struct RememberParams {
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ProposeRevisionParams {
struct ProposeChangeParams {
memory_id: String,
#[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))]
expected_revision: u64,
intent: SubjektivMemoryRevisionIntent,
#[schemars(length(min = 1))]
expected_change_id: String,
intent: SubjektivMemoryChangeIntent,
claim: String,
why_useful: String,
change_reason: String,
@@ -371,10 +369,10 @@ impl Tool for SubjektivExplicitTool {
)
}
ExplicitOperation::Propose => {
let params: ProposeRevisionParams = parse(input_json, PROPOSE_REVISION_TOOL)?;
if params.expected_revision == 0 {
let params: ProposeChangeParams = parse(input_json, PROPOSE_CHANGE_TOOL)?;
if params.expected_change_id.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"expected_revision must be a positive integer".into(),
"expected_change_id must not be empty".into(),
));
}
let validation =
@@ -383,7 +381,7 @@ impl Tool for SubjektivExplicitTool {
.execute(SubjektivMemoryBackendOperation::ValidateProposal(
SubjektivMemoryValidateProposalRequest {
memory_id: params.memory_id.clone(),
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
},
)) {
@@ -398,9 +396,9 @@ impl Tool for SubjektivExplicitTool {
)));
}
};
let proposal = SubjektivMemoryRevisionProposal {
let proposal = SubjektivMemoryChangeProposal {
memory_id: params.memory_id,
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
change_reason: params.change_reason,
};
@@ -447,7 +445,7 @@ impl Tool for SubjektivExplicitTool {
"status": "pending_commit",
"target": proposal.as_ref().map(|value| serde_json::json!({
"memory_id": value.memory_id,
"expected_revision": value.expected_revision,
"expected_change_id": value.expected_change_id,
})),
"intent": proposal.as_ref().map(|value| value.intent),
"message": "Candidate is not confirmed Memory. It will be staged only after this tool call is durably committed; an uncommitted/cancelled operation is discarded.",
@@ -514,7 +512,7 @@ impl SubjektivMemoryState {
let session_id = capture.session_id.clone();
let view = SessionCapture::from_history_entries(capture.segment_id, capture.history);
let committed = view
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL])
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL])
.into_iter()
.any(|call| {
receipt_id(&session_id, &call.call_id)
@@ -537,7 +535,7 @@ impl SubjektivMemoryState {
why_useful: String,
staleness: Option<String>,
entries: Vec<SessionEntryEvidence>,
proposal: Option<SubjektivMemoryRevisionProposal>,
proposal: Option<SubjektivMemoryChangeProposal>,
) -> Result<SubjektivMemoryStageExplicitResponse, ToolError> {
if entries.is_empty() {
return Err(ToolError::ExecutionFailed(
@@ -620,7 +618,7 @@ fn replay_pending_committed_memory_with_policy(
let view =
SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone());
let mut failures = Vec::new();
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) {
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) {
let receipt_id = match receipt_id(&capture.session_id, &call.call_id) {
Ok(value) => value,
Err(error) => {
@@ -693,12 +691,12 @@ fn pending_from_committed_call(
None,
)
}
PROPOSE_REVISION_TOOL => {
let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?;
PROPOSE_CHANGE_TOOL => {
let params: ProposeChangeParams = parse(arguments, PROPOSE_CHANGE_TOOL)?;
let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal(
SubjektivMemoryValidateProposalRequest {
memory_id: params.memory_id.clone(),
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
},
))?;
@@ -716,9 +714,9 @@ fn pending_from_committed_call(
params.why_useful,
params.staleness,
params.entry_refs,
Some(SubjektivMemoryRevisionProposal {
Some(SubjektivMemoryChangeProposal {
memory_id: params.memory_id,
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
change_reason: params.change_reason,
}),
@@ -1067,7 +1065,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 0,
run_exit: CommittedRunExit::Finished,
history: Vec::new(),
@@ -1096,9 +1094,9 @@ mod tests {
vec![
QUERY_TOOL,
READ_TOOL,
LIST_REVISIONS_TOOL,
LIST_CHANGES_TOOL,
REMEMBER_TOOL,
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
]
);
assert_eq!(descriptor.hooks.len(), 3);
@@ -1128,11 +1126,11 @@ mod tests {
.any(|field| field == "entry_refs")
);
let propose = schema_for::<ProposeRevisionParams>();
let propose = schema_for::<ProposeChangeParams>();
let propose_text = serde_json::to_string(&propose).unwrap();
assert!(!propose_text.contains("subject_id"));
assert!(!propose_text.contains("\"kind\""));
assert!(propose_text.contains("expected_revision"));
assert!(propose_text.contains("expected_change_id"));
assert!(propose_text.contains("change_reason"));
let parsed: RememberParams = serde_json::from_str(
@@ -1152,7 +1150,7 @@ mod tests {
worker_id: Some("w".repeat(64)),
flow_selector: Some("f".repeat(64)),
flow_definition_id: Some("d".repeat(64)),
flow_definition_revision: Some(1),
flow_definition_fingerprint: Some("fingerprint".into()),
};
let evidence = (0..MAX_EVIDENCE_REFS)
.map(|index| memory::extract::StagingEvidence {
@@ -1178,8 +1176,8 @@ mod tests {
.collect::<Vec<_>>();
let response = server_api::SubjektivMemoryReadResponse {
memory_id: "memory-1".into(),
revision: 1,
current_revision: 1,
change_id: "change-1".into(),
current_change_id: "change-1".into(),
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Escape-aware read".into(),
@@ -1252,7 +1250,7 @@ mod tests {
#[tokio::test]
async fn backend_conflict_discriminants_survive_as_structured_tool_output() {
for code in ["revision_conflict", "stale_cursor"] {
for code in ["change_conflict", "stale_cursor"] {
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient(
@@ -1291,7 +1289,7 @@ mod tests {
operation: ExplicitOperation::Remember,
};
let input =
r#"{"kind":"lesson","claim":"Use fixed revisions","why_useful":"Avoid mixed reads"}"#;
r#"{"kind":"lesson","claim":"Use fixed changes","why_useful":"Avoid mixed reads"}"#;
let output = tool
.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0))
.await
@@ -1321,7 +1319,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 3,
run_exit: CommittedRunExit::Interrupted,
history: vec![
@@ -1380,7 +1378,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
run_exit: CommittedRunExit::Finished,
history: vec![
@@ -1457,7 +1455,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
run_exit: CommittedRunExit::Finished,
history: vec![
+123 -96
View File
@@ -63,7 +63,7 @@ impl WorkspaceTicketReadKind {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
}
Self::Show => {
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
"Show one authoritative Workspace Ticket with its content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
}
}
}
@@ -292,8 +292,8 @@ struct WorkspaceCompleteTicketInput {
ticket: String,
/// Stable key for exact replay of this completion decision.
operation_key: String,
/// Current item revision from ShowTicket.
expected_item_revision: String,
/// SHA-256 of the current editable Ticket content from ShowTicket.
expected_content_digest: String,
expected_state: TicketWorkflowState,
/// Required explanation of the completion decision; MR approval is not a prerequisite.
reason: String,
@@ -308,8 +308,8 @@ struct WorkspaceTicketStateUpdateInput {
/// Ticket reference. Prefer `T-*`.
ticket: String,
operation_key: String,
/// Current item revision from ShowTicket.
expected_item_revision: String,
/// SHA-256 of the current editable Ticket content from ShowTicket.
expected_content_digest: String,
expected_state: TicketWorkflowState,
/// Progress-display state only: this does not start Workers or integrate Merge Requests.
state: TicketWorkflowState,
@@ -326,8 +326,8 @@ struct WorkspaceTicketCloseInput {
ticket: String,
/// Stable key for exact replay of this close decision.
operation_key: String,
/// Current item revision from ShowTicket.
expected_item_revision: String,
/// SHA-256 of the current editable Ticket content from ShowTicket.
expected_content_digest: String,
expected_state: TicketWorkflowState,
/// Required explanation for closing this Ticket.
resolution: String,
@@ -381,13 +381,13 @@ struct WorkspaceTicketDecisionTool {
fn validate_ticket_decision(
ticket: &str,
operation_key: &str,
revision: &str,
content_digest: &str,
reason: &str,
) -> Result<(), ToolError> {
for (name, value) in [
("ticket", ticket),
("operation_key", operation_key),
("expected_item_revision", revision),
("expected_content_digest", content_digest),
("reason", reason),
] {
if value.trim().is_empty() {
@@ -412,7 +412,7 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision(
&value.ticket,
&value.operation_key,
&value.expected_item_revision,
&value.expected_content_digest,
&value.reason,
)?;
let mut body = serde_json::to_value(&value)
@@ -428,12 +428,12 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision(
&value.ticket,
&value.operation_key,
&value.expected_item_revision,
&value.expected_content_digest,
&value.resolution,
)?;
let body = json!({
"operation_key": value.operation_key,
"expected_item_revision": value.expected_item_revision,
"expected_content_digest": value.expected_content_digest,
"expected_state": value.expected_state,
"state": TicketWorkflowState::Closed,
"reason": value.resolution,
@@ -447,7 +447,7 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision(
&value.ticket,
&value.operation_key,
&value.expected_item_revision,
&value.expected_content_digest,
&value.reason,
)?;
let mut body = serde_json::to_value(&value)
@@ -1005,7 +1005,7 @@ impl WorkspaceHttpTicketBackend {
)?;
serde_json::to_value(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&snapshot),
expected_content_digest: ticket::ticket_content_digest(&snapshot),
expected_state,
state,
reason: reason.into(),
@@ -1205,7 +1205,7 @@ impl WorkspaceHttpTicketBackend {
)?;
let body = serde_json::to_value(ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&snapshot),
expected_content_digest: ticket::ticket_content_digest(&snapshot),
expected_state: snapshot.meta.workflow_state,
reason: resolution.as_str().to_owned(),
references: Vec::new(),
@@ -1763,13 +1763,12 @@ fn native_ticket_tools(
}
#[derive(Default)]
struct TicketRevisionState {
revisions: HashMap<String, String>,
struct TicketObservationState {
content_digests: HashMap<String, String>,
aliases_by_canonical: HashMap<String, BTreeSet<String>>,
mutation_sequence: u64,
}
type TicketRevisions = Arc<Mutex<TicketRevisionState>>;
type TicketObservations = Arc<Mutex<TicketObservationState>>;
/// Mount the enabled Ticket Feature surface as native collection and route-bound
/// item objects. Only tools already enabled for this Worker are projected and
@@ -1797,7 +1796,7 @@ pub fn mount_workspace_http_ticket_wip(
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.cloned()
.collect::<Vec<_>>();
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let collection_descriptor = ticket_descriptor(
&collection_tools,
@@ -1824,7 +1823,7 @@ pub fn mount_workspace_http_ticket_wip(
tools: operation_map(collection_tools),
permissions: permissions.clone(),
collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
}),
})?;
@@ -1843,7 +1842,7 @@ pub fn mount_workspace_http_ticket_wip(
tools: operation_map(item_tools),
permissions,
collection_route: collection_route.clone(),
revisions,
observations,
}),
})?;
registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
@@ -1861,7 +1860,7 @@ struct TicketCollectionWipHandler {
tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: TicketRevisions,
observations: TicketObservations,
}
#[async_trait]
@@ -1879,7 +1878,7 @@ impl WipOperationHandler for TicketCollectionWipHandler {
match operation {
"query" => add_ticket_paths(&mut response, &self.collection_route),
"create" => {
record_ticket_observation(&self.revisions, None, &response, true);
record_ticket_observation(&self.observations, None, &response, true);
if let Some(reference) = ticket_reference(&response).map(ToOwned::to_owned) {
response.as_object_mut().map(|object| {
object.insert(
@@ -1901,7 +1900,7 @@ struct TicketItemResolver {
tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: TicketRevisions,
observations: TicketObservations,
}
impl WipDynamicItemResolver for TicketItemResolver {
@@ -1909,14 +1908,13 @@ impl WipDynamicItemResolver for TicketItemResolver {
if !is_ticket_route_reference(item_reference) {
return None;
}
let revision = self
.revisions
let content_digest = self
.observations
.lock()
.unwrap_or_else(|error| error.into_inner())
.revisions
.content_digests
.get(item_reference)
.cloned()
.unwrap_or_else(|| "unobserved".into());
.cloned();
let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem {
object: Object {
@@ -1924,13 +1922,15 @@ impl WipDynamicItemResolver for TicketItemResolver {
description: Some("Authoritative Ticket bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
r#ref: Some(format!("ticket:{item_reference}")),
validator: Some(route_validator(&route, &revision)),
validator: content_digest
.as_deref()
.map(|digest| route_validator(&route, digest)),
},
handler: Arc::new(TicketItemWipHandler {
tools: self.tools.clone(),
permissions: self.permissions.clone(),
ticket_reference: item_reference.into(),
revisions: Arc::clone(&self.revisions),
observations: Arc::clone(&self.observations),
}),
})
}
@@ -1940,7 +1940,7 @@ struct TicketItemWipHandler {
tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>,
ticket_reference: String,
revisions: TicketRevisions,
observations: TicketObservations,
}
#[async_trait]
@@ -1960,13 +1960,13 @@ impl WipOperationHandler for TicketItemWipHandler {
let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
let response = ticket_tool_output_json(output)?;
record_ticket_observation(
&self.revisions,
&self.observations,
Some(&self.ticket_reference),
&response,
tool.projection.mutating,
);
if tool.projection.mutating {
record_affected_ticket_mutations(&self.revisions, &self.ticket_reference, &response);
record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response);
}
Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
@@ -2122,23 +2122,16 @@ fn ticket_reference(response: &Value) -> Option<&str> {
.filter(|reference| is_canonical_ticket_resource_key(reference))
}
fn response_revision(response: &Value) -> Option<&str> {
response
.get("item_revision")
.or_else(|| response.get("revision"))
.or_else(|| response.get("updated_at"))
.or_else(|| response.get("meta").and_then(|meta| meta.get("updated_at")))
.and_then(Value::as_str)
}
fn record_ticket_observation(
revisions: &TicketRevisions,
observations: &TicketObservations,
bound_reference: Option<&str>,
response: &Value,
mutation: bool,
) {
let observed_canonical = ticket_reference(response).map(ToOwned::to_owned);
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
let mut state = observations
.lock()
.unwrap_or_else(|error| error.into_inner());
let existing_bound_group = bound_reference.and_then(|reference| {
state
.aliases_by_canonical
@@ -2180,29 +2173,22 @@ fn record_ticket_observation(
aliases.insert(reference.to_string());
}
let revision = if mutation {
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
format!(
"{}#mutation-{}",
response_revision(response).unwrap_or("observed"),
state.mutation_sequence
)
} else if let Some(revision) = response_revision(response) {
revision.to_string()
} else {
aliases
.iter()
.find_map(|alias| state.revisions.get(alias).cloned())
.unwrap_or_else(|| "observed".into())
};
// Mutation receipts are not full representations. Forget the observed
// content rather than manufacturing a local change counter.
for alias in &aliases {
state.revisions.insert(alias.clone(), revision.clone());
if mutation {
state.content_digests.remove(alias);
} else {
let bytes = Sha256::digest(response.to_string().as_bytes());
let digest = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
state.content_digests.insert(alias.clone(), digest);
}
}
state.aliases_by_canonical.insert(group_key, aliases);
}
fn record_affected_ticket_mutations(
revisions: &TicketRevisions,
observations: &TicketObservations,
bound_reference: &str,
response: &Value,
) {
@@ -2228,7 +2214,7 @@ fn record_affected_ticket_mutations(
);
}
for reference in affected.difference(&subject_references) {
record_ticket_observation(revisions, Some(reference), &Value::Null, true);
record_ticket_observation(observations, Some(reference), &Value::Null, true);
}
}
@@ -2239,11 +2225,11 @@ fn is_ticket_route_reference(reference: &str) -> bool {
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}
fn route_validator(route: &str, revision: &str) -> Vec<u8> {
fn route_validator(route: &str, content_digest: &str) -> Vec<u8> {
let mut digest = Sha256::new();
digest.update(route.as_bytes());
digest.update([0]);
digest.update(revision.as_bytes());
digest.update(content_digest.as_bytes());
digest.finalize().to_vec()
}
@@ -2427,7 +2413,7 @@ mod tests {
fn decision_input() -> Value {
json!({
"ticket": "T-718", "operation_key": "decision-1",
"expected_item_revision": "revision-1", "expected_state": "planning",
"expected_content_digest": "content_digest-1", "expected_state": "planning",
"reason": "No repository changes are needed"
})
}
@@ -2482,8 +2468,8 @@ mod tests {
let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap();
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
assert_eq!(
request.expected_item_revision,
ticket::ticket_item_revision(&snapshot)
request.expected_content_digest,
ticket::ticket_content_digest(&snapshot)
);
assert_eq!(request.expected_state, TicketWorkflowState::Planning);
assert_eq!(request.state, TicketWorkflowState::Closed);
@@ -2592,8 +2578,8 @@ mod tests {
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
assert!(keys.insert(request.operation_key));
assert_eq!(
request.expected_item_revision,
ticket::ticket_item_revision(&snapshot)
request.expected_content_digest,
ticket::ticket_content_digest(&snapshot)
);
assert_eq!(request.expected_state, TicketWorkflowState::Ready);
assert_eq!(request.reason, "Resolved without repository changes");
@@ -2643,7 +2629,7 @@ mod tests {
);
for field in [
"operation_key",
"expected_item_revision",
"expected_content_digest",
"expected_state",
"resolution",
] {
@@ -2660,13 +2646,19 @@ mod tests {
.iter()
.any(|p| p.name == "references" && !p.required)
);
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: operation_map(tools),
permissions: None,
collection_route: "/tickets".into(),
revisions,
observations,
};
record_ticket_observation(
&resolver.observations,
Some("T-718"),
&json!({"ticket": "T-718", "body": "observed requirements"}),
false,
);
let bound = resolver.resolve("T-718").unwrap();
let before = bound.object.validator;
let other = resolver.resolve("T-719").unwrap().object.validator;
@@ -2702,7 +2694,7 @@ mod tests {
assert_eq!(
body,
json!({
"operation_key": "decision-1", "expected_item_revision": "revision-1",
"operation_key": "decision-1", "expected_content_digest": "content_digest-1",
"expected_state": "planning", "state": "closed",
"reason": "No repository changes are needed", "references": []
})
@@ -2794,7 +2786,7 @@ mod tests {
assert_eq!(
body,
json!({
"operation_key": "decision-1", "expected_item_revision": "revision-1",
"operation_key": "decision-1", "expected_content_digest": "content_digest-1",
"expected_state": "planning", "reason": "No repository changes are needed", "references": []
})
);
@@ -2859,7 +2851,7 @@ mod tests {
assert!(validator.is_valid(&valid));
for field in [
"operation_key",
"expected_item_revision",
"expected_content_digest",
"expected_state",
reason_field,
] {
@@ -2875,7 +2867,7 @@ mod tests {
for field in [
reason_field,
"operation_key",
"expected_item_revision",
"expected_content_digest",
"ticket",
] {
let mut invalid = valid.clone();
@@ -2968,7 +2960,7 @@ mod tests {
#[tokio::test]
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
let client = decision_client(200, TicketWorkflowState::Done);
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
@@ -2980,7 +2972,7 @@ mod tests {
assert!(!complete.parameters.iter().any(|p| p.name == "ticket"));
for field in [
"operation_key",
"expected_item_revision",
"expected_content_digest",
"expected_state",
"reason",
] {
@@ -2995,8 +2987,14 @@ mod tests {
tools: operation_map(tools),
permissions: None,
collection_route: "/tickets".into(),
revisions: revisions.clone(),
observations: observations.clone(),
};
record_ticket_observation(
&resolver.observations,
Some("T-718"),
&json!({"ticket": "T-718", "body": "observed requirements"}),
false,
);
let bound = resolver.resolve("T-718").unwrap();
let before = bound.object.validator;
let other = resolver.resolve("T-719").unwrap().object.validator;
@@ -3138,12 +3136,12 @@ mod tests {
.is_err()
);
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
revisions,
observations,
};
assert!(resolver.resolve("T-42").is_some());
assert!(resolver.resolve("00001TICKET").is_some());
@@ -3202,36 +3200,36 @@ mod tests {
#[test]
fn ticket_alias_validators_refresh_together_after_mutation() {
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
};
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation(
&revisions,
&observations,
Some("00001TICKET"),
&json!({"ticket": "00001TICKET", "ok": true}),
true,
);
assert_ne!(
assert_eq!(
unobserved_internal,
resolver.resolve("00001TICKET").unwrap().object.validator,
"a first mutation through an internal-id route must stale that route"
"an unobserved route stays unvalidated after mutation"
);
record_ticket_observation(
&revisions,
&observations,
Some("00001TICKET"),
&json!({"ticket": "T-42", "item_revision": "rev-1"}),
&json!({"ticket": "T-42", "content_digest": "rev-1"}),
false,
);
let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator;
let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation(
&revisions,
&observations,
Some("T-42"),
&json!({"ticket": "T-42", "updated_at": "rev-2"}),
true,
@@ -3246,14 +3244,43 @@ mod tests {
);
}
#[test]
fn ticket_observation_digest_is_content_based_and_repeated_mutations_only_invalidate() {
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
observations: Arc::clone(&observations),
};
let response = json!({"ticket": "T-42", "body": "actual content", "state": "planning"});
assert!(resolver.resolve("T-42").unwrap().object.validator.is_none());
record_ticket_observation(&observations, Some("T-42"), &response, false);
let observed = resolver.resolve("T-42").unwrap().object.validator;
assert!(observed.is_some());
record_ticket_observation(&observations, Some("T-42"), &response, false);
assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed);
for _ in 0..2 {
record_ticket_observation(
&observations,
Some("T-42"),
&json!({"operation_key": "same-replay"}),
true,
);
assert!(resolver.resolve("T-42").unwrap().object.validator.is_none());
}
record_ticket_observation(&observations, Some("T-42"), &response, false);
assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed);
}
#[test]
fn related_ticket_mutations_invalidate_target_and_queue_observations() {
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
for ticket in ["T-42", "T-43", "T-44"] {
record_ticket_observation(
&revisions,
&observations,
Some(ticket),
&json!({"ticket": ticket, "item_revision": "rev-1"}),
&json!({"ticket": ticket, "content_digest": "rev-1"}),
false,
);
}
@@ -3261,12 +3288,12 @@ mod tests {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
record_affected_ticket_mutations(
&revisions,
&observations,
"T-42",
&json!({
"ticket": "T-42",
@@ -99,7 +99,6 @@ fn same_snapshot(
) -> Result<(), WipOperationError> {
if snapshot.connection_id != other.connection_id
|| snapshot.validator != other.validator
|| snapshot.revision != other.revision
|| snapshot.digest != other.digest
|| snapshot.entrypoints != other.entrypoints
{
@@ -504,7 +503,7 @@ impl ConfigHandler {
"Workspace config text exceeds limit",
));
}
// Resolve canonical per-file preconditions at the SAME captured tree revision.
// Resolve canonical per-file preconditions at the SAME captured tree content.
let metadata = observe(
&self.backend,
&self.attachment.connection_id,
@@ -716,7 +715,6 @@ impl ConfigHandler {
connection_id: self.attachment.connection_id.clone(),
validator: self.snapshot.validator.clone(),
request: ConfigCommitRequest {
base_revision: self.snapshot.revision,
base_digest: self.snapshot.digest.clone(),
changes,
entrypoints: self.snapshot.entrypoints.clone(),
@@ -735,10 +733,7 @@ impl ConfigHandler {
)
.await
.map_err(|_| unknown())?;
if updated.revision != result.revision
|| updated.digest != result.digest
|| updated.validator != result.validator
{
if updated.digest != result.digest || updated.validator != result.validator {
return Err(unknown());
}
let node = updated
@@ -746,7 +741,7 @@ impl ConfigHandler {
.iter()
.find(|n| n.path == self.node.path)
.ok_or_else(unknown)?;
let value = json_to_wip(&json!({"revision": result.revision, "digest": result.digest,
let value = json_to_wip(&json!({"digest": result.digest,
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
Ok(WipOperationOutput::native_with_validator(
value,
@@ -14,7 +14,6 @@ use protocol::{FeatureInvocation, FeatureInvocationIdentity};
use serde::Serialize;
use serde_json::{Value as Json, json};
use server_api::*;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
@@ -24,7 +23,6 @@ struct State {
read_only: bool,
active: Option<WorkspaceConfigAttachment>,
next_connection: u64,
revision: u64,
entries: BTreeMap<String, (ConfigContentType, String)>,
commits: Vec<WorkspaceConfigCommitRequest>,
requests: Vec<WorkspaceRequest>,
@@ -32,6 +30,7 @@ struct State {
fail_current: bool,
lose_commit_reply: bool,
race_commit: bool,
mismatched_commit_reply: Option<&'static str>,
}
#[derive(Debug)]
struct Router {
@@ -47,7 +46,6 @@ impl Router {
read_only: false,
active: None,
next_connection: 0,
revision: 1,
entries: BTreeMap::from([
(
"main.dcdl".into(),
@@ -64,6 +62,7 @@ impl Router {
fail_current: false,
lose_commit_reply: false,
race_commit: false,
mismatched_commit_reply: None,
})),
})
}
@@ -88,20 +87,30 @@ fn denied(status: u16, classification: WorkspaceConfigFailureClassification) ->
}
}
fn digest(content: &str) -> String {
Sha256::digest(content.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
config_source::digest_bytes(content.as_bytes())
}
impl State {
fn tree_digest(&self) -> String {
digest(&serde_json::to_string(&self.entries).unwrap())
config_source::ConfigTreeSnapshot::from_entries(self.entries.iter().map(
|(path, (content_type, content))| {
config_source::ConfigEntry::new(
config_source::VirtualPath::parse(path).unwrap(),
match content_type {
ConfigContentType::Decodal => config_source::ConfigContentType::Decodal,
ConfigContentType::Text => config_source::ConfigContentType::Text,
},
content.clone(),
)
.unwrap()
},
))
.unwrap()
.digest
}
fn validator(&self, path: &str) -> String {
format!(
"wc:{}:{}:{}:{}",
"wc:{}:{}:{}",
self.active.as_ref().unwrap().connection_id,
self.revision,
self.tree_digest(),
path
)
@@ -262,7 +271,6 @@ impl WorkspaceClient for Router {
Ok(response(&WorkspaceConfigObserveResponse {
connection_id: state.active.as_ref().unwrap().connection_id.clone(),
validator: state.validator(""),
revision: state.revision,
digest: state.tree_digest(),
entrypoints: vec!["main.dcdl".into()],
nodes,
@@ -294,7 +302,7 @@ impl WorkspaceClient for Router {
let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap();
state.commits.push(request.clone());
if state.race_commit {
state.revision += 1;
state.entries.get_mut("main.dcdl").unwrap().1 = "model = concurrent".into();
state.race_commit = false;
}
if state.read_only {
@@ -305,7 +313,6 @@ impl WorkspaceClient for Router {
}
assert_eq!(request.request.entrypoints, ["main.dcdl"]);
if request.validator != state.validator("")
|| request.request.base_revision != state.revision
|| request.request.base_digest != state.tree_digest()
{
return Ok(denied(
@@ -396,18 +403,23 @@ impl WorkspaceClient for Router {
));
}
state.entries = entries;
state.revision += 1;
if state.lose_commit_reply {
state.lose_commit_reply = false;
return Err(WorkspaceClientError::Request(
"secret-token /private/host/config".into(),
));
}
Ok(response(&WorkspaceConfigCommitResponse {
let mut result = WorkspaceConfigCommitResponse {
validator: state.validator(""),
revision: state.revision,
digest: state.tree_digest(),
}))
};
match state.mismatched_commit_reply.take() {
Some("digest") => result.digest = "different-content".into(),
Some("validator") => result.validator = "different-authority".into(),
None => {}
Some(_) => unreachable!("unknown fixture mismatch"),
}
Ok(response(&result))
}
_ => panic!("unregistered endpoint: {suffix}"),
}
@@ -668,13 +680,20 @@ fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeatur
}
#[tokio::test]
async fn workspace_config_production_stale_same_bytes_and_commit_race_never_overwrite() {
async fn workspace_config_production_stale_content_and_commit_race_never_overwrite() {
let client = Router::new();
let (feature, runtime) = runtime(client.clone());
feature.attach("initial", None).await.unwrap();
let path = "/workspace-config/main.dcdl";
observe_interface(&runtime, path, true).await;
client.state.lock().unwrap().revision += 1; // Same bytes, different identity revision.
client
.state
.lock()
.unwrap()
.entries
.get_mut("main.dcdl")
.unwrap()
.1 = "model = external".into();
assert!(
call(&runtime, path, "write", json!({"content":"overwritten"}))
.await
@@ -690,7 +709,7 @@ async fn workspace_config_production_stale_same_bytes_and_commit_race_never_over
);
assert_eq!(
client.state.lock().unwrap().entries["main.dcdl"].1,
"model = old"
"model = concurrent"
);
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
}
@@ -758,6 +777,26 @@ async fn workspace_config_production_invalid_size_save_failure_unknown_are_safe_
);
}
#[tokio::test]
async fn post_commit_observation_mismatch_reports_unknown_without_replaying_write() {
for field in ["digest", "validator"] {
let client = Router::new();
let (feature, runtime) = runtime(client.clone());
feature.attach("initial", None).await.unwrap();
let path = "/workspace-config/main.dcdl";
observe_interface(&runtime, path, true).await;
client.state.lock().unwrap().mismatched_commit_reply = Some(field);
let error = call(&runtime, path, "write", json!({"content":"saved"}))
.await
.unwrap_err()
.to_string();
assert!(error.contains("unknown"), "{field}: {error}");
let state = client.state.lock().unwrap();
assert_eq!(state.commits.len(), 1);
assert_eq!(state.entries["main.dcdl"].1, "saved");
}
}
#[tokio::test]
async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() {
let client = Router::new();
@@ -876,7 +915,6 @@ async fn workspace_config_edit_shared_argument_rejection_never_commits() {
assert!(error.to_string().contains(expected), "{error:?}");
let state = client.state.lock().unwrap();
assert!(state.commits.is_empty());
assert_eq!(state.revision, 1);
assert_eq!(state.entries["main.dcdl"].1, "model = old");
// Argument-only rejection must not even fetch a preimage.
assert!(
+3 -5
View File
@@ -22,8 +22,7 @@ pub(crate) enum CommittedRunExit {
pub(crate) struct CommittedSessionCapture {
pub(crate) session_id: String,
pub(crate) segment_id: String,
/// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
pub(crate) run_exit: CommittedRunExit,
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
@@ -35,8 +34,7 @@ pub(crate) struct CommittedSessionCapture {
pub(crate) struct CommittedSessionLocation {
pub(crate) session_id: String,
pub(crate) segment_id: String,
/// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
}
@@ -45,7 +43,7 @@ impl CommittedSessionCapture {
CommittedSessionLocation {
session_id: self.session_id.clone(),
segment_id: self.segment_id.clone(),
session_revision: self.session_revision,
entry_count: self.entry_count,
}
}
+10 -6
View File
@@ -282,16 +282,20 @@ impl SystemItemAppendHandle {
.push(item);
}
/// Queue a revisioned Subject behavior refresh. The caller receives this
/// Queue a Subject behavior refresh with its exact represented behavior. The caller receives this
/// narrow operation only at a pre-request boundary, and the host commits it
/// before exposing it to the model.
pub fn append_subject_behavior_refresh(&self, body: impl Into<String>, behavior_revision: u64) {
pub fn append_subject_behavior_refresh(
&self,
body: impl Into<String>,
behavior_md: impl Into<String>,
) {
self.pending
.lock()
.expect("system-item append queue poisoned")
.push(SystemItem::SubjectBehaviorRefresh {
body: body.into(),
behavior_revision,
behavior_md: behavior_md.into(),
prompt_provenance: None,
});
}
@@ -511,7 +515,7 @@ pub struct HookInvocationContext {
pub workspace_id: Option<String>,
pub worker_id: String,
pub session_id: String,
pub session_revision: u64,
pub run_id: Option<String>,
pub turn_index: Option<usize>,
pub call_id: Option<String>,
@@ -990,7 +994,7 @@ mod tests {
let handle = RestoreSystemItemAppendHandle::new(Arc::clone(&pending));
let provenance = session_store::PromptRenderProvenance {
workspace_id: Some("workspace".into()),
config_revision: 1,
source_digest: "source".into(),
projection_digest: "projection".into(),
logical_name: "internal.resident_memory_restore_section".into(),
@@ -1081,7 +1085,7 @@ mod tests {
workspace_id: Some("workspace".into()),
worker_id: "worker".into(),
session_id: "session".into(),
session_revision: 4,
run_id: None,
turn_index: None,
call_id: None,
+47 -19
View File
@@ -210,11 +210,8 @@ impl WorkerInterceptor {
let Some(source) = self.resident_context_source.as_ref() else {
return;
};
if let SystemItem::SubjectBehaviorRefresh {
behavior_revision, ..
} = item
{
source.confirm_resident_context_revision(*behavior_revision);
if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item {
source.confirm_resident_context_behavior(behavior_md);
}
}
@@ -373,7 +370,7 @@ impl WorkerInterceptor {
let projection = prompts.projection();
let provenance = |logical_name: &str| session_store::PromptRenderProvenance {
workspace_id: self.prompt_workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: logical_name.to_string(),
@@ -487,7 +484,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
let projection = prompts.projection();
let provenance = session_store::PromptRenderProvenance {
workspace_id: self.prompt_workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: "internal.notify_wrapper".to_string(),
@@ -954,13 +951,13 @@ mod tests {
input
.system_items()
.expect("test interceptor has durable append authority")
.append_subject_behavior_refresh("current Subject context", 7);
.append_subject_behavior_refresh("current Subject context", "Ask when uncertain.");
Ok(HookPreRequestAction::Continue)
}
}
struct ConfirmRecordingResidentSource {
confirmed: Arc<Mutex<Vec<u64>>>,
confirmed: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
@@ -969,8 +966,8 @@ mod tests {
SystemPromptContribution::Unavailable
}
fn confirm_resident_context_revision(&self, revision: u64) {
self.confirmed.lock().unwrap().push(revision);
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
self.confirmed.lock().unwrap().push(behavior_md.to_owned());
}
}
@@ -1478,7 +1475,7 @@ mod tests {
}
#[tokio::test]
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
async fn subject_behavior_is_confirmed_only_after_durable_commit() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let committed = Arc::new(Mutex::new(Vec::new()));
@@ -1511,19 +1508,49 @@ mod tests {
.unwrap();
assert!(matches!(action, PreRequestAction::ContinueWith(_)));
assert_eq!(*confirmed.lock().unwrap(), [7]);
assert_eq!(*confirmed.lock().unwrap(), ["Ask when uncertain."]);
assert!(matches!(
committed.lock().unwrap().as_slice(),
[SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
behavior_md,
prompt_provenance: Some(_),
..
}]
}] if behavior_md == "Ask when uncertain."
));
}
#[test]
fn behavior_ack_uses_raw_content_including_clear_not_rendered_context() {
let committed = Arc::new(Mutex::new(Vec::new()));
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(HookRegistryBuilder::new().build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
)
.with_resident_context_source(Some(source));
let items = ["raw behavior", ""].map(|behavior_md| SystemItem::SubjectBehaviorRefresh {
body: "same rendered context".into(),
behavior_md: behavior_md.into(),
prompt_provenance: None,
});
interceptor.commit_system_items(&items).unwrap();
assert_eq!(*confirmed.lock().unwrap(), ["raw behavior", ""]);
assert_eq!(committed.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn failed_subject_behavior_persistence_does_not_confirm_revision() {
async fn failed_subject_behavior_persistence_does_not_confirm_behavior() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let confirmed = Arc::new(Mutex::new(Vec::new()));
@@ -1897,7 +1924,6 @@ mod tests {
);
let mut projection = crate::prompt::catalog::EffectivePromptCatalog::new(
templates,
2,
projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(),
)
@@ -1926,7 +1952,10 @@ mod tests {
panic!("notification Prompt provenance was not committed");
};
assert_eq!(provenance.workspace_id.as_deref(), Some("workspace-a"));
assert_eq!(provenance.config_revision, 2);
assert_eq!(
provenance.projection_digest,
prompts.load().projection().catalog_digest
);
assert_eq!(provenance.source_digest, "source-2");
assert_eq!(provenance.logical_name, "internal.notify_wrapper");
}
@@ -1953,7 +1982,6 @@ mod tests {
);
let mut projection = crate::prompt::catalog::EffectivePromptCatalog::new(
templates,
3,
projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(),
)
+4 -4
View File
@@ -14,7 +14,7 @@ use crate::feature::{
};
pub const SUBMIT_JOB_RESULT_TOOL: &str = "SubmitJobResult";
const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input revision are immutable Host bindings. Only result is accepted as input; final prose is not success.";
const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input digest are immutable Host bindings. Only result is accepted as input; final prose is not success.";
/// Host acceptance authority. Implementations must validate the immutable fences and commit
/// acceptance durably before returning Ok. Err may mean an unknown outcome; identical retries
@@ -64,7 +64,7 @@ struct SubmissionState {
pub struct JobResultFeature {
job_id: String,
attempt_id: String,
input_revision: String,
input_digest: String,
max_result_bytes: u32,
sink: Arc<dyn JobResultSink>,
state: Arc<Mutex<SubmissionState>>,
@@ -86,7 +86,7 @@ impl JobResultFeature {
Ok(Self {
job_id: request.job_id.clone(),
attempt_id,
input_revision: request.input_revision.clone(),
input_digest: request.input_digest()?,
max_result_bytes: request.limits.max_result_bytes,
sink,
state: Arc::default(),
@@ -216,7 +216,7 @@ impl Tool for JobResultFeature {
let submission = ::job::JobResultSubmission {
job_id: self.job_id.clone(),
attempt_id: self.attempt_id.clone(),
input_revision: self.input_revision.clone(),
input_digest: self.input_digest.clone(),
result: prepared,
};
let mut accepted = self.accepted.lock().expect("Job result state poisoned");
+5 -3
View File
@@ -94,7 +94,6 @@ fn request(profile: &str) -> ::job::JobRequest {
::job::JobRequest {
job_id: "job-a".into(),
purpose: "scripted".into(),
input_revision: "rev-1".into(),
input_ref: "snapshot:one".into(),
input: json!({"text":"input"}),
instruction: "Summarize the input".into(),
@@ -187,7 +186,7 @@ async fn executes_selected_profiles_with_only_result_authority_and_bounded_polic
::job::JobResultSubmission {
job_id: "job-a".into(),
attempt_id: "attempt-7".into(),
input_revision: "rev-1".into(),
input_digest: request("a").input_digest().unwrap(),
result: json!({"ok":true})
}
);
@@ -266,7 +265,10 @@ async fn selected_builtin_job_executes_with_catalog_contract_and_only_result_too
assert!(rx.await.unwrap().is_closed());
assert_eq!(result.submission.job_id, request.job_id);
assert_eq!(result.submission.attempt_id, "builtin-attempt");
assert_eq!(result.submission.input_revision, request.input_revision);
assert_eq!(
result.submission.input_digest,
request.input_digest().unwrap()
);
assert_eq!(result.submission.result, json!({"ok":true}));
assert_eq!(sink.0.lock().unwrap().len(), 1);
let requests = client.requests.lock().unwrap();
+17 -23
View File
@@ -30,7 +30,7 @@ const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EffectivePromptCatalog {
pub templates: BTreeMap<String, String>,
pub config_revision: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source_digest: String,
pub schema_fingerprint: String,
@@ -41,7 +41,7 @@ pub struct EffectivePromptCatalog {
impl EffectivePromptCatalog {
pub fn new(
templates: BTreeMap<String, String>,
config_revision: u64,
schema_fingerprint: impl Into<String>,
toolchain_fingerprint: impl Into<String>,
) -> Result<Self, CatalogError> {
@@ -49,7 +49,7 @@ impl EffectivePromptCatalog {
let catalog_digest = catalog_digest(&templates)?;
Ok(Self {
templates,
config_revision,
source_digest: String::new(),
schema_fingerprint: schema_fingerprint.into(),
toolchain_fingerprint: toolchain_fingerprint.into(),
@@ -59,7 +59,7 @@ impl EffectivePromptCatalog {
pub fn from_projection(
prompts: &serde_json::Value,
config_revision: u64,
schema_fingerprint: impl Into<String>,
toolchain_fingerprint: impl Into<String>,
) -> Result<Self, CatalogError> {
@@ -68,12 +68,7 @@ impl EffectivePromptCatalog {
if let Some(default_prompt) = templates.remove("default_prompt") {
templates.insert("default".to_string(), default_prompt);
}
Self::new(
templates,
config_revision,
schema_fingerprint,
toolchain_fingerprint,
)
Self::new(templates, schema_fingerprint, toolchain_fingerprint)
}
pub fn verify_digest(&self) -> Result<(), CatalogError> {
@@ -279,7 +274,7 @@ pub enum CatalogError {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspacePromptProjection {
pub workspace_id: String,
pub config_revision: u64,
pub source_digest: String,
pub projection_digest: String,
pub schema_fingerprint: String,
@@ -325,7 +320,7 @@ impl WorkspacePromptProjection {
}
Ok(Self {
workspace_id,
config_revision: catalog.config_revision,
source_digest,
projection_digest,
schema_fingerprint: catalog.schema_fingerprint.clone(),
@@ -345,8 +340,7 @@ impl WorkspacePromptProjection {
self.projection_digest.clone(),
self.catalog.clone(),
)?;
if rebuilt.config_revision != self.config_revision
|| rebuilt.schema_fingerprint != self.schema_fingerprint
if rebuilt.schema_fingerprint != self.schema_fingerprint
|| rebuilt.toolchain_fingerprint != self.toolchain_fingerprint
{
return Err(CatalogError::InvalidTemplateCatalog(
@@ -365,7 +359,6 @@ pub struct PromptCatalog {
impl std::fmt::Debug for PromptCatalog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PromptCatalog")
.field("config_revision", &self.projection.config_revision)
.field("catalog_digest", &self.projection.catalog_digest)
.finish_non_exhaustive()
}
@@ -376,7 +369,6 @@ impl PromptCatalog {
let templates = builtin_prompt_templates()?;
let projection = EffectivePromptCatalog::new(
templates,
0,
BUILTIN_TOOLCHAIN_FINGERPRINT,
BUILTIN_TOOLCHAIN_FINGERPRINT,
)?;
@@ -580,7 +572,7 @@ pub fn prompt_schema_source() -> Result<String, CatalogError> {
pub fn builtin_prompt_templates() -> Result<BTreeMap<String, String>, CatalogError> {
let mut entries = Vec::new();
collect_builtin_entries(&BUILTIN_PROMPT_SOURCES, "", &mut entries)?;
let snapshot = ConfigTreeSnapshot::from_entries(0, entries)
let snapshot = ConfigTreeSnapshot::from_entries(entries)
.map_err(|error| CatalogError::BuiltinTree(error.to_string()))?;
let entry = VirtualPath::parse(BUILTIN_CATALOG_ENTRY)
.map_err(|error| CatalogError::BuiltinTree(error.to_string()))?;
@@ -825,7 +817,7 @@ mod tests {
assert!(common.contains("normal non-force push"));
assert!(common.contains("Moving only the target ref does not invalidate approval"));
assert!(common.contains("take precedence over stale Memory"));
assert!(coder.contains("Never invent an add-revision operation"));
assert!(coder.contains("Never invent an operation to append another source candidate"));
assert!(orchestrator.contains("Target-only movement preserves source approval"));
assert!(reviewer.contains("target-only movement does not invalidate approval"));
}
@@ -913,7 +905,7 @@ mod tests {
("first".to_string(), "FIRST".to_string()),
("second".to_string(), "SECOND".to_string()),
]);
let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap();
let projection = EffectivePromptCatalog::new(templates, "schema", "toolchain").unwrap();
projection.verify_digest().unwrap();
let mut tampered = projection.clone();
tampered
@@ -929,7 +921,6 @@ mod tests {
fn workspace_prompt_projection_round_trips_and_rejects_tampered_metadata() {
let catalog = EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "PROMPT".to_string())]),
8,
"schema",
"toolchain",
)
@@ -947,7 +938,7 @@ mod tests {
restored.validate().unwrap();
let mut tampered = restored;
tampered.config_revision += 1;
tampered.schema_fingerprint = "tampered-schema".into();
assert!(tampered.validate().is_err());
}
@@ -955,11 +946,14 @@ mod tests {
fn catalog_source_preserves_workspace_projection_for_subworkers() {
let templates = BTreeMap::from([("template".to_string(), "OVERRIDE".to_string())]);
let catalog = PromptCatalog::from_projection(
EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(),
EffectivePromptCatalog::new(templates, "schema", "toolchain").unwrap(),
)
.unwrap();
let child = PromptCatalog::load(&catalog.source()).unwrap();
assert_eq!(child.projection.config_revision, 9);
assert_eq!(
child.projection.catalog_digest,
catalog.projection.catalog_digest
);
assert_eq!(child.projection.templates["template"], "OVERRIDE");
}
+1 -1
View File
@@ -832,7 +832,7 @@ mod tests {
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "coder-review".into(),
definition_revision: 3,
definition_digest: Some("sha256:flow-source".into()),
instance_id: "instance".into(),
state_id: "implement".into(),
},
+1 -1
View File
@@ -153,7 +153,7 @@ mod tests {
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".to_string(),
definition_id: "coder-review".to_string(),
definition_revision: 7,
definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".to_string(),
state_id: "implement".to_string(),
},
-2
View File
@@ -111,7 +111,6 @@ mod tests {
let body = serde_json::json!({
"authority": "workspace-config-skills-v1",
"projection": {
"config_revision": 7,
"tree_digest": "tree-digest"
},
"entries": [{
@@ -123,7 +122,6 @@ mod tests {
"kind": "workspace",
"id": "workspace:triage-errors",
"virtual_path": "skills/triage-errors/SKILL.md",
"revision": 7,
"source_digest": "source-digest",
"tree_digest": "tree-digest"
},
+1
View File
@@ -1,3 +1,4 @@
pub mod comm_tools;
mod published;
pub mod registry;
pub mod tool;
+408
View File
@@ -0,0 +1,408 @@
//! Reconnect projection derived only from the parent's published child stream.
//!
//! Records are prepared before the first child Run. Their initial projection and
//! receiver are captured at that boundary, not when forwarding eventually starts.
//! Later source reads may resolve one immutable committed entry by ID, but never
//! replace live state with the child's (possibly unpublished) current state.
use crate::internal_worker::InternalWorkerSessionHandle;
use protocol::{Event, InFlightBlock, InternalWorkerSnapshot, SessionSnapshotEntryData as Data};
use std::sync::Mutex;
use tokio::sync::broadcast;
pub(super) struct PublishedProtocol {
pub snapshot: Mutex<InternalWorkerSnapshot>,
pub events: Mutex<Option<broadcast::Receiver<Event>>>,
}
impl PublishedProtocol {
/// The host registers a prepared Idle session before its first Run.
pub fn new(source: &InternalWorkerSessionHandle, worker: protocol::InternalWorkerRef) -> Self {
let events = source.subscribe_events();
let initial = source.protocol_snapshot();
Self {
snapshot: Mutex::new(InternalWorkerSnapshot {
worker,
session: initial.session,
greeting: initial.greeting,
status: initial.status,
error: initial.error,
in_flight: initial.in_flight,
internal_workers: initial.internal_workers,
}),
events: Mutex::new(Some(events)),
}
}
}
pub(super) fn apply(
snapshot: &mut InternalWorkerSnapshot,
event: &Event,
source: &InternalWorkerSessionHandle,
) {
match event {
Event::Snapshot {
session,
greeting,
state,
in_flight,
internal_workers,
} => {
snapshot.session = session.clone();
snapshot.greeting = Some(greeting.clone());
snapshot.status = state.catalog_status();
snapshot.in_flight = in_flight.clone();
snapshot.internal_workers = internal_workers.clone();
}
Event::WorkerState { snapshot: state } => {
snapshot.status = state.catalog_status();
if snapshot.status != protocol::WorkerStatus::Running {
snapshot.in_flight = Default::default();
} else {
snapshot.error = None;
}
}
Event::Shutdown => {
snapshot.status = protocol::WorkerStatus::Stopped;
snapshot.in_flight = Default::default();
}
Event::Error { message, .. } => snapshot.error = Some(message.clone()),
Event::ContextUsage { usage } => {
if let Some(greeting) = &mut snapshot.greeting {
greeting.context_usage = *usage;
greeting.context_tokens = usage.map_or(0, |usage| usage.tokens);
}
}
Event::PendingSubmissionsChanged { pending } => {
snapshot.session.pending_submissions = pending.clone()
}
Event::SegmentRotated { session } => {
snapshot.session = session.clone();
snapshot.in_flight = Default::default();
}
Event::SessionEntryCommitted { entry } => {
match &entry.data {
Data::ToolCall { call_id, .. } => snapshot.in_flight.blocks.retain(
|block| !matches!(block, InFlightBlock::ToolCall { id, .. } if id == call_id),
),
Data::Message {
role: protocol::SessionMessageRole::Assistant,
content,
} => {
let body = content
.iter()
.map(|part| match part {
protocol::SessionContentPart::Text { text } => text.as_str(),
protocol::SessionContentPart::Refusal { refusal } => refusal.as_str(),
})
.collect::<String>();
if let Some(index) = snapshot.in_flight.blocks.iter().position(
|block| matches!(block, InFlightBlock::Text { text, .. } if text == &body),
) {
snapshot.in_flight.blocks.remove(index);
}
}
_ => {}
}
if let Some(existing) = snapshot
.session
.entries
.iter_mut()
.find(|old| old.entry_id == entry.entry_id)
{
*existing = entry.clone();
} else {
snapshot.session.entries.push(entry.clone());
}
}
Event::UserMessage {
entry_id: Some(id), ..
}
| Event::SystemItem {
entry_id: Some(id), ..
} => {
// Resolve only this committed identity. Reading the entire current
// source projection here would admit future/buffered history.
if !snapshot
.session
.entries
.iter()
.any(|entry| &entry.entry_id == id)
{
if let Some(entry) = committed_entries(source, &snapshot.worker.session_id)
.into_iter()
.find(|entry| &entry.entry_id == id)
{
snapshot.session.entries.push(entry);
}
}
}
Event::InvokeStart {
timestamp_ms: Some(ts),
..
}
| Event::Usage {
timestamp_ms: Some(ts),
..
} => {
if let Some(entry) = committed_entries(source, &snapshot.worker.session_id)
.into_iter()
.find(|entry| {
entry.timestamp == *ts
&& !snapshot
.session
.entries
.iter()
.any(|old| old.entry_id == entry.entry_id)
&& match (&entry.data, event) {
(Data::Invoke { trigger }, Event::InvokeStart { kind, .. }) => {
trigger == kind
}
(
Data::Usage {
input_tokens,
cache_read_input_tokens,
output_tokens,
},
Event::Usage {
input_tokens: input,
cache_read_input_tokens: cache,
output_tokens: output,
..
},
) => {
*input_tokens == input.unwrap_or_default()
&& *cache_read_input_tokens == cache.unwrap_or_default()
&& *output_tokens == output.unwrap_or_default()
}
_ => false,
}
})
{
snapshot.session.entries.push(entry);
}
}
Event::TextDelta { text } => match snapshot.in_flight.blocks.last_mut() {
Some(InFlightBlock::Text {
text: body,
finished: false,
}) => body.push_str(text),
_ => snapshot.in_flight.blocks.push(InFlightBlock::Text {
text: text.clone(),
finished: false,
}),
},
Event::TextDone { text } => {
if let Some(InFlightBlock::Text {
text: body,
finished,
}) = snapshot.in_flight.blocks.last_mut()
{
*body = text.clone();
*finished = true;
} else {
snapshot.in_flight.blocks.push(InFlightBlock::Text {
text: text.clone(),
finished: true,
});
}
}
Event::ThinkingStart => snapshot.in_flight.blocks.push(InFlightBlock::Thinking {
text: String::new(),
finished: false,
}),
Event::ThinkingDelta { text } => {
if let Some(InFlightBlock::Thinking { text: body, .. }) =
snapshot.in_flight.blocks.last_mut()
{
body.push_str(text);
}
}
Event::ThinkingDone { text } => {
if let Some(InFlightBlock::Thinking {
text: body,
finished,
}) = snapshot.in_flight.blocks.last_mut()
{
*body = text.clone();
*finished = true;
}
}
Event::ToolCallStart { id, name } => {
snapshot.in_flight.blocks.push(InFlightBlock::ToolCall {
id: id.clone(),
name: name.clone(),
args: String::new(),
state: protocol::InFlightToolCallState::Pending,
})
}
Event::ToolCallArgsDelta { id, json } => if let Some(InFlightBlock::ToolCall {
args,
state,
..
}) = snapshot.in_flight.blocks.iter_mut().find(
|block| matches!(block, InFlightBlock::ToolCall { id: current, .. } if current == id),
) {
args.push_str(json);
*state = protocol::InFlightToolCallState::StreamingArgs;
},
Event::ToolCallDone {
id,
name,
arguments,
} => if let Some(InFlightBlock::ToolCall {
args,
name: current_name,
state,
..
}) = snapshot.in_flight.blocks.iter_mut().find(
|block| matches!(block, InFlightBlock::ToolCall { id: current, .. } if current == id),
) {
*args = arguments.clone();
*current_name = name.clone();
*state = protocol::InFlightToolCallState::Done;
},
Event::RunEnd { .. } => snapshot.in_flight = Default::default(),
Event::CompactionProgress { compaction } => {
snapshot.in_flight.compaction = compaction.clone()
}
Event::Command { event } => apply_command(&mut snapshot.in_flight.commands, event),
Event::InternalWorker { worker, event } => {
if let Some(child) = snapshot
.internal_workers
.iter_mut()
.find(|child| child.worker.session_id == worker.session_id)
{
apply(child, event, source);
} else if let Event::Snapshot {
session,
greeting,
state,
in_flight,
internal_workers,
} = event.as_ref()
{
snapshot.internal_workers.push(InternalWorkerSnapshot {
worker: worker.clone(),
session: session.clone(),
greeting: Some(greeting.clone()),
status: state.catalog_status(),
error: None,
in_flight: in_flight.clone(),
internal_workers: internal_workers.clone(),
});
}
}
Event::InternalWorkerRemoved { worker } => snapshot
.internal_workers
.retain(|child| child.worker.session_id != worker.session_id),
_ => {}
}
}
fn committed_entries(
source: &InternalWorkerSessionHandle,
session_id: &str,
) -> Vec<protocol::SessionSnapshotEntry> {
fn find(
children: &[InternalWorkerSnapshot],
id: &str,
) -> Option<Vec<protocol::SessionSnapshotEntry>> {
for child in children {
if child.worker.session_id == id {
return Some(child.session.entries.clone());
}
if let Some(entries) = find(&child.internal_workers, id) {
return Some(entries);
}
}
None
}
let projection = source.protocol_snapshot();
if source.session_id_string() == session_id {
let mut entries = projection.session.entries;
// A buffered pre-rotation identity may no longer be in the current
// segment projection. Resolve it from the retained immutable log too.
for entry in
session_store::public_snapshot::project_current_session_snapshot(&source.entries())
.entries
{
if !entries
.iter()
.any(|current| current.entry_id == entry.entry_id)
{
entries.push(entry);
}
}
entries
} else {
find(&projection.internal_workers, session_id).unwrap_or_default()
}
}
fn apply_command(commands: &mut Vec<protocol::CommandSnapshot>, event: &protocol::CommandEvent) {
use protocol::{
CommandEvent, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice,
};
match event {
CommandEvent::Started {
command_id,
tool_call_id,
observed_at_ms,
} => {
commands.retain(|command| &command.command_id != command_id);
commands.push(CommandSnapshot {
command_id: command_id.clone(),
tool_call_id: tool_call_id.clone(),
status: CommandStatus::Running,
started_at_ms: *observed_at_ms,
observed_at_ms: *observed_at_ms,
last_output_at_ms: None,
stdout: CommandStreamSlice::default(),
stderr: CommandStreamSlice::default(),
exit_code: None,
});
}
CommandEvent::Output {
command_id,
stream,
start_offset,
end_offset,
content,
observed_at_ms,
} => {
if let Some(command) = commands
.iter_mut()
.find(|command| &command.command_id == command_id)
{
command.observed_at_ms = *observed_at_ms;
command.last_output_at_ms = Some(*observed_at_ms);
let target = match stream {
CommandStream::Stdout => &mut command.stdout,
CommandStream::Stderr => &mut command.stderr,
};
if target.end_offset != *start_offset {
target.content.clear();
target.start_offset = *start_offset;
target.truncated = *start_offset > 0;
}
target.content.push_str(content);
target.end_offset = *end_offset;
if target.content.len() > 32 * 1024 {
let mut cut = target.content.len() - 32 * 1024;
while !target.content.is_char_boundary(cut) {
cut += 1;
}
target.content.drain(..cut);
target.start_offset = target
.end_offset
.saturating_sub(target.content.len() as u64);
target.truncated = true;
}
}
}
CommandEvent::Terminal { command_id, .. } => {
commands.retain(|command| &command.command_id != command_id)
}
}
}
+372 -114
View File
@@ -14,7 +14,7 @@ use std::collections::{BTreeMap, HashSet};
use std::io;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::Instant;
@@ -29,9 +29,7 @@ use tokio::sync::{Notify, broadcast};
use tracing::warn;
use workdir::WorkdirScopeLeaseSet;
use crate::internal_worker::{
InternalWorkerSessionHandle, InternalWorkerSessionSnapshot, InternalWorkerVisibility,
};
use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility};
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use crate::runtime::worker_allocation;
@@ -81,10 +79,9 @@ pub(crate) struct InternalSpawnedWorkerRecord {
started_at: Instant,
stop_lock: Arc<tokio::sync::Mutex<()>>,
scope_reclaimed: Arc<AtomicBool>,
protocol_revision: Arc<AtomicU64>,
protocol_emit_lock: Arc<Mutex<()>>,
protocol_terminal: Arc<AtomicBool>,
forwarding_started: Arc<AtomicBool>,
published: Arc<super::published::PublishedProtocol>,
}
impl InternalSpawnedWorkerRecord {
@@ -98,19 +95,26 @@ impl InternalSpawnedWorkerRecord {
change_tracker: Option<tools::Tracker>,
) -> Self {
Self {
worker_name,
worker_name: worker_name.clone(),
scope_delegated,
workdir_tool_scope: Arc::new(workdir_tool_scope),
#[cfg(test)]
installed_tools: installed_tools.into(),
published: Arc::new(super::published::PublishedProtocol::new(
&session,
InternalWorkerRef {
session_id: session.session_id_string(),
name: worker_name.clone(),
parent_session_id: None,
kind: InternalWorkerKind::SubWorker,
},
)),
session,
child_registry,
change_tracker,
started_at: Instant::now(),
stop_lock: Arc::new(tokio::sync::Mutex::new(())),
scope_reclaimed: Arc::new(AtomicBool::new(false)),
protocol_revision: Arc::new(AtomicU64::new(0)),
protocol_emit_lock: Arc::new(Mutex::new(())),
protocol_terminal: Arc::new(AtomicBool::new(false)),
forwarding_started: Arc::new(AtomicBool::new(false)),
}
@@ -183,10 +187,6 @@ impl InternalSpawnedWorkerRecord {
kind: InternalWorkerKind::SubWorker,
}
}
fn protocol_revision(&self) -> u64 {
self.protocol_revision.load(Ordering::Acquire)
}
}
/// Parent-visible service Internal Worker. Unlike a SubWorker this record has no
@@ -196,8 +196,7 @@ pub(crate) struct InternalServiceWorkerRecord {
pub service_kind: String,
pub display_name: String,
pub session: InternalWorkerSessionHandle,
protocol_revision: Arc<AtomicU64>,
protocol_emit_lock: Arc<Mutex<()>>,
published: Arc<super::published::PublishedProtocol>,
protocol_terminal: Arc<AtomicBool>,
forwarding_started: Arc<AtomicBool>,
}
@@ -208,12 +207,23 @@ impl InternalServiceWorkerRecord {
display_name: impl Into<String>,
session: InternalWorkerSessionHandle,
) -> Self {
let service_kind = service_kind.into();
let display_name = display_name.into();
Self {
service_kind: service_kind.into(),
display_name: display_name.into(),
published: Arc::new(super::published::PublishedProtocol::new(
&session,
InternalWorkerRef {
session_id: session.session_id_string(),
name: display_name.clone(),
parent_session_id: None,
kind: InternalWorkerKind::Service {
kind: service_kind.clone(),
},
},
)),
service_kind,
display_name,
session,
protocol_revision: Arc::new(AtomicU64::new(0)),
protocol_emit_lock: Arc::new(Mutex::new(())),
protocol_terminal: Arc::new(AtomicBool::new(false)),
forwarding_started: Arc::new(AtomicBool::new(false)),
}
@@ -229,10 +239,6 @@ impl InternalServiceWorkerRecord {
},
}
}
fn protocol_revision(&self) -> u64 {
self.protocol_revision.load(Ordering::Acquire)
}
}
pub(crate) struct InternalSpawnReservation {
@@ -324,6 +330,7 @@ pub struct SpawnedWorkerRegistry {
internal_spawn_cleanup_failed: AtomicBool,
parent_scope: Option<SharedScope>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
protocol_publish_lock: Arc<Mutex<()>>,
}
pub struct SpawnedWorkerRegistryLoad {
@@ -346,6 +353,7 @@ impl SpawnedWorkerRegistry {
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: None,
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
})
}
@@ -363,6 +371,7 @@ impl SpawnedWorkerRegistry {
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: None,
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
})
}
@@ -379,6 +388,7 @@ impl SpawnedWorkerRegistry {
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: Some(parent_scope),
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
})
}
@@ -464,6 +474,7 @@ impl SpawnedWorkerRegistry {
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope,
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
}),
reclaimed_unreachable: !persisted_children.is_empty(),
})
@@ -503,6 +514,13 @@ impl SpawnedWorkerRegistry {
})
}
/// Parent snapshot capture/subscription and child publication share this gate.
pub(crate) fn protocol_snapshot_publish_guard(&self) -> std::sync::MutexGuard<'_, ()> {
self.protocol_publish_lock
.lock()
.unwrap_or_else(|error| error.into_inner())
}
pub(crate) fn attach_parent_protocol(
&self,
working_event_tx: broadcast::Sender<Event>,
@@ -616,6 +634,7 @@ impl SpawnedWorkerRegistry {
/// Remove one service Worker and emit the terminal projection fence.
pub(crate) fn remove_service(&self, session_id: &str) -> io::Result<bool> {
let publication_guard = self.protocol_snapshot_publish_guard();
let removed = {
let mut records = self
.service_records
@@ -627,7 +646,7 @@ impl SpawnedWorkerRegistry {
.map(|index| records.remove(index))
};
if let Some(record) = removed {
self.publish_service_removal(&record);
self.publish_service_removal(&publication_guard, &record);
Ok(true)
} else {
Ok(false)
@@ -646,17 +665,23 @@ impl SpawnedWorkerRegistry {
return;
};
let worker = record.protocol_ref(Some(parent_session_id));
let protocol_revision = record.protocol_revision.clone();
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_emit_lock = self.protocol_publish_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events();
let mut child_rx = record
.published
.events
.lock()
.unwrap()
.take()
.expect("forwarding starts once");
let published = record.published.clone();
let source = record.session.clone();
publish_initial_internal_snapshot(
&parent_tx,
&worker,
&protocol_revision,
&protocol_emit_lock,
&protocol_terminal,
&record.session,
&published,
);
tokio::spawn(async move {
loop {
@@ -669,10 +694,13 @@ impl SpawnedWorkerRegistry {
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
super::published::apply(
&mut published.snapshot.lock().unwrap(),
&event,
&source,
);
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(event),
});
if shutdown {
@@ -686,17 +714,20 @@ impl SpawnedWorkerRegistry {
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let error = format!(
"internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session"
);
published.snapshot.lock().unwrap().error = Some(error);
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(Event::Error {
code: protocol::ErrorCode::Internal,
message: format!(
"internal Worker output lagged by {skipped} events; reconnect to resynchronize"
"internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session"
),
}),
});
break;
}
Err(broadcast::error::RecvError::Closed) => break,
}
@@ -716,17 +747,23 @@ impl SpawnedWorkerRegistry {
return;
};
let worker = record.protocol_ref(Some(parent_session_id));
let protocol_revision = record.protocol_revision.clone();
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_emit_lock = self.protocol_publish_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events();
let mut child_rx = record
.published
.events
.lock()
.unwrap()
.take()
.expect("forwarding starts once");
let published = record.published.clone();
let source = record.session.clone();
publish_initial_internal_snapshot(
&parent_tx,
&worker,
&protocol_revision,
&protocol_emit_lock,
&protocol_terminal,
&record.session,
&published,
);
tokio::spawn(async move {
loop {
@@ -739,10 +776,13 @@ impl SpawnedWorkerRegistry {
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
super::published::apply(
&mut published.snapshot.lock().unwrap(),
&event,
&source,
);
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(event),
});
if shutdown {
@@ -756,17 +796,20 @@ impl SpawnedWorkerRegistry {
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let error = format!(
"internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session"
);
published.snapshot.lock().unwrap().error = Some(error);
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(Event::Error {
code: protocol::ErrorCode::Internal,
message: format!(
"internal Worker output lagged by {skipped} events; reconnect to resynchronize"
"internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session"
),
}),
});
break;
}
Err(broadcast::error::RecvError::Closed) => break,
}
@@ -823,11 +866,9 @@ impl SpawnedWorkerRegistry {
.iter()
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
.map(|record| {
internal_worker_snapshot(
record.protocol_ref(parent_session_id.clone()),
record.protocol_revision(),
&record.session,
)
let mut snapshot = record.published.snapshot.lock().unwrap().clone();
snapshot.worker.parent_session_id = parent_session_id.clone();
snapshot
})
.collect::<Vec<_>>();
snapshots.extend(
@@ -839,11 +880,9 @@ impl SpawnedWorkerRegistry {
record.session.visibility() == InternalWorkerVisibility::ParentClient
})
.map(|record| {
internal_worker_snapshot(
record.protocol_ref(parent_session_id.clone()),
record.protocol_revision(),
&record.session,
)
let mut snapshot = record.published.snapshot.lock().unwrap().clone();
snapshot.worker.parent_session_id = parent_session_id.clone();
snapshot
}),
);
snapshots
@@ -970,6 +1009,8 @@ impl SpawnedWorkerRegistry {
.map_err(|error| io::Error::other(error.to_string()))?;
let summary = record.stop_summary();
self.reclaim_record_scope(&record)?;
// Membership removal and its terminal event are one publication boundary.
let publication_guard = self.protocol_snapshot_publish_guard();
let removed =
{
let mut records = self.internal_records.lock().map_err(|_| {
@@ -992,12 +1033,16 @@ impl SpawnedWorkerRegistry {
removed
};
if removed.is_some() {
self.publish_internal_removal(&record);
self.publish_internal_removal(&publication_guard, &record);
}
Ok(removed.map(|_| summary))
}
fn publish_internal_removal(&self, record: &InternalSpawnedWorkerRecord) {
fn publish_internal_removal(
&self,
_guard: &std::sync::MutexGuard<'_, ()>,
record: &InternalSpawnedWorkerRecord,
) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
return;
}
@@ -1005,19 +1050,17 @@ impl SpawnedWorkerRegistry {
else {
return;
};
let _emit_guard = record
.protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
record.protocol_terminal.store(true, Ordering::Release);
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorkerRemoved {
worker: record.protocol_ref(Some(parent_session_id)),
revision,
});
}
fn publish_service_removal(&self, record: &InternalServiceWorkerRecord) {
fn publish_service_removal(
&self,
_guard: &std::sync::MutexGuard<'_, ()>,
record: &InternalServiceWorkerRecord,
) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
return;
}
@@ -1025,15 +1068,9 @@ impl SpawnedWorkerRegistry {
else {
return;
};
let _emit_guard = record
.protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
record.protocol_terminal.store(true, Ordering::Release);
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorkerRemoved {
worker: record.protocol_ref(Some(parent_session_id)),
revision,
});
}
}
@@ -1041,29 +1078,28 @@ impl SpawnedWorkerRegistry {
fn publish_initial_internal_snapshot(
parent_tx: &broadcast::Sender<Event>,
worker: &InternalWorkerRef,
protocol_revision: &AtomicU64,
protocol_emit_lock: &Mutex<()>,
protocol_terminal: &AtomicBool,
session: &InternalWorkerSessionHandle,
published: &super::published::PublishedProtocol,
) {
let Some(event) = internal_worker_live_snapshot(session.protocol_snapshot()) else {
return;
};
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
return;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let mut snapshot = published.snapshot.lock().unwrap();
snapshot.worker = worker.clone();
let Some(event) = internal_worker_live_snapshot(snapshot.clone()) else {
return;
};
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(event),
});
}
fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Option<Event> {
fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option<Event> {
Some(Event::Snapshot {
session: snapshot.session,
greeting: snapshot.greeting?,
@@ -1073,24 +1109,6 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Opt
})
}
fn internal_worker_snapshot(
worker: InternalWorkerRef,
revision: u64,
session: &InternalWorkerSessionHandle,
) -> InternalWorkerSnapshot {
let snapshot = session.protocol_snapshot();
InternalWorkerSnapshot {
worker,
revision,
session: snapshot.session,
greeting: snapshot.greeting,
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
internal_workers: snapshot.internal_workers,
}
}
impl Drop for SpawnedWorkerRegistry {
fn drop(&mut self) {
let Some(parent_scope) = &self.parent_scope else {
@@ -1389,7 +1407,6 @@ mod tests {
assert!(matches!(
initial,
Event::InternalWorker {
revision: 1,
event,
..
} if matches!(
@@ -1420,7 +1437,6 @@ mod tests {
assert!(matches!(
update,
Event::InternalWorker {
revision: 2,
event,
..
} if matches!(
@@ -1435,6 +1451,243 @@ mod tests {
));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parent_publication_gate_serializes_snapshot_live_and_terminal_events() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(16);
registry.attach_parent_protocol(parent_tx.clone(), "parent-session".into());
let (record, child_tx) = record_with_greeting(
"research",
InternalWorkerVisibility::ParentClient,
Some(protocol::Greeting {
worker_name: "research".into(),
cwd: "/child".into(),
provider: "provider".into(),
model: "child-model".into(),
reasoning: None,
scope_summary: "child scope".into(),
tools: Vec::new(),
context_window: 64_000,
context_tokens: 0,
context_usage: None,
}),
)
.await;
registry
.internal_records
.lock()
.unwrap()
.push(record.clone());
// Capturing before acquiring the gate would publish the obsolete Idle state.
let guard = registry.protocol_snapshot_publish_guard();
let worker_ref = record.protocol_ref(Some("parent-session".into()));
let gate = registry.protocol_publish_lock.clone();
let terminal = record.protocol_terminal.clone();
let published = record.published.clone();
let (started_tx, started_rx) = std::sync::mpsc::channel();
let publisher = std::thread::spawn(move || {
started_tx.send(()).unwrap();
publish_initial_internal_snapshot(
&parent_tx,
&worker_ref,
&gate,
&terminal,
&published,
);
});
started_rx.recv().unwrap();
record
.session
.force_status(InternalWorkerSessionStatus::Running);
assert!(matches!(
parent_rx.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
drop(guard);
publisher.join().unwrap();
let Event::InternalWorker { event, .. } = parent_rx.recv().await.unwrap() else {
panic!("expected child snapshot");
};
let Event::Snapshot { state, .. } = *event else {
panic!("expected snapshot");
};
assert_eq!(state, protocol::WorkerStatus::Idle.into());
registry.start_protocol_forwarding(record.clone());
parent_rx.recv().await.unwrap(); // initial snapshot from forwarding
let guard = registry.protocol_snapshot_publish_guard();
child_tx
.send(Event::TextDone {
text: "live".into(),
})
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(20), parent_rx.recv())
.await
.is_err()
);
drop(guard);
assert!(
matches!(parent_rx.recv().await.unwrap(), Event::InternalWorker { event, .. }
if matches!(*event, Event::TextDone { ref text } if text == "live"))
);
{
let guard = registry.protocol_snapshot_publish_guard();
registry.publish_internal_removal(&guard, &record);
}
assert!(matches!(
parent_rx.recv().await.unwrap(),
Event::InternalWorkerRemoved { .. }
));
child_tx
.send(Event::TextDone {
text: "must not publish".into(),
})
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(20), parent_rx.recv())
.await
.is_err()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reconnect_snapshot_excludes_buffered_child_state_history_and_deltas() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(32);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let (record, child_tx) = record("buffered", InternalWorkerVisibility::ParentClient).await;
registry
.internal_records
.lock()
.unwrap()
.push(record.clone());
registry.start_protocol_forwarding(record.clone());
child_tx
.send(Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
})
.unwrap();
parent_rx.recv().await.unwrap();
let guard = registry.protocol_snapshot_publish_guard();
record.session.emit_test_text_delta("not yet published");
let entry = crate::session_history::test_logged_history_entry(agen::Item::user_message(
"future history",
));
record
.session
.publish_test_entry(LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![protocol::Segment::text("future history")],
history: vec![entry],
extensions: Vec::new(),
});
record
.session
.force_status(InternalWorkerSessionStatus::Idle);
child_tx
.send(Event::WorkerState {
snapshot: protocol::WorkerStatus::Idle.into(),
})
.unwrap();
let snapshot = registry.internal_worker_snapshots().pop().unwrap();
assert_eq!(snapshot.status, protocol::WorkerStatus::Running);
assert!(snapshot.session.entries.is_empty());
assert!(snapshot.in_flight.is_empty());
drop(guard);
// The reconnect baseline is the exact published prefix, not the child's
// newer Idle/history state followed by buffered older output.
let mut replay = snapshot;
for _ in 0..3 {
let Event::InternalWorker { event, .. } =
tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap()
else {
panic!("expected child event");
};
super::super::published::apply(&mut replay, &event, &record.session);
}
let current = registry.internal_worker_snapshots().pop().unwrap();
assert_eq!(replay.status, current.status);
assert_eq!(replay.session, current.session);
assert_eq!(replay.in_flight, current.in_flight);
assert_eq!(current.status, protocol::WorkerStatus::Idle);
assert_eq!(current.session.entries.len(), 1);
}
#[tokio::test]
async fn prepared_projection_does_not_replay_pre_forwarding_deltas_twice() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(16);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let (record, child_tx) = record("early", InternalWorkerVisibility::ParentClient).await;
registry
.internal_records
.lock()
.unwrap()
.push(record.clone());
record
.session
.force_status(InternalWorkerSessionStatus::Running);
child_tx
.send(Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
})
.unwrap();
record.session.emit_test_text_delta("once");
let initial = registry.internal_worker_snapshots().pop().unwrap();
assert_eq!(initial.status, protocol::WorkerStatus::Idle);
assert!(initial.in_flight.is_empty());
registry.start_protocol_forwarding(record.clone());
parent_rx.recv().await.unwrap();
parent_rx.recv().await.unwrap();
let published = registry.internal_worker_snapshots().pop().unwrap();
assert_eq!(published.status, protocol::WorkerStatus::Running);
assert!(matches!(published.in_flight.blocks.as_slice(),
[protocol::InFlightBlock::Text { text, .. }] if text == "once"));
}
#[tokio::test]
async fn lagged_child_stream_reports_incomplete_projection_without_replaying_tail() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(16);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let (record, child_tx) = record("lagged", InternalWorkerVisibility::ParentClient).await;
registry
.internal_records
.lock()
.unwrap()
.push(record.clone());
for _ in 0..300 {
child_tx
.send(Event::TextDelta {
text: "lost prefix".into(),
})
.unwrap();
}
registry.start_protocol_forwarding(record.clone());
let event = parent_rx.recv().await.unwrap();
assert!(matches!(event, Event::InternalWorker { event, .. }
if matches!(*event, Event::Error { ref message, .. } if message.contains("projection is incomplete"))));
let published = registry.internal_worker_snapshots().pop().unwrap();
assert!(
published
.error
.as_deref()
.unwrap()
.contains("stop and recreate")
);
assert!(published.in_flight.is_empty());
assert!(matches!(
parent_rx.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn visible_internal_output_is_wrapped_after_registry_insertion() {
let registry = registry();
@@ -1459,7 +1712,7 @@ mod tests {
.unwrap();
assert!(matches!(
event,
Event::InternalWorker { worker, revision: 1, event }
Event::InternalWorker { worker, event }
if worker.name == "research"
&& worker.parent_session_id.as_deref() == Some("parent-session")
&& matches!(*event, Event::TextDone { ref text } if text == "answer")
@@ -1480,14 +1733,25 @@ mod tests {
.unwrap();
assert!(matches!(
committed,
Event::InternalWorker { revision: 2, event, .. }
Event::InternalWorker { event, .. }
if matches!(*event, Event::UserMessage { .. })
));
let snapshots = registry.internal_worker_snapshots();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].revision, 2);
assert_eq!(snapshots[0].session.entries.len(), 1);
record
.session
.publish_test_entry(LogEntry::AnnotatedAssistantItem {
ts: 2,
entry: crate::session_history::test_logged_history_entry(
agen::Item::assistant_message("answer"),
),
});
let committed = parent_rx.recv().await.unwrap();
assert!(matches!(committed, Event::InternalWorker { event, .. }
if matches!(*event, Event::SessionEntryCommitted { .. })));
record.session.emit_test_text_delta("partial");
let streamed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
@@ -1495,11 +1759,10 @@ mod tests {
.unwrap();
assert!(matches!(
streamed,
Event::InternalWorker { revision: 3, event, .. }
Event::InternalWorker { event, .. }
if matches!(*event, Event::TextDelta { ref text } if text == "partial")
));
let snapshots = registry.internal_worker_snapshots();
assert_eq!(snapshots[0].revision, 3);
assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
}
@@ -1536,7 +1799,7 @@ mod tests {
.unwrap();
assert!(matches!(
event,
Event::InternalWorker { worker, revision: 1, event }
Event::InternalWorker { worker, event }
if worker.session_id == session_id
&& matches!(worker.kind, InternalWorkerKind::Service { ref kind } if kind == "compaction")
&& matches!(*event, Event::TextDone { ref text } if text == "summary candidate")
@@ -1554,7 +1817,7 @@ mod tests {
.unwrap();
assert!(matches!(
removed,
Event::InternalWorkerRemoved { worker, revision: 2 }
Event::InternalWorkerRemoved { worker }
if worker.session_id == session_id
));
assert!(registry.internal_worker_snapshots().is_empty());
@@ -1649,23 +1912,18 @@ mod tests {
})
);
assert!(registry.get_internal("child").is_none());
let terminal_revision = loop {
if let Event::InternalWorkerRemoved { worker, revision } =
parent_rx.recv().await.unwrap()
{
loop {
if let Event::InternalWorkerRemoved { worker } = parent_rx.recv().await.unwrap() {
assert_eq!(worker.session_id, summary.session_id);
assert!(revision > 0);
break revision;
break;
}
};
}
assert!(registry.remove_internal("child").await.unwrap().is_none());
while let Ok(Ok(event)) =
tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()).await
{
assert!(!matches!(event, Event::InternalWorkerRemoved { .. }));
if let Event::InternalWorker { revision, .. } = event {
assert!(revision > terminal_revision);
}
assert!(!matches!(event, Event::InternalWorker { .. }));
}
}
+2 -2
View File
@@ -1339,7 +1339,7 @@ enabled = false
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -2100,7 +2100,7 @@ enabled = false
parent.feature.memory.profile.enabled = true;
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 4,
language: "日本語".to_string(),
};
parent
+2 -3
View File
@@ -161,7 +161,7 @@ fn decode<T: serde::de::DeserializeOwned>(
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
"change_conflict"
| "stale_cursor"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
@@ -394,7 +394,7 @@ pub(crate) fn test_connection(client: Arc<dyn WorkspaceClient>) -> SubjektivHost
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: client.workspace_id().unwrap().to_string(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -447,7 +447,6 @@ mod tests {
Ok(SubjektivMemoryBackendResponse::ResidentContext(
SubjektivResidentContextOutput {
behavior_md: "Explicit local subject behavior.".into(),
behavior_revision: 1,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability:
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
+35 -35
View File
@@ -2987,7 +2987,7 @@ mod tests {
}
}
fn objective_detail_response(revision: &str) -> crate::worker::WorkspaceResponse {
fn objective_detail_response(content_digest: &str) -> crate::worker::WorkspaceResponse {
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
@@ -2997,7 +2997,7 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": revision,
"content_digest": content_digest,
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -4009,7 +4009,7 @@ mod tests {
struct ContextualItemResolver {
owner: Arc<ContextualHandler>,
revision: Arc<AtomicUsize>,
content: Arc<Mutex<String>>,
contextual_family: bool,
}
@@ -4032,7 +4032,7 @@ mod tests {
description: None,
interfaces: vec![root_reference("test.dynamic/v1")],
r#ref: Some(format!("objective:{item_reference}")),
validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]),
validator: Some(self.content.lock().unwrap().as_bytes().to_vec()),
},
handler: Arc::new(handler),
})
@@ -4050,16 +4050,16 @@ mod tests {
fn contextual_dynamic_registry(
owner: Arc<ContextualHandler>,
contributor: Arc<ContextualHandler>,
revision: Arc<AtomicUsize>,
content: Arc<Mutex<String>>,
contextual_collection: bool,
) -> WipMountRegistry {
let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&owner.calls));
let mut registry = dynamic_registry(Arc::clone(&content), Arc::clone(&owner.calls));
let mut mounted = registry.dynamic_mounts.pop().unwrap();
mounted.descriptor.operations = vec![contributed_operation("read")];
mounted.interface_validator = Some(descriptor_validator(&mounted.descriptor));
mounted.resolver = Arc::new(ContextualItemResolver {
owner,
revision,
content,
contextual_family: !contextual_collection,
});
if contextual_collection {
@@ -4084,16 +4084,16 @@ mod tests {
}
#[tokio::test]
async fn contextual_dynamic_filters_by_path_and_rechecks_permission_and_object_revision() {
async fn contextual_dynamic_filters_by_path_and_rechecks_permission_and_object_content() {
// Both ways to opt in a family must block unqualified descriptor fetches.
for contextual_collection in [false, true] {
let owner = Arc::new(ContextualHandler::new(true, true));
let contributor = Arc::new(ContextualHandler::new(true, true));
let revision = Arc::new(AtomicUsize::new(1));
let content = Arc::new(Mutex::new("initial fixture body".to_string()));
let host = WipHost::new(contextual_dynamic_registry(
Arc::clone(&owner),
Arc::clone(&contributor),
Arc::clone(&revision),
Arc::clone(&content),
contextual_collection,
));
let initial = host.projection("/objectives/O-1").unwrap();
@@ -4161,7 +4161,7 @@ mod tests {
..
}))
));
revision.store(2, Ordering::SeqCst);
*content.lock().unwrap() = "changed fixture body".to_string();
assert!(matches!(
host.call(asset_request(&current, "read"), direct_wip_context())
.await,
@@ -4280,7 +4280,7 @@ mod tests {
struct CurrentObjectValidatorHandler {
inner: Arc<ContextualHandler>,
revision: Arc<AtomicUsize>,
content: Arc<Mutex<String>>,
}
#[async_trait]
@@ -4290,7 +4290,7 @@ mod tests {
}
fn object_validator(&self) -> Option<Vec<u8>> {
Some(self.revision.load(Ordering::SeqCst).to_be_bytes().to_vec())
Some(self.content.lock().unwrap().as_bytes().to_vec())
}
async fn call(
@@ -4306,14 +4306,14 @@ mod tests {
#[tokio::test]
async fn static_object_validator_hook_rejects_current_owner_state_changes() {
let owner = Arc::new(ContextualHandler::new(true, true));
let revision = Arc::new(AtomicUsize::new(1));
let content = Arc::new(Mutex::new("initial fixture body".to_string()));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("asset", "assets").unwrap();
registry
.mount(contribution_projection(Arc::new(
CurrentObjectValidatorHandler {
inner: Arc::clone(&owner),
revision: Arc::clone(&revision),
content: Arc::clone(&content),
},
)))
.unwrap();
@@ -4328,7 +4328,7 @@ mod tests {
descriptor,
handler: Arc::new(CurrentObjectValidatorHandler {
inner: Arc::new(ContextualHandler::new(true, true)),
revision: Arc::new(AtomicUsize::new(99)),
content: Arc::new(Mutex::new("contributor body".to_string())),
}),
})
.unwrap();
@@ -4338,12 +4338,12 @@ mod tests {
let initial = runtime.host.projection("/assets/A-1").unwrap();
assert_eq!(
initial.object.validator,
Some(1usize.to_be_bytes().to_vec())
Some(b"initial fixture body".to_vec())
);
revision.store(2, Ordering::SeqCst);
*content.lock().unwrap() = "changed fixture body".to_string();
assert_eq!(
runtime.host.object_at("/assets/A-1").unwrap().validator,
Some(2usize.to_be_bytes().to_vec())
Some(b"changed fixture body".to_vec())
);
assert!(matches!(
runtime
@@ -4381,7 +4381,7 @@ mod tests {
let mut registry = contextual_dynamic_registry(
Arc::clone(&owner),
Arc::new(ContextualHandler::new(false, true)),
Arc::new(AtomicUsize::new(1)),
Arc::new(Mutex::new("initial fixture body".to_string())),
true,
);
let denied_collection = Arc::new(ContextualHandler::new(false, true));
@@ -5682,7 +5682,7 @@ mod tests {
}
struct DynamicResolver {
revision: Arc<AtomicUsize>,
content: Arc<Mutex<String>>,
calls: Arc<AtomicUsize>,
}
@@ -5715,7 +5715,7 @@ mod tests {
description: None,
interfaces: vec![root_reference("test.dynamic/v1")],
r#ref: Some(format!("objective:{item_reference}")),
validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]),
validator: Some(self.content.lock().unwrap().as_bytes().to_vec()),
},
handler: Arc::new(DynamicHandler {
calls: Arc::clone(&self.calls),
@@ -5738,7 +5738,7 @@ mod tests {
}
}
fn dynamic_registry(revision: Arc<AtomicUsize>, calls: Arc<AtomicUsize>) -> WipMountRegistry {
fn dynamic_registry(content: Arc<Mutex<String>>, calls: Arc<AtomicUsize>) -> WipMountRegistry {
let mut registry = WipMountRegistry::new();
let namespace = registry
.allocate_namespace("objective", "objectives")
@@ -5772,7 +5772,7 @@ mod tests {
interface: root_reference("test.dynamic/v1"),
descriptor,
interface_validator,
resolver: Arc::new(DynamicResolver { revision, calls }),
resolver: Arc::new(DynamicResolver { content, calls }),
})
.unwrap();
registry
@@ -5780,15 +5780,15 @@ mod tests {
#[tokio::test]
async fn dynamic_routes_resolve_only_direct_children_and_reject_stale_calls() {
let revision = Arc::new(AtomicUsize::new(1));
let content = Arc::new(Mutex::new("initial fixture body".to_string()));
let calls = Arc::new(AtomicUsize::new(0));
let host = WipHost::new(dynamic_registry(Arc::clone(&revision), Arc::clone(&calls)));
let host = WipHost::new(dynamic_registry(Arc::clone(&content), Arc::clone(&calls)));
let initial = host.projection("/objectives/O-3").unwrap();
assert!(host.projection("/objectives/O-3/other").is_none());
assert!(host.projection("/objectives/T-3").is_none());
assert!(host.projection("/hidden/objectives/O-3").is_none());
revision.store(2, Ordering::SeqCst);
*content.lock().unwrap() = "changed fixture body".to_string();
let result = host
.call(
CallOperationRequest {
@@ -5825,9 +5825,9 @@ mod tests {
#[test]
fn dynamic_registration_rejects_foreign_owners_and_operation_collisions_atomically() {
let revision = Arc::new(AtomicUsize::new(1));
let content = Arc::new(Mutex::new("initial fixture body".to_string()));
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&calls));
let mut registry = dynamic_registry(Arc::clone(&content), Arc::clone(&calls));
let mounted = registry.dynamic_mounts.pop().unwrap();
let foreign = WipDynamicMount {
collection_route: mounted.collection_route.clone(),
@@ -5836,7 +5836,7 @@ mod tests {
descriptor: mounted.descriptor.clone(),
interface_validator: mounted.interface_validator.clone(),
resolver: Arc::new(DynamicResolver {
revision,
content,
calls: Arc::clone(&calls),
}),
};
@@ -5887,11 +5887,11 @@ mod tests {
#[tokio::test]
async fn dynamic_object_resolver_accepts_disjoint_feature_operations() {
let revision = Arc::new(AtomicUsize::new(1));
let content = Arc::new(Mutex::new("initial fixture body".to_string()));
let base_calls = Arc::new(AtomicUsize::new(0));
let contribution_calls = Arc::new(AtomicUsize::new(0));
let allowed = Arc::new(AtomicBool::new(true));
let mut registry = dynamic_registry(revision, base_calls);
let mut registry = dynamic_registry(content, base_calls);
let mounted = registry
.dynamic_mounts
.first()
@@ -5952,8 +5952,8 @@ mod tests {
engine.register_tool(echo_definition("Echo".into(), Arc::clone(&calls)));
engine.register_tool(echo_definition("Other".into(), Arc::clone(&calls)));
let revision = Arc::new(AtomicUsize::new(1));
let mut registry = dynamic_registry(revision, calls);
let content = Arc::new(Mutex::new("initial fixture body".to_string()));
let mut registry = dynamic_registry(content, calls);
registry
.replace_compatibility_tools("/objectives", ["Echo"])
.unwrap();
@@ -6302,7 +6302,7 @@ mod tests {
"sequence": 2,
"request_event_id": "request-1",
"subject_ref": "source-1",
"ticket_item_revision": "ticket-rev-1",
"ticket_content_digest": "ticket-rev-1",
"ticket_merge_request_subjects": [{"merge_request_id": "MR-1", "subject_ref": "source-1"}],
"decision": "approve",
"body": "approved",
+144 -125
View File
@@ -225,7 +225,6 @@ struct NotificationReceipt {
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct PendingActivationState {
revision: u64,
next_activation_sequence: u64,
/// A prepared activation remains in checkpoints until the same atomic
/// UserInput record commits the clearing checkpoint. Restore puts it back
@@ -291,7 +290,6 @@ impl PendingActivationState {
(None, None) => None,
};
protocol::PendingSubmissionsSnapshot {
revision: self.revision,
notification_count: u32::try_from(self.pending_notifications.len()).unwrap_or(u32::MAX),
notification_previews: self
.pending_notifications
@@ -702,7 +700,6 @@ impl std::fmt::Debug for WorkspacePromptCatalogResolution {
formatter
.debug_struct("WorkspacePromptCatalogResolution")
.field("workspace_id", &self.projection.workspace_id)
.field("config_revision", &self.projection.config_revision)
.field("source_digest", &self.projection.source_digest)
.field("projection_digest", &self.projection.projection_digest)
.finish_non_exhaustive()
@@ -927,7 +924,6 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
/// launch/session state; this hook never reconstructs historical prompts.
fn current_prompt_projection(
&self,
_minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
Ok(None)
}
@@ -995,9 +991,8 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
fn current_prompt_projection(
&self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
self.inner.current_prompt_projection(minimum_revision)
self.inner.current_prompt_projection()
}
fn execute_binary(
@@ -1809,8 +1804,11 @@ pub(crate) enum PendingSubmissionError {
ByteLimit,
#[error("pending submission artifact references exceed {MAX_PENDING_ARTIFACT_REFS}")]
ArtifactLimit,
#[error("pending queue revision conflict: expected {expected}, current {current}")]
RevisionConflict { expected: u64, current: u64 },
#[error("pending queue content conflict: expected {expected:?}, current {current:?}")]
ContentConflict {
expected: Vec<String>,
current: Vec<String>,
},
#[error("pending queue head conflict: expected {expected}, current {current:?}")]
HeadConflict {
expected: String,
@@ -1832,26 +1830,17 @@ impl<St> PendingSubmissionHandle<St>
where
St: Store + Clone,
{
fn validate_fence(
fn validate_head(
state: &PendingActivationState,
expected_revision: u64,
expected_head_id: Option<&str>,
expected: &str,
) -> Result<(), PendingSubmissionError> {
if state.revision != expected_revision {
return Err(PendingSubmissionError::RevisionConflict {
expected: expected_revision,
current: state.revision,
let current = state.snapshot().head_id;
if current.as_deref() != Some(expected) {
return Err(PendingSubmissionError::HeadConflict {
expected: expected.to_owned(),
current,
});
}
if let Some(expected) = expected_head_id {
let current = state.snapshot().head_id;
if current.as_deref() != Some(expected) {
return Err(PendingSubmissionError::HeadConflict {
expected: expected.to_owned(),
current,
});
}
}
Ok(())
}
@@ -2015,7 +2004,6 @@ where
payload_digest,
disposition,
});
current.revision = current.revision.saturating_add(1);
if activate_now {
current.activating = Some(pending.clone());
@@ -2166,7 +2154,6 @@ where
source_namespace,
payload_digest,
});
state.revision = state.revision.saturating_add(1);
if let Err(error) = self.persist_locked(&state) {
*state = original;
return Err(error);
@@ -2202,7 +2189,6 @@ where
state
.activating_notifications
.extend(notifications.iter().cloned());
state.revision = state.revision.saturating_add(1);
notifications
.into_iter()
@@ -2218,7 +2204,7 @@ where
pub(crate) fn prepare_next_activation(
&self,
fence: Option<(u64, &str)>,
fence: Option<&str>,
) -> Result<Option<PendingActivation>, PendingSubmissionError> {
let _append_guard = self
.writer
@@ -2230,8 +2216,8 @@ where
.state
.lock()
.expect("pending activation state poisoned");
if let Some((expected_revision, expected_head_id)) = fence {
Self::validate_fence(&state, expected_revision, Some(expected_head_id))?;
if let Some(expected_head_id) = fence {
Self::validate_head(&state, expected_head_id)?;
}
if state.activating.is_some() {
return Ok(None);
@@ -2240,7 +2226,6 @@ where
return Ok(None);
};
state.activating = Some(pending.clone());
state.revision = state.revision.saturating_add(1);
Ok(Some(PendingActivation::Submission(pending)))
}
@@ -2267,7 +2252,6 @@ where
.receipts
.retain(|receipt| receipt.submission_id != pending.submission_id);
}
state.revision = state.revision.saturating_add(1);
if let Err(error) = self.persist_locked(&state) {
tracing::error!(error = %error, "failed to persist aborted pending activation");
}
@@ -2288,7 +2272,6 @@ where
receipt.disposition = protocol::SubmissionDisposition::Started;
}
committed.activating = None;
committed.revision = committed.revision.saturating_add(1);
pending_activation_extension(&committed)
}
@@ -2311,7 +2294,6 @@ where
receipt.disposition = protocol::SubmissionDisposition::Started;
}
state.activating = None;
state.revision = state.revision.saturating_add(1);
}
}
@@ -2341,7 +2323,6 @@ where
pub(crate) fn cancel(
&self,
submission_id: &str,
expected_revision: u64,
) -> Result<protocol::PendingSubmissionsSnapshot, PendingSubmissionError> {
let _append_guard = self
.writer
@@ -2353,7 +2334,6 @@ where
.state
.lock()
.expect("pending activation state poisoned");
Self::validate_fence(&state, expected_revision, None)?;
let original = state.clone();
let Some(index) = state
.pending
@@ -2366,7 +2346,6 @@ where
.pending
.remove(index)
.expect("located pending submission must exist");
state.revision = state.revision.saturating_add(1);
if let Err(error) = self.persist_locked(&state) {
*state = original;
return Err(error);
@@ -2377,7 +2356,7 @@ where
pub(crate) fn clear(
&self,
expected_revision: u64,
expected_submission_ids: &[String],
) -> Result<protocol::PendingSubmissionsSnapshot, PendingSubmissionError> {
let _append_guard = self
.writer
@@ -2389,11 +2368,19 @@ where
.state
.lock()
.expect("pending activation state poisoned");
Self::validate_fence(&state, expected_revision, None)?;
let original = state.clone();
let current = state
.pending
.iter()
.map(|pending| pending.submission_id.clone())
.collect::<Vec<_>>();
if current != expected_submission_ids {
return Err(PendingSubmissionError::ContentConflict {
expected: expected_submission_ids.to_vec(),
current,
});
}
let removed = state.pending.drain(..).collect::<Vec<_>>();
state.pending_notifications.clear();
state.revision = state.revision.saturating_add(1);
if let Err(error) = self.persist_locked(&state) {
*state = original;
return Err(error);
@@ -2502,7 +2489,6 @@ impl<St: Store + Clone> DurableNotificationCommitter for PendingSubmissionHandle
}
let mut committed = state.clone();
committed.activating_notifications.pop_front();
committed.revision = committed.revision.saturating_add(1);
let metadata = new_history_metadata(
provenance.unwrap_or(WorkerHistoryProvenance::LegacyUnknown),
None,
@@ -2633,30 +2619,27 @@ struct PreparedFlowProjection {
selector: String,
instructions: String,
definition_id: String,
definition_revision: u64,
definition_digest: String,
instance_id: String,
state_id: String,
}
/// Sole live owner of committed model-visible Worker history.
///
/// `Engine` borrows this history only while executing a run. The revision is
/// advanced together with every live rewrite so projections can fence stale
/// observations without maintaining a second transcript.
/// `Engine` borrows this history only while executing a run. Features capture
/// the committed log location and check it under the append lock before writing;
/// this live view does not maintain an independent freshness counter.
#[derive(Clone)]
pub struct WorkerSession {
session_id: SessionId,
revision: u64,
history: History<SessionHistoryMetadata>,
pending_activations: Arc<Mutex<PendingActivationState>>,
}
impl WorkerSession {
fn new(session_id: SessionId, entries: Vec<HistoryEntry<SessionHistoryMetadata>>) -> Self {
let revision = u64::try_from(entries.len()).unwrap_or(u64::MAX);
Self {
session_id,
revision,
history: History::from_entries(entries),
pending_activations: Arc::new(Mutex::new(PendingActivationState::default())),
}
@@ -2684,13 +2667,11 @@ impl WorkerSession {
}
if let Some(activating) = state.activating.take() {
state.pending.push_front(activating);
state.revision = state.revision.saturating_add(1);
}
if !state.activating_notifications.is_empty() {
let mut restored = std::mem::take(&mut state.activating_notifications);
restored.append(&mut state.pending_notifications);
state.pending_notifications = restored;
state.revision = state.revision.saturating_add(1);
}
*self
.pending_activations
@@ -2717,10 +2698,6 @@ impl WorkerSession {
self.session_id
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn history(&self) -> &History<SessionHistoryMetadata> {
&self.history
}
@@ -2729,13 +2706,8 @@ impl WorkerSession {
&mut self.history
}
fn note_mutation(&mut self) {
self.revision = self.revision.saturating_add(1);
}
fn replace_history(&mut self, entries: Vec<HistoryEntry<SessionHistoryMetadata>>) {
self.history.replace_entries(entries);
self.note_mutation();
}
}
@@ -2748,7 +2720,7 @@ pub(crate) enum SystemPromptContribution {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResidentContextRefresh {
pub body: String,
pub revision: u64,
pub behavior_md: String,
}
#[async_trait::async_trait]
@@ -2763,8 +2735,8 @@ pub(crate) trait SystemPromptContributionSource: Send + Sync {
Ok(None)
}
/// Acknowledges that the revision's typed refresh was durably committed.
fn confirm_resident_context_revision(&self, _revision: u64) {}
/// Acknowledges the exact behavior represented by a durably committed refresh.
fn confirm_resident_context_behavior(&self, _behavior_md: &str) {}
/// Invalidates the in-memory representation fence before history may be
/// compacted or rewound.
@@ -3266,7 +3238,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.workspace_context
.workspace_id()
.map(|workspace_id| workspace_id.as_str().to_string()),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: logical_name.to_string(),
@@ -3283,7 +3255,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let Some(resolution) = self
.workspace_context
.client()
.current_prompt_projection(None)
.current_prompt_projection()
.map_err(|source| WorkerError::WorkspacePromptProjection {
message: source.to_string(),
})?
@@ -3292,8 +3264,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
};
let projection = &resolution.projection;
let current = self.prompts.load();
if current.projection().config_revision == projection.config_revision
&& current.projection().source_digest == projection.source_digest
if current.projection().source_digest == projection.source_digest
&& current.projection().catalog_digest == projection.projection_digest
&& Arc::ptr_eq(&current, &resolution.catalog)
{
@@ -3524,7 +3495,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
std::iter::once(history_entry.item),
&mut annotate,
)?;
session.note_mutation();
Ok(activation)
}
@@ -3613,7 +3584,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.map(|id| id.as_str().to_string()),
worker_id: self.manifest.worker.name.clone(),
session_id: self.session.session_id().to_string(),
session_revision: self.session.revision(),
run_id,
turn_index: Some(self.engine().turn_count()),
call_id: None,
@@ -4036,7 +4007,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
Ok(CommittedSessionCapture {
session_id: location.session_id.to_string(),
segment_id: location.segment_id.to_string(),
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
entry_count: entries.len(),
run_exit,
history,
@@ -4059,10 +4030,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.lock()
.expect("segment append lock poisoned");
let location = writer.state.location();
// Compare the actual append position while holding the same lock as
// the write. Any intervening log append or segment replacement makes
// this captured result stale; a separate change counter adds nothing.
if location.session_id.to_string() != expected.session_id
|| location.segment_id.to_string() != expected.segment_id
|| u64::try_from(writer.state.entries_written()).unwrap_or(u64::MAX)
!= expected.session_revision
|| writer.state.entries_written() != expected.entry_count
{
return Ok(false);
@@ -4420,7 +4392,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
std::iter::once(history_entry.item),
&mut annotate,
)?;
session.note_mutation();
}
self.restore_feature_lifecycle_pending = false;
Ok(())
@@ -4641,7 +4612,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
snapshot: EmptyTurnRollbackSnapshot,
) -> Result<(), StoreError> {
self.session.history_mut().truncate(snapshot.history_len);
self.session.note_mutation();
self.last_run_interrupted = snapshot.last_run_interrupted;
self.engine_mut()
.set_active_run_turn_count(snapshot.active_run_turn_count);
@@ -4773,7 +4744,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
selector: selector.to_string(),
instructions: initial_instructions,
definition_id: state.instance.definition_id.clone(),
definition_revision: state.instance.definition_revision,
definition_digest: state.instance.definition_digest.clone(),
instance_id: state.instance.instance_id.clone(),
state_id: state.instance.current_state.to_string(),
};
@@ -4974,7 +4945,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.run_with_annotation(self.session.history_mut(), input_string, &mut annotate)
.await;
self.engine = Some(locked.unlock());
self.session.note_mutation();
if let Some(input) = &committed_activation_input {
// A prompt hook may cancel before Engine materializes input/results.
@@ -5194,7 +5164,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.session.history_mut().push_entry(entry);
}
}
self.session.note_mutation();
}
pub(crate) fn can_schedule_notification_run(&self) -> bool {
@@ -5336,7 +5305,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
entry: to_logged_history_entry(&entry),
})?;
self.session.history_mut().push_entry(entry);
self.session.note_mutation();
}
Ok(())
}
@@ -5369,7 +5337,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let interrupt_entry =
HistoryEntry::new(agen::Item::system_message(system_note), interrupt_metadata);
self.session.history_mut().push_entry(interrupt_entry);
self.session.note_mutation();
Ok(())
}
@@ -5507,7 +5475,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
WorkerHistoryProvenance::FlowInstruction {
selector: flow.selector.clone(),
definition_id: flow.definition_id.clone(),
definition_revision: flow.definition_revision,
definition_digest: Some(flow.definition_digest.clone()),
instance_id: flow.instance_id.clone(),
state_id: flow.state_id.clone(),
},
@@ -5584,7 +5552,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.resume_with_annotation(self.session.history_mut(), &mut annotate)
.await;
self.engine = Some(locked.unlock());
self.session.note_mutation();
self.handle_worker_result(result, history_before).await
}
@@ -5638,7 +5605,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.resume_with_annotation(self.session.history_mut(), &mut annotate)
.await;
self.engine = Some(locked.unlock());
self.session.note_mutation();
self.handle_worker_result(result, history_before).await
}
@@ -6414,7 +6380,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}
/// Runs one parent-owned observable compaction service and returns the new
/// Segment ID. Lifecycle revisions are committed before they are broadcast.
/// Segment ID. Lifecycle states are committed before they are broadcast.
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
self.compact_with_cancel(retained_tokens, None, CompactionTrigger::RequestThreshold)
.await
@@ -6434,7 +6400,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let mut lifecycle = CompactionLifecycle {
schema_version: 3,
compaction_id: uuid::Uuid::now_v7().to_string(),
revision: 1,
internal_worker: None,
state: CompactionLifecycleState::Running,
started_at_ms: segment_log::now_millis(),
@@ -6516,7 +6481,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
) {
self.try_record_metric(&metric);
}
lifecycle.revision = lifecycle.revision.saturating_add(1);
lifecycle.state = if matches!(&error, WorkerError::CompactCancelled) {
CompactionLifecycleState::Interrupted
} else {
@@ -6873,7 +6837,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
handle.clone(),
))
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
lifecycle.revision = lifecycle.revision.saturating_add(1);
lifecycle.internal_worker = Some(internal_ref);
let cleanup = PendingCompactionCleanup {
session_id: handle.session_id_string(),
@@ -7293,7 +7256,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// their blocks from `SegmentStart.history`. No per-item
// broadcast is required.
let _ = &compact_introduced_system_messages;
lifecycle.revision = lifecycle.revision.saturating_add(1);
lifecycle.state = CompactionLifecycleState::Done;
lifecycle.ended_at_ms = Some(segment_log::now_millis());
Ok((
@@ -9948,7 +9910,7 @@ permission = "read"
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-a".to_string(),
settings_revision: 7,
language: "Japanese".to_string(),
})
.unwrap();
@@ -9964,7 +9926,7 @@ permission = "read"
restored.feature.memory.workspace_settings(),
Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-a".to_string(),
settings_revision: 7,
language: "Japanese".to_string(),
})
);
@@ -10068,7 +10030,7 @@ mod build_summary_prompt_tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
load_count: Arc<AtomicUsize>,
behavior_revision: Option<Arc<AtomicUsize>>,
behavior_md: Option<Arc<Mutex<String>>>,
}
impl WorkspaceClient for RestoreSubjectResidentClient {
@@ -10100,11 +10062,10 @@ mod build_summary_prompt_tests {
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md: "restore behavior".to_string(),
behavior_revision: self
.behavior_revision
.as_ref()
.map_or(1, |revision| revision.load(Ordering::SeqCst) as u64),
behavior_md: self.behavior_md.as_ref().map_or_else(
|| "restore behavior".to_string(),
|body| body.lock().unwrap().clone(),
),
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
@@ -10127,7 +10088,7 @@ mod build_summary_prompt_tests {
availability,
content: content.map(str::to_string),
load_count: Arc::clone(&load_count),
behavior_revision: None,
behavior_md: None,
});
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
manifest,
@@ -10417,7 +10378,6 @@ mod build_summary_prompt_tests {
selector: "builtin:coder-review".parse().unwrap(),
workspace_id: "workspace-test".to_string(),
flow_id: "flow-source-1".to_string(),
revision: 3,
content_digest: definition.content_digest.clone(),
definition,
};
@@ -10818,7 +10778,7 @@ mod build_summary_prompt_tests {
.bind_backend_job(crate::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: None,
input_digest: None,
subjektiv_consolidation: false
})
.is_err()
@@ -10833,7 +10793,7 @@ mod build_summary_prompt_tests {
let binding = crate::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: Some("revision-1".into()),
input_digest: Some("revision-1".into()),
subjektiv_consolidation: true,
};
worker.bind_backend_job(binding.clone()).unwrap();
@@ -11983,7 +11943,10 @@ mod build_summary_prompt_tests {
WorkerHistoryProvenance::FlowInstruction { .. }
));
assert_eq!(projected[1].annotation.origin, input_provenance);
assert_eq!(state.instance.definition_revision, 3);
assert_eq!(
state.instance.definition_digest,
state.definition.content_digest
);
assert_eq!(state.instance.current_state.as_str(), "implement");
assert_eq!(workspace_client.requests.lock().unwrap().len(), 1);
@@ -13022,7 +12985,7 @@ permission = "write"
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -13119,7 +13082,7 @@ permission = "write"
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -13317,7 +13280,7 @@ permission = "write"
memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 3,
language: "Japanese".to_string(),
})
.unwrap();
@@ -13633,7 +13596,7 @@ permission = "write"
.append_if_current(
&stale.location(),
"test.feature",
serde_json::json!({"revision": "stale"}),
serde_json::json!({"result": "stale"}),
)
.unwrap()
);
@@ -13644,7 +13607,7 @@ permission = "write"
.append_if_current(
&current.location(),
"test.feature",
serde_json::json!({"revision": "current"}),
serde_json::json!({"result": "current"}),
)
.unwrap()
);
@@ -13927,9 +13890,7 @@ permission = "write"
.is_ok()
);
handle
.cancel(&accepted.submission_id, handle.snapshot().revision)
.unwrap();
handle.cancel(&accepted.submission_id).unwrap();
assert_eq!(
handle
.writer
@@ -14039,9 +14000,7 @@ permission = "write"
format!("{}…", "界".repeat(240))
]
);
let after = handle
.cancel(&submit.submission_id, before.revision)
.unwrap();
let after = handle.cancel(&submit.submission_id).unwrap();
assert!(after.submissions.is_empty());
assert_eq!(after.notification_previews, before.notification_previews);
let entries = handle.persisted_entries_for_test();
@@ -14192,21 +14151,19 @@ permission = "write"
let fence = handle.snapshot();
assert!(matches!(
handle.cancel(&accepted.submission_id, fence.revision.saturating_sub(1)),
Err(PendingSubmissionError::RevisionConflict { .. })
handle.clear(&["stale-submission".to_owned()]),
Err(PendingSubmissionError::ContentConflict { .. })
));
assert!(matches!(
handle.prepare_next_activation(Some((fence.revision, "wrong-head"))),
handle.prepare_next_activation(Some("wrong-head")),
Err(PendingSubmissionError::HeadConflict { .. })
));
assert_eq!(handle.snapshot(), fence);
let snapshot = handle
.cancel(&accepted.submission_id, handle.snapshot().revision)
.unwrap();
let snapshot = handle.cancel(&accepted.submission_id).unwrap();
assert!(snapshot.submissions.is_empty());
assert!(matches!(
handle.cancel(&accepted.submission_id, handle.snapshot().revision),
handle.cancel(&accepted.submission_id),
Err(PendingSubmissionError::NotFound(_))
));
@@ -14224,11 +14181,74 @@ permission = "write"
Err(PendingSubmissionError::CountLimit)
));
assert_eq!(handle.snapshot().submissions.len(), MAX_PENDING_SUBMISSIONS);
let cleared = handle.clear(handle.snapshot().revision).unwrap();
let cleared = handle
.clear(
&handle
.snapshot()
.submissions
.iter()
.map(|submission| submission.submission_id.clone())
.collect::<Vec<_>>(),
)
.unwrap();
assert!(cleared.submissions.is_empty());
assert_eq!(cleared.notification_count, 0);
}
#[test]
fn pending_queue_fences_use_target_state_and_exact_fifo_content() {
let temp = tempfile::tempdir().unwrap();
let handle = PendingSubmissionHandle::for_test(temp.path());
let first = handle
.accept("first".into(), vec![Segment::text("one")], false)
.unwrap();
let observed = vec![first.submission_id.clone()];
let second = handle
.accept("second".into(), vec![Segment::text("two")], false)
.unwrap();
handle
.accept_notification("notice".into(), "keep this".into())
.unwrap();
assert!(matches!(
handle.clear(&observed),
Err(PendingSubmissionError::ContentConflict { .. })
));
assert!(matches!(
handle.clear(&[second.submission_id.clone(), first.submission_id.clone()]),
Err(PendingSubmissionError::ContentConflict { .. })
));
assert_eq!(handle.snapshot().submissions.len(), 2);
handle.cancel(&second.submission_id).unwrap();
let activation = handle
.prepare_next_activation(Some(&first.submission_id))
.unwrap()
.unwrap();
assert!(matches!(
handle.cancel(&first.submission_id),
Err(PendingSubmissionError::NotFound(_))
));
let PendingActivation::Submission(submission) = activation;
handle.abort_activation(submission);
let cleared = handle.clear(&observed).unwrap();
assert!(cleared.submissions.is_empty());
assert_eq!(cleared.notification_count, 1);
assert_eq!(cleared.notification_previews, ["keep this"]);
let entries = handle.persisted_entries_for_test();
let payload = entries
.iter()
.rev()
.find_map(|entry| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN => Some(payload),
_ => None,
})
.unwrap();
assert!(payload.get("revision").is_none());
let restored: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(restored.snapshot(), cleared);
}
#[test]
fn durable_notification_commits_authenticated_history_provenance() {
let temp = tempfile::tempdir().unwrap();
@@ -14477,7 +14497,7 @@ permission = "write"
assert_eq!(checkpoint.notification_receipts.len(), 2);
assert_eq!(checkpoint.pending.len(), 1);
assert_eq!(checkpoint.receipts.len(), 2);
assert_eq!(checkpoint.revision, handle.state.lock().unwrap().revision);
assert_eq!(checkpoint.snapshot(), handle.snapshot());
let restored = PendingSubmissionHandle {
state: Arc::new(Mutex::new(checkpoint)),
writer: handle.writer.clone(),
@@ -14979,7 +14999,7 @@ permission = "write"
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -15006,7 +15026,7 @@ permission = "write"
.await
.unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let behavior_revision = Arc::new(AtomicUsize::new(1));
let behavior_md = Arc::new(Mutex::new("restore behavior".to_string()));
let resident_source = || {
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
&manifest,
@@ -15015,7 +15035,7 @@ permission = "write"
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("literal /prepare()".into()),
load_count: Arc::default(),
behavior_revision: Some(behavior_revision.clone()),
behavior_md: Some(behavior_md.clone()),
},
))),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -15049,7 +15069,7 @@ permission = "write"
.unwrap(),
);
original.materialize_durable_session_head().await.unwrap();
behavior_revision.store(2, Ordering::SeqCst);
*behavior_md.lock().unwrap() = "updated behavior".to_string();
*client.sink.lock().unwrap() = Some(original.sink());
let pending = original.pending_submission_handle();
pending
@@ -15738,7 +15758,6 @@ permission = "write"
fn restoring_an_in_flight_activation_requeues_it_at_the_fifo_head() {
let mut session = WorkerSession::new(session_store::new_session_id(), Vec::new());
let state = PendingActivationState {
revision: 4,
next_activation_sequence: 3,
invocation_receipts: Vec::new(),
invocation_recovery_blocked: false,
+2 -5
View File
@@ -867,7 +867,7 @@ async fn backend_job_capability_is_explicit_and_preserves_profile_features() {
.bind_backend_job(worker::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: Some("input-1".into()),
input_digest: Some("input-1".into()),
subjektiv_consolidation: false,
})
.unwrap();
@@ -913,7 +913,6 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -932,7 +931,7 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy
.bind_backend_job(worker::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: None,
input_digest: None,
subjektiv_consolidation: true,
})
.unwrap();
@@ -1897,7 +1896,6 @@ async fn submit_while_running_or_paused_is_durably_queued() {
assert_eq!(snapshot.head_id, pending.head_id);
handle
.send(Method::ContinuePending {
expected_revision: snapshot.revision,
expected_head_id: snapshot.head_id.unwrap(),
})
.await
@@ -3506,7 +3504,6 @@ async fn paused_submit_waits_for_cancel_and_continue_pending() {
assert_eq!(pending.submissions.len(), 1);
handle
.send(Method::ContinuePending {
expected_revision: pending.revision,
expected_head_id: pending.head_id.unwrap(),
})
.await
@@ -127,7 +127,7 @@ async fn make_worker_with_body(
.clone();
templates.insert("test".to_string(), body.to_string());
let projection =
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
EffectivePromptCatalog::new(templates, "test-schema", "test-toolchain").unwrap();
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
let worker =
@@ -162,7 +162,7 @@ async fn template_parse_rejects_invalid_syntax() {
.clone();
templates.insert("broken".to_string(), "{{ unclosed".to_string());
let error =
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err();
EffectivePromptCatalog::new(templates, "test-schema", "test-toolchain").unwrap_err();
assert!(error.to_string().contains("does not compile"));
}