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:
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¬ify_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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/") => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod comm_tools;
|
||||
mod published;
|
||||
pub mod registry;
|
||||
pub mod tool;
|
||||
|
||||
@@ -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
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(¤t, "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
@@ -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(¤t, &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(
|
||||
¤t.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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user