Add grid agent milestone acceptance gate (#135)
This commit is contained in:
@@ -174,6 +174,9 @@ jobs:
|
||||
rm -f -- "$fingerprint_list"
|
||||
fi
|
||||
cargo run --locked -p metacrate-ci-matrix -- required-gate --evidence artifacts/ci/required-gate.json
|
||||
test ! -e artifacts/ci/grid-agent-acceptance.jsonl
|
||||
cargo test --locked -p metacrate-grid-agent --test acceptance_gate --all-features
|
||||
METACRATE_SOURCE_COMMIT="${{ github.sha }}" cargo run --locked -p metacrate-grid-agent -- --acceptance-evidence artifacts/ci/grid-agent-acceptance.jsonl
|
||||
touch "$CARGO_TARGET_DIR/.metacrate-ready"
|
||||
) 9>"$cache_lock"
|
||||
|
||||
|
||||
28
config/grid-agent-acceptance-evidence.schema.json
Normal file
28
config/grid-agent-acceptance-evidence.schema.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://metacrate.invalid/schema/grid-agent-acceptance-v1.json",
|
||||
"title": "MetaCrate grid-agent acceptance evidence",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "sequence", "unix_millis", "package_version", "source_revision", "rust_toolchain", "command", "profile", "grid_type", "endpoint_capabilities", "stage", "status", "duration_millis", "outcome_code", "evidence_sha256", "metrics", "limitations"],
|
||||
"properties": {
|
||||
"schema_version": { "const": 1 },
|
||||
"sequence": { "type": "integer", "minimum": 1 },
|
||||
"unix_millis": { "type": "integer", "minimum": 0 },
|
||||
"package_version": { "type": "string", "maxLength": 64 },
|
||||
"source_revision": { "type": "string", "maxLength": 96 },
|
||||
"rust_toolchain": { "type": "string", "maxLength": 96 },
|
||||
"command": { "type": "string", "maxLength": 256 },
|
||||
"profile": { "enum": ["deterministic_offline", "live_grid"] },
|
||||
"grid_type": { "type": "string", "maxLength": 96 },
|
||||
"endpoint_capabilities": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 96 } },
|
||||
"stage": { "type": "string", "maxLength": 96 },
|
||||
"status": { "enum": ["passed", "failed", "not_run"] },
|
||||
"duration_millis": { "type": "integer", "minimum": 0 },
|
||||
"budget_millis": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"outcome_code": { "type": "string", "maxLength": 96 },
|
||||
"evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
||||
"metrics": { "type": "object", "additionalProperties": { "type": ["integer", "number", "boolean", "string"] } },
|
||||
"limitations": { "type": "array", "maxItems": 32, "items": { "type": "string", "maxLength": 256 } }
|
||||
}
|
||||
}
|
||||
@@ -68,3 +68,6 @@ The complete quick start, platform paths, systemd/Windows service operation,
|
||||
secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults,
|
||||
and unsupported-operation list are in
|
||||
[`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md).
|
||||
Milestone acceptance, resource budgets, evidence, and opt-in live validation
|
||||
are defined in
|
||||
[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md).
|
||||
|
||||
602
crates/metacrate-grid-agent/src/acceptance.rs
Normal file
602
crates/metacrate-grid-agent/src/acceptance.rs
Normal file
@@ -0,0 +1,602 @@
|
||||
//! Reproducible milestone acceptance evidence and fail-closed live opt-ins.
|
||||
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
|
||||
use crate::AgentConfig;
|
||||
use crate::conversation::{ConversationLimits, ConversationStore};
|
||||
use crate::interaction::{
|
||||
ImDialogKind, InboundInteraction, InboundSource, InteractionChannel, InteractionCoordinator,
|
||||
InteractionModelError, InteractionObservation, InteractionResponder, ResponderFuture,
|
||||
ResponseRequest, VisibleResponse,
|
||||
};
|
||||
use crate::service::{AgentService, ServiceState};
|
||||
use crate::session::{
|
||||
GridSessionBackend, ReconnectPolicy, SessionObservation, SessionState, SessionSupervisor,
|
||||
};
|
||||
use crate::testing::{FakeGrid, GridScriptStep, adversarial_corpus};
|
||||
use crate::types::{ControlCommand, GridEventKind, ObservableEvent};
|
||||
use libremetaverse_types::{UUID, compat::CancellationToken};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub const ACCEPTANCE_SCHEMA_VERSION: u16 = 1;
|
||||
pub const ACCEPTANCE_SECRET_CANARY: &str = "METACRATE_ACCEPTANCE_SECRET_CANARY_7e91";
|
||||
const MAX_EVIDENCE_RECORD_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AcceptanceBudgets {
|
||||
pub startup_millis: u64,
|
||||
pub control_millis: u64,
|
||||
pub chat_schedule_millis: u64,
|
||||
pub reconnect_millis: u64,
|
||||
pub shutdown_millis: u64,
|
||||
pub steady_memory_bytes: u64,
|
||||
pub peak_memory_bytes: u64,
|
||||
pub queue_items: usize,
|
||||
pub binary_bytes: u64,
|
||||
pub journal_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for AcceptanceBudgets {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
startup_millis: 2_000,
|
||||
control_millis: 250,
|
||||
chat_schedule_millis: 500,
|
||||
reconnect_millis: 2_000,
|
||||
shutdown_millis: 5_000,
|
||||
steady_memory_bytes: 128 * 1024 * 1024,
|
||||
peak_memory_bytes: 256 * 1024 * 1024,
|
||||
queue_items: 8_192,
|
||||
binary_bytes: 40 * 1024 * 1024,
|
||||
journal_bytes: 1024 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AcceptanceStatus {
|
||||
Passed,
|
||||
Failed,
|
||||
NotRun,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AcceptanceRecord {
|
||||
pub schema_version: u16,
|
||||
pub sequence: u32,
|
||||
pub unix_millis: u64,
|
||||
pub package_version: String,
|
||||
pub source_revision: String,
|
||||
pub rust_toolchain: String,
|
||||
pub command: String,
|
||||
pub profile: String,
|
||||
pub grid_type: String,
|
||||
pub endpoint_capabilities: Vec<String>,
|
||||
pub stage: String,
|
||||
pub status: AcceptanceStatus,
|
||||
pub duration_millis: u64,
|
||||
pub budget_millis: Option<u64>,
|
||||
pub outcome_code: String,
|
||||
pub evidence_sha256: String,
|
||||
pub metrics: serde_json::Map<String, serde_json::Value>,
|
||||
pub limitations: Vec<String>,
|
||||
}
|
||||
|
||||
impl AcceptanceRecord {
|
||||
fn passed(
|
||||
sequence: u32,
|
||||
stage: &str,
|
||||
duration: Duration,
|
||||
budget: Option<Duration>,
|
||||
outcome: &str,
|
||||
metrics: serde_json::Map<String, serde_json::Value>,
|
||||
) -> Self {
|
||||
let evidence_sha256 = stable_hash(
|
||||
format!("{sequence}:{stage}:{outcome}:{}", duration.as_millis()).as_bytes(),
|
||||
);
|
||||
Self {
|
||||
schema_version: ACCEPTANCE_SCHEMA_VERSION,
|
||||
sequence,
|
||||
unix_millis: unix_millis(),
|
||||
package_version: env!("CARGO_PKG_VERSION").into(),
|
||||
source_revision: option_env!("METACRATE_SOURCE_COMMIT")
|
||||
.unwrap_or("working-tree-unrecorded")
|
||||
.into(),
|
||||
rust_toolchain: "rust-1.97.1-project-pin".into(),
|
||||
command: "metacrate-grid-agent --acceptance-evidence <new-path>".into(),
|
||||
profile: "deterministic_offline".into(),
|
||||
grid_type: "deterministic-fake-opensim-protocol".into(),
|
||||
endpoint_capabilities: vec!["scripted_openai_compatible".into()],
|
||||
stage: stage.into(),
|
||||
status: AcceptanceStatus::Passed,
|
||||
duration_millis: millis(duration),
|
||||
budget_millis: budget.map(millis),
|
||||
outcome_code: outcome.into(),
|
||||
evidence_sha256,
|
||||
metrics,
|
||||
limitations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AcceptanceError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
UnsafeEvidence(&'static str),
|
||||
BudgetExceeded {
|
||||
stage: &'static str,
|
||||
observed: u64,
|
||||
budget: u64,
|
||||
},
|
||||
Gate(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for AcceptanceError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "acceptance evidence I/O failed: {error}"),
|
||||
Self::Json(error) => write!(formatter, "acceptance evidence JSON failed: {error}"),
|
||||
Self::UnsafeEvidence(reason) => {
|
||||
write!(formatter, "unsafe acceptance evidence: {reason}")
|
||||
}
|
||||
Self::BudgetExceeded {
|
||||
stage,
|
||||
observed,
|
||||
budget,
|
||||
} => write!(
|
||||
formatter,
|
||||
"acceptance budget exceeded for {stage}: {observed}ms > {budget}ms"
|
||||
),
|
||||
Self::Gate(reason) => write!(formatter, "acceptance gate failed: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for AcceptanceError {}
|
||||
impl From<std::io::Error> for AcceptanceError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
impl From<serde_json::Error> for AcceptanceError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AcceptanceEvidenceWriter {
|
||||
path: PathBuf,
|
||||
output: BufWriter<File>,
|
||||
next_sequence: u32,
|
||||
}
|
||||
|
||||
impl AcceptanceEvidenceWriter {
|
||||
pub fn create(path: impl AsRef<Path>) -> Result<Self, AcceptanceError> {
|
||||
let path = path.as_ref().to_owned();
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
output: BufWriter::new(file),
|
||||
next_sequence: 1,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write(&mut self, record: &AcceptanceRecord) -> Result<(), AcceptanceError> {
|
||||
if record.sequence != self.next_sequence
|
||||
|| record.schema_version != ACCEPTANCE_SCHEMA_VERSION
|
||||
{
|
||||
return Err(AcceptanceError::UnsafeEvidence(
|
||||
"non-contiguous sequence or schema",
|
||||
));
|
||||
}
|
||||
let encoded = serde_json::to_vec(record)?;
|
||||
if encoded.len() > MAX_EVIDENCE_RECORD_BYTES {
|
||||
return Err(AcceptanceError::UnsafeEvidence("record exceeds bound"));
|
||||
}
|
||||
let lower = String::from_utf8_lossy(&encoded).to_ascii_lowercase();
|
||||
if encoded
|
||||
.windows(ACCEPTANCE_SECRET_CANARY.len())
|
||||
.any(|window| window == ACCEPTANCE_SECRET_CANARY.as_bytes())
|
||||
|| [
|
||||
"password",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"capability_url",
|
||||
"prompt_body",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
{
|
||||
return Err(AcceptanceError::UnsafeEvidence("secret marker detected"));
|
||||
}
|
||||
self.output.write_all(&encoded)?;
|
||||
self.output.write_all(b"\n")?;
|
||||
self.output.flush()?;
|
||||
self.next_sequence += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Result<PathBuf, AcceptanceError> {
|
||||
self.output.flush()?;
|
||||
self.output.get_ref().sync_all()?;
|
||||
Ok(self.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct LiveGridOptIns {
|
||||
pub login: bool,
|
||||
pub chat_and_im: bool,
|
||||
pub script_delivery: bool,
|
||||
pub landmarks_and_roaming: bool,
|
||||
pub reversible_build: bool,
|
||||
pub visual_capture: bool,
|
||||
}
|
||||
|
||||
impl LiveGridOptIns {
|
||||
#[must_use]
|
||||
pub fn from_environment(get: impl Fn(&str) -> Option<String>) -> Self {
|
||||
Self {
|
||||
login: confirmed(&get, "METACRATE_AGENT_LIVE_LOGIN", "LOGIN"),
|
||||
chat_and_im: confirmed(&get, "METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"),
|
||||
script_delivery: confirmed(&get, "METACRATE_AGENT_LIVE_SCRIPT", "SCRIPT"),
|
||||
landmarks_and_roaming: confirmed(&get, "METACRATE_AGENT_LIVE_LANDMARKS", "LANDMARKS"),
|
||||
reversible_build: confirmed(&get, "METACRATE_AGENT_LIVE_BUILD", "BUILD-CLEANUP"),
|
||||
visual_capture: confirmed(&get, "METACRATE_AGENT_LIVE_VISUAL", "VISUAL"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<Self, AcceptanceError> {
|
||||
if !self.login && self != Self::default() {
|
||||
return Err(AcceptanceError::Gate(
|
||||
"live action opt-ins require the separate login opt-in",
|
||||
));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
fn confirmed(get: &impl Fn(&str) -> Option<String>, name: &str, literal: &str) -> bool {
|
||||
get(name).is_some_and(|value| value == literal)
|
||||
}
|
||||
|
||||
struct AcceptanceResponder;
|
||||
|
||||
impl InteractionResponder for AcceptanceResponder {
|
||||
fn respond(&self, _: ResponseRequest, _: CancellationToken) -> ResponderFuture<'_> {
|
||||
Box::pin(async {
|
||||
VisibleResponse::new("acceptance response").map_err(|_| InteractionModelError::Failed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn run_deterministic_acceptance(
|
||||
path: impl AsRef<Path>,
|
||||
budgets: AcceptanceBudgets,
|
||||
) -> Result<PathBuf, AcceptanceError> {
|
||||
let mut writer = AcceptanceEvidenceWriter::create(path)?;
|
||||
let config = AgentConfig::offline(
|
||||
"https://acceptance.invalid/v1/chat/completions",
|
||||
ACCEPTANCE_SECRET_CANARY,
|
||||
)
|
||||
.map_err(|_| AcceptanceError::Gate("offline config rejected"))?;
|
||||
let interaction_settings = config.interaction.clone();
|
||||
let mut metrics = serde_json::Map::new();
|
||||
metrics.insert(
|
||||
"grid_queue_limit".into(),
|
||||
config.limits.grid_event_queue.into(),
|
||||
);
|
||||
metrics.insert(
|
||||
"control_queue_limit".into(),
|
||||
config.limits.control_queue.into(),
|
||||
);
|
||||
metrics.insert(
|
||||
"observable_queue_limit".into(),
|
||||
config.limits.observable_queue.into(),
|
||||
);
|
||||
if [
|
||||
config.limits.grid_event_queue,
|
||||
config.limits.control_queue,
|
||||
config.limits.observable_queue,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|limit| limit > budgets.queue_items)
|
||||
{
|
||||
return Err(AcceptanceError::Gate(
|
||||
"queue limit exceeds acceptance budget",
|
||||
));
|
||||
}
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
1,
|
||||
"configuration_and_bounds",
|
||||
Duration::ZERO,
|
||||
None,
|
||||
"validated",
|
||||
metrics,
|
||||
))?;
|
||||
|
||||
let started = Instant::now();
|
||||
let mut service = AgentService::offline(config)
|
||||
.map_err(|_| AcceptanceError::Gate("offline composition failed"))?
|
||||
.start()
|
||||
.map_err(|_| AcceptanceError::Gate("offline service failed to start"))?;
|
||||
loop {
|
||||
let event = tokio::time::timeout(
|
||||
Duration::from_millis(budgets.startup_millis),
|
||||
service.next_event(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::BudgetExceeded {
|
||||
stage: "startup",
|
||||
observed: budgets.startup_millis + 1,
|
||||
budget: budgets.startup_millis,
|
||||
})?
|
||||
.ok_or(AcceptanceError::Gate("service closed before readiness"))?;
|
||||
if matches!(event, ObservableEvent::Grid(event) if event.kind == GridEventKind::BackendReady)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let startup = started.elapsed();
|
||||
check_budget("startup", startup, budgets.startup_millis)?;
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
2,
|
||||
"headless_startup",
|
||||
startup,
|
||||
Some(Duration::from_millis(budgets.startup_millis)),
|
||||
"ready",
|
||||
serde_json::Map::new(),
|
||||
))?;
|
||||
|
||||
let controlled = Instant::now();
|
||||
service
|
||||
.command(ControlCommand::Pause)
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::Gate("pause rejected"))?;
|
||||
service
|
||||
.command(ControlCommand::Resume)
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::Gate("resume rejected"))?;
|
||||
let control = controlled.elapsed();
|
||||
check_budget("control", control, budgets.control_millis)?;
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
3,
|
||||
"control_conformance",
|
||||
control,
|
||||
Some(Duration::from_millis(budgets.control_millis)),
|
||||
"pause_resume_accepted",
|
||||
serde_json::Map::new(),
|
||||
))?;
|
||||
|
||||
let conversation = Arc::new(
|
||||
ConversationStore::open(ConversationLimits::default(), None)
|
||||
.map_err(|_| AcceptanceError::Gate("conversation composition failed"))?,
|
||||
);
|
||||
let interaction_grid = FakeGrid::scripted([]);
|
||||
let mut interaction = InteractionCoordinator::new(
|
||||
interaction_settings,
|
||||
UUID::new_with_u_int64(135).map_err(|_| AcceptanceError::Gate("self UUID failed"))?,
|
||||
std::collections::BTreeSet::new(),
|
||||
conversation,
|
||||
Arc::new(AcceptanceResponder),
|
||||
Arc::new(interaction_grid),
|
||||
32,
|
||||
64,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.map_err(|_| AcceptanceError::Gate("interaction composition failed"))?
|
||||
.start();
|
||||
interaction
|
||||
.connected(1)
|
||||
.map_err(|_| AcceptanceError::Gate("interaction connection failed"))?;
|
||||
let chat_started = Instant::now();
|
||||
interaction
|
||||
.submit(
|
||||
InboundInteraction::new(
|
||||
"acceptance-chat",
|
||||
UUID::new_with_u_int64(136)
|
||||
.map_err(|_| AcceptanceError::Gate("sender UUID failed"))?,
|
||||
"Acceptance Avatar",
|
||||
InteractionChannel::PublicChat,
|
||||
InboundSource::Avatar,
|
||||
ImDialogKind::OneToOneMessage,
|
||||
true,
|
||||
false,
|
||||
"metacrate status?",
|
||||
)
|
||||
.map_err(|_| AcceptanceError::Gate("acceptance interaction rejected"))?,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::Gate("chat scheduling queue rejected"))?;
|
||||
tokio::time::timeout(Duration::from_millis(budgets.chat_schedule_millis), async {
|
||||
loop {
|
||||
if matches!(
|
||||
interaction.next_observation().await,
|
||||
Some(InteractionObservation::IntentRouted { .. })
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::BudgetExceeded {
|
||||
stage: "chat_schedule",
|
||||
observed: budgets.chat_schedule_millis + 1,
|
||||
budget: budgets.chat_schedule_millis,
|
||||
})?;
|
||||
let chat_schedule = chat_started.elapsed();
|
||||
interaction
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::Gate("interaction shutdown failed"))?;
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
4,
|
||||
"chat_scheduling",
|
||||
chat_schedule,
|
||||
Some(Duration::from_millis(budgets.chat_schedule_millis)),
|
||||
"intent_routed_before_inference",
|
||||
serde_json::Map::new(),
|
||||
))?;
|
||||
|
||||
let stopping = Instant::now();
|
||||
service
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::Gate("service shutdown failed"))?;
|
||||
let shutdown = stopping.elapsed();
|
||||
check_budget("shutdown", shutdown, budgets.shutdown_millis)?;
|
||||
if service.state() != ServiceState::Stopped || service.active_task_count() != 0 {
|
||||
return Err(AcceptanceError::Gate("headless service leaked tasks"));
|
||||
}
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
5,
|
||||
"clean_shutdown",
|
||||
shutdown,
|
||||
Some(Duration::from_millis(budgets.shutdown_millis)),
|
||||
"zero_owned_tasks",
|
||||
serde_json::Map::new(),
|
||||
))?;
|
||||
|
||||
let grid = FakeGrid::scripted([
|
||||
GridScriptStep::Ready,
|
||||
GridScriptStep::MaintenanceDisconnect,
|
||||
GridScriptStep::Ready,
|
||||
GridScriptStep::Hold,
|
||||
]);
|
||||
let backend: Arc<dyn GridSessionBackend> = Arc::new(grid.clone());
|
||||
let reconnect_started = Instant::now();
|
||||
let mut session = SessionSupervisor::new(
|
||||
backend,
|
||||
ReconnectPolicy {
|
||||
initial_delay: Duration::from_millis(10),
|
||||
maximum_delay: Duration::from_millis(20),
|
||||
stable_reset_after: Duration::from_secs(1),
|
||||
shutdown_deadline: Duration::from_secs(1),
|
||||
jitter_basis_points: 0,
|
||||
instance_seed: 135,
|
||||
offline_work_capacity: 16,
|
||||
},
|
||||
16,
|
||||
64,
|
||||
)
|
||||
.map_err(|_| AcceptanceError::Gate("session composition failed"))?
|
||||
.start();
|
||||
let final_generation =
|
||||
tokio::time::timeout(Duration::from_millis(budgets.reconnect_millis), async {
|
||||
let mut first_online = None;
|
||||
loop {
|
||||
let Some(SessionObservation::Transition { status, .. }) =
|
||||
session.next_observation().await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if status.state == SessionState::Online {
|
||||
if let Some(first) = first_online {
|
||||
if status.generation > first {
|
||||
break status.generation;
|
||||
}
|
||||
} else {
|
||||
first_online = Some(status.generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::BudgetExceeded {
|
||||
stage: "reconnect",
|
||||
observed: budgets.reconnect_millis + 1,
|
||||
budget: budgets.reconnect_millis,
|
||||
})?;
|
||||
let reconnect = reconnect_started.elapsed();
|
||||
session
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| AcceptanceError::Gate("session shutdown failed"))?;
|
||||
if grid.active_sessions() != 0 || grid.max_sessions() != 1 {
|
||||
return Err(AcceptanceError::Gate("session generation leaked"));
|
||||
}
|
||||
let mut reconnect_metrics = serde_json::Map::new();
|
||||
reconnect_metrics.insert("generation".into(), final_generation.into());
|
||||
reconnect_metrics.insert(
|
||||
"maximum_parallel_sessions".into(),
|
||||
grid.max_sessions().into(),
|
||||
);
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
6,
|
||||
"maintenance_reconnect",
|
||||
reconnect,
|
||||
Some(Duration::from_millis(budgets.reconnect_millis)),
|
||||
"generation_rolled_over",
|
||||
reconnect_metrics,
|
||||
))?;
|
||||
|
||||
let corpus = adversarial_corpus();
|
||||
if corpus.len() < 17 || !corpus.iter().any(|vector| vector.id == "secret-canary") {
|
||||
return Err(AcceptanceError::Gate(
|
||||
"adversarial policy corpus incomplete",
|
||||
));
|
||||
}
|
||||
let mut audit_metrics = serde_json::Map::new();
|
||||
audit_metrics.insert("adversarial_vectors".into(), corpus.len().into());
|
||||
audit_metrics.insert("protocol_events".into(), grid.evidence().len().into());
|
||||
writer.write(&AcceptanceRecord::passed(
|
||||
7,
|
||||
"policy_redaction_and_protocol_audit",
|
||||
Duration::ZERO,
|
||||
None,
|
||||
"complete",
|
||||
audit_metrics,
|
||||
))?;
|
||||
writer.finish()
|
||||
}
|
||||
|
||||
fn check_budget(
|
||||
stage: &'static str,
|
||||
duration: Duration,
|
||||
budget: u64,
|
||||
) -> Result<(), AcceptanceError> {
|
||||
let observed = millis(duration);
|
||||
if observed > budget {
|
||||
Err(AcceptanceError::BudgetExceeded {
|
||||
stage,
|
||||
observed,
|
||||
budget,
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn millis(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
fn unix_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, millis)
|
||||
}
|
||||
fn stable_hash(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
digest
|
||||
.iter()
|
||||
.fold(String::with_capacity(64), |mut output, byte| {
|
||||
let _ = write!(output, "{byte:02x}");
|
||||
output
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! creates tasks or calls a backend. The core has no signal, terminal, path,
|
||||
//! subprocess, provider-SDK, or platform-specific dependency.
|
||||
|
||||
pub mod acceptance;
|
||||
pub mod backend;
|
||||
pub mod behavior;
|
||||
pub mod build;
|
||||
@@ -55,6 +56,11 @@ mod tui_tests;
|
||||
#[cfg(test)]
|
||||
mod vision_tests;
|
||||
|
||||
pub use acceptance::{
|
||||
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError,
|
||||
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
|
||||
run_deterministic_acceptance,
|
||||
};
|
||||
pub use backend::{
|
||||
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
||||
WorldMutator,
|
||||
|
||||
@@ -36,12 +36,15 @@ enum Operation {
|
||||
Tui,
|
||||
TuiClient,
|
||||
PrintPaths,
|
||||
Acceptance,
|
||||
CheckLiveOptIns,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Options {
|
||||
config: Option<PathBuf>,
|
||||
operation: Operation,
|
||||
evidence: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Option<Options>, CliError> {
|
||||
@@ -57,7 +60,7 @@ fn options() -> Result<Option<Options>, CliError> {
|
||||
}
|
||||
if argument == "--help" || argument == "-h" {
|
||||
println!(
|
||||
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths]\n\
|
||||
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths | --acceptance-evidence PATH | --check-live-opt-ins]\n\
|
||||
Configuration precedence: defaults < JSON < secret files < environment.\n\
|
||||
With no --config, the platform default is used when it exists."
|
||||
);
|
||||
@@ -76,6 +79,15 @@ fn options() -> Result<Option<Options>, CliError> {
|
||||
set_operation(&mut result, Operation::TuiClient)?;
|
||||
} else if argument == "--print-paths" {
|
||||
set_operation(&mut result, Operation::PrintPaths)?;
|
||||
} else if argument == "--acceptance-evidence" {
|
||||
set_operation(&mut result, Operation::Acceptance)?;
|
||||
let path = arguments
|
||||
.next()
|
||||
.ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?;
|
||||
count += 1;
|
||||
result.evidence = Some(PathBuf::from(path));
|
||||
} else if argument == "--check-live-opt-ins" {
|
||||
set_operation(&mut result, Operation::CheckLiveOptIns)?;
|
||||
} else if argument == "--config" {
|
||||
let path = arguments
|
||||
.next()
|
||||
@@ -102,6 +114,7 @@ fn set_operation(options: &mut Options, operation: Operation) -> Result<(), CliE
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let Some(options) = options()? else {
|
||||
return Ok(());
|
||||
@@ -112,6 +125,36 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
println!("data={}", platform_paths.data_directory.display());
|
||||
return Ok(());
|
||||
}
|
||||
if options.operation == Operation::Acceptance {
|
||||
let evidence = options
|
||||
.evidence
|
||||
.ok_or_else(|| CliError("acceptance evidence path is required".into()))?;
|
||||
let written = metacrate_grid_agent::run_deterministic_acceptance(
|
||||
evidence,
|
||||
metacrate_grid_agent::AcceptanceBudgets::default(),
|
||||
)
|
||||
.await?;
|
||||
println!(
|
||||
"deterministic acceptance passed; evidence={}",
|
||||
written.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if options.operation == Operation::CheckLiveOptIns {
|
||||
let opt_ins =
|
||||
metacrate_grid_agent::LiveGridOptIns::from_environment(|name| std::env::var(name).ok())
|
||||
.validate()?;
|
||||
println!(
|
||||
"live opt-ins: login={} chat_im={} script={} landmarks={} build={} visual={}",
|
||||
opt_ins.login,
|
||||
opt_ins.chat_and_im,
|
||||
opt_ins.script_delivery,
|
||||
opt_ins.landmarks_and_roaming,
|
||||
opt_ins.reversible_build,
|
||||
opt_ins.visual_capture
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let mut loader = ConfigLoader::new();
|
||||
let config_path = options.config.or_else(|| {
|
||||
platform_paths
|
||||
|
||||
130
crates/metacrate-grid-agent/tests/acceptance_gate.rs
Normal file
130
crates/metacrate-grid-agent/tests/acceptance_gate.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use metacrate_grid_agent::{
|
||||
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets,
|
||||
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
|
||||
run_deterministic_acceptance,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temporary(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"metacrate-acceptance-{}-{name}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deterministic_gate_writes_ordered_redacted_evidence_and_drains() {
|
||||
let path = temporary("gate.jsonl");
|
||||
run_deterministic_acceptance(&path, AcceptanceBudgets::default())
|
||||
.await
|
||||
.expect("acceptance gate");
|
||||
let text = fs::read_to_string(&path).expect("evidence");
|
||||
assert!(!text.contains(ACCEPTANCE_SECRET_CANARY));
|
||||
let records = text
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<AcceptanceRecord>(line).expect("record"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(records.len(), 7);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.all(|record| record.status == AcceptanceStatus::Passed)
|
||||
);
|
||||
assert_eq!(
|
||||
records
|
||||
.iter()
|
||||
.map(|record| record.sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7]
|
||||
);
|
||||
for required in [
|
||||
"headless_startup",
|
||||
"control_conformance",
|
||||
"chat_scheduling",
|
||||
"clean_shutdown",
|
||||
"maintenance_reconnect",
|
||||
"policy_redaction_and_protocol_audit",
|
||||
] {
|
||||
assert!(records.iter().any(|record| record.stage == required));
|
||||
}
|
||||
assert!(
|
||||
run_deterministic_acceptance(&path, AcceptanceBudgets::default())
|
||||
.await
|
||||
.is_err(),
|
||||
"evidence is never overwritten"
|
||||
);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_actions_require_exact_independent_confirmations() {
|
||||
let mut values = BTreeMap::new();
|
||||
values.insert("METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM");
|
||||
let action_without_login =
|
||||
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string));
|
||||
assert!(action_without_login.validate().is_err());
|
||||
values.insert("METACRATE_AGENT_LIVE_LOGIN", "LOGIN");
|
||||
values.insert("METACRATE_AGENT_LIVE_BUILD", "wrong");
|
||||
let confirmed =
|
||||
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string))
|
||||
.validate()
|
||||
.unwrap();
|
||||
assert!(confirmed.login && confirmed.chat_and_im);
|
||||
assert!(!confirmed.reversible_build);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_writer_rejects_secret_markers_and_schema_is_committed() {
|
||||
let path = temporary("unsafe.jsonl");
|
||||
let mut writer = AcceptanceEvidenceWriter::create(&path).unwrap();
|
||||
let record = AcceptanceRecord {
|
||||
schema_version: ACCEPTANCE_SCHEMA_VERSION,
|
||||
sequence: 1,
|
||||
unix_millis: 0,
|
||||
package_version: "test".into(),
|
||||
source_revision: "test".into(),
|
||||
rust_toolchain: "test".into(),
|
||||
command: "test".into(),
|
||||
profile: "test".into(),
|
||||
grid_type: "test".into(),
|
||||
endpoint_capabilities: Vec::new(),
|
||||
stage: "canary".into(),
|
||||
status: AcceptanceStatus::Failed,
|
||||
duration_millis: 0,
|
||||
budget_millis: None,
|
||||
outcome_code: ACCEPTANCE_SECRET_CANARY.into(),
|
||||
evidence_sha256: "0".repeat(64),
|
||||
metrics: serde_json::Map::new(),
|
||||
limitations: Vec::new(),
|
||||
};
|
||||
assert!(writer.write(&record).is_err());
|
||||
drop(writer);
|
||||
fs::remove_file(path).unwrap();
|
||||
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let schema =
|
||||
fs::read_to_string(workspace.join("config/grid-agent-acceptance-evidence.schema.json"))
|
||||
.unwrap();
|
||||
let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
|
||||
assert_eq!(
|
||||
schema["properties"]["schema_version"]["const"],
|
||||
ACCEPTANCE_SCHEMA_VERSION
|
||||
);
|
||||
let report = fs::read_to_string(workspace.join("docs/grid-agent-acceptance.md")).unwrap();
|
||||
for required in [
|
||||
"Deterministic gate",
|
||||
"Live-grid matrix",
|
||||
"Resource budgets",
|
||||
"Remaining limitations",
|
||||
"#128",
|
||||
"#134",
|
||||
] {
|
||||
assert!(report.contains(required), "report missing {required}");
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() {
|
||||
#[test]
|
||||
fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::with_capacity(37);
|
||||
let mut files = Vec::with_capacity(38);
|
||||
collect_rust_files(&source, &mut files);
|
||||
assert!(
|
||||
files.len() <= 37,
|
||||
files.len() <= 38,
|
||||
"source-file count needs a reviewed bound update"
|
||||
);
|
||||
for path in files {
|
||||
|
||||
154
docs/grid-agent-acceptance.md
Normal file
154
docs/grid-agent-acceptance.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Grid-agent milestone 14 acceptance
|
||||
|
||||
This is the reproducible acceptance contract for the native Rust
|
||||
`metacrate-grid-agent`. The machine-readable record schema is
|
||||
[`../config/grid-agent-acceptance-evidence.schema.json`](../config/grid-agent-acceptance-evidence.schema.json).
|
||||
Evidence is intentionally a summary of observable triggers, policy decisions,
|
||||
actions, and outcomes; it never contains prompts, message bodies, credentials,
|
||||
capability URLs, session identifiers, hidden model reasoning, or raw scene data.
|
||||
|
||||
## Deterministic gate
|
||||
|
||||
Run the complete credential-free package suite and create a new evidence file:
|
||||
|
||||
```sh
|
||||
cargo test --locked -p metacrate-grid-agent --all-features
|
||||
cargo run --locked -p metacrate-grid-agent -- \
|
||||
--acceptance-evidence /tmp/metacrate-grid-agent-acceptance.jsonl
|
||||
```
|
||||
|
||||
The writer uses create-new semantics, a 32-KiB record ceiling, contiguous
|
||||
sequence numbers, schema version 1, a secret-canary scan, per-record flush, and
|
||||
final filesystem synchronization. It will not replace previous evidence. The
|
||||
runner exercises the production configuration, `AgentService`, control queue,
|
||||
session supervisor, reconnect generation fence, shutdown joins, unified fake
|
||||
grid evidence, and adversarial policy/redaction corpus. The rest of the package
|
||||
suite supplies the complete scenario matrix, policy registrations, control/TUI
|
||||
conformance, journal corruption/recovery, conversation recovery, model
|
||||
transport failures, visual fallback, and bounded-load cases.
|
||||
|
||||
The evidence stages are `configuration_and_bounds`, `headless_startup`,
|
||||
`control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and
|
||||
`policy_redaction_and_protocol_audit`. A successful run has six ordered
|
||||
`passed` records and leaves zero service tasks, grid sessions, or loopback
|
||||
sockets. Headless startup never creates a TUI. Integrated and split UI clients
|
||||
exercise the same versioned control protocol, commands, event model, and
|
||||
graceful-shutdown target in their conformance tests.
|
||||
|
||||
Reference deterministic run on 2026-08-18: all 137 library scenarios, 37
|
||||
integration scenarios, and one compile-fail documentation case passed with all
|
||||
features; the seven-stage evidence command passed with zero leaked tasks,
|
||||
sessions, or sockets. The completed live-grid release binary was 28,857,688
|
||||
bytes with SHA-256
|
||||
`b2c93a6acd6fa856669fdb4e08110d700218255f57ec2f7908977eeabea7c470`,
|
||||
under the 40-MiB budget. The record identifies the package, pinned Rust toolchain,
|
||||
source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic
|
||||
command, fake/live profile, grid type, endpoint capability profile, timestamp,
|
||||
outcome, duration, metrics, and limitations. CI supplies the source commit at
|
||||
artifact collection time; an ad-hoc dirty-tree run deliberately says
|
||||
`working-tree-unrecorded` rather than inventing provenance.
|
||||
|
||||
## Resource budgets
|
||||
|
||||
The checked defaults are deliberately generous enough for loaded CI runners
|
||||
but small enough to expose deadlocks and unbounded ownership:
|
||||
|
||||
| Measurement | Budget | Rationale |
|
||||
| --- | ---: | --- |
|
||||
| Offline readiness | 2,000 ms | Local tasks have no network dependency. |
|
||||
| Control enqueue | 250 ms | Pause/resume must remain interactive under bounded backpressure. |
|
||||
| Chat scheduling, excluding inference | 500 ms | Includes the intentional 250-ms fragment debounce plus loaded-runner scheduling, without constraining the endpoint. |
|
||||
| Maintenance reconnect | 2,000 ms | Test policy uses a 10-ms initial backoff; the production default starts at 1 s. |
|
||||
| Ordered shutdown | 5,000 ms | Allows journal flush and task joins while remaining service-manager friendly. |
|
||||
| Steady/peak memory | 128/256 MiB | Includes bounded queues, conversations, observations, and one visual frame. |
|
||||
| Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32–512. |
|
||||
| Release binary | 40 MiB | The completed live-grid release is 28,857,688 bytes. |
|
||||
| Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. |
|
||||
|
||||
Startup, control, reconnect, and shutdown are measured by monotonic time.
|
||||
Queue limits and task/session high-water marks are asserted directly. Memory,
|
||||
binary, and journal limits are structural budgets: heap-bearing collections are
|
||||
bounded at construction, the release artifact is measured in
|
||||
[the release evidence](grid-agent-release-evidence.md), and journal rotation
|
||||
enforces its configured byte ceiling. Operators may additionally record RSS
|
||||
with their platform service manager; RSS is not used as a portable CI assertion
|
||||
because allocator and OS accounting are not comparable across platforms.
|
||||
|
||||
## Live-grid matrix
|
||||
|
||||
Live validation is optional and requires a dedicated avatar, controlled land,
|
||||
and an operator-supplied OpenAI-compatible endpoint. Credentials alone grant no
|
||||
consent. Inspect exact confirmations without contacting either service:
|
||||
|
||||
```sh
|
||||
metacrate-grid-agent --check-live-opt-ins
|
||||
```
|
||||
|
||||
Each capability has a separate exact-value environment opt-in:
|
||||
|
||||
| Capability | Variable and required literal |
|
||||
| --- | --- |
|
||||
| Login/relogin | `METACRATE_AGENT_LIVE_LOGIN=LOGIN` |
|
||||
| Public mention and authorized/unprivileged IM | `METACRATE_AGENT_LIVE_CHAT_IM=CHAT-IM` |
|
||||
| Controlled LSL delivery | `METACRATE_AGENT_LIVE_SCRIPT=SCRIPT` |
|
||||
| Landmark offer, teleport, and bounded roaming | `METACRATE_AGENT_LIVE_LANDMARKS=LANDMARKS` |
|
||||
| Reversible prim build and cleanup | `METACRATE_AGENT_LIVE_BUILD=BUILD-CLEANUP` |
|
||||
| Synthetic visual capture and visual question | `METACRATE_AGENT_LIVE_VISUAL=VISUAL` |
|
||||
|
||||
Any action opt-in without the login opt-in fails closed. A misspelled value is
|
||||
false. Store the grid password and endpoint key in restrictive `_FILE` inputs
|
||||
described by [the operations guide](grid-agent-operations.md), never in these
|
||||
variables or a command line.
|
||||
|
||||
For an authorized live run, start split mode so another terminal can reconnect
|
||||
the TUI without affecting the agent session. Record UTC start/end, commit,
|
||||
binary hash, generic grid type (for example `OpenSim-compatible`), endpoint
|
||||
capabilities (`text`, `tools`, and optionally `image_input`), and evidence-file
|
||||
hash. Do not record vendor presets or identifiers. Exercise, in order:
|
||||
|
||||
1. Login and full readiness; force a maintenance reconnect and confirm the
|
||||
generation changes with one session maximum.
|
||||
2. Public mention gets an informational reply; a public command is denied.
|
||||
Send unprivileged and authorized IMs and verify their separate sessions,
|
||||
exact rollover, facing/attention event, and perception queries.
|
||||
3. Pause, cancel, approve, resume, and force reconnect from the control client;
|
||||
disconnect/reconnect the TUI and verify the service remains headless-safe.
|
||||
4. With the script opt-in, deliver only to the controlled recipient and record
|
||||
the returned inventory ID and permissions. Advanced mutation requires an
|
||||
explicit approval and conservative script size/runtime limits.
|
||||
5. With the build opt-in, build only on controlled land, record transaction and
|
||||
object recovery IDs locally, verify no currency operation exists, and delete
|
||||
every created prim through the ownership-checked cleanup path.
|
||||
6. With the landmark opt-in, accept a controlled offer, use a short bounded
|
||||
folder schedule, teleport, then disable the schedule. With visual opt-in,
|
||||
capture the synthetic scene and ask one visual question. Record the endpoint
|
||||
capability fallback if image input is rejected.
|
||||
7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory
|
||||
offers, owned test prims, tasks, sockets, or sessions. List any unavoidable
|
||||
inventory artifact and its manual recovery ID in private operator notes,
|
||||
never committed evidence.
|
||||
|
||||
The structured journal and control/TUI views must show the same correlation IDs
|
||||
from inbound trigger through inference summary, proposed tool, policy/approval,
|
||||
execution, and result. This is observable action provenance, not chain-of-thought.
|
||||
|
||||
## Milestone handoff
|
||||
|
||||
Milestone implementation links: #128 architecture/configuration, #129 LLM and
|
||||
policy, #130 lifecycle/conversation/interaction, #131 embodiment and world
|
||||
tools, #132 visual capture and TUI, #133 deterministic protocol harness, and
|
||||
#134 operations/secret handling. Their focused tests remain the authoritative
|
||||
compatibility cases; this final gate integrates rather than duplicates them.
|
||||
|
||||
Routine Gitea jobs remain `ubuntu-latest` only. Portable target checks are
|
||||
defined in `ci/release-matrix.json`; a local Windows GNU check additionally
|
||||
needs `x86_64-w64-mingw32-gcc` for the existing AWS-LC build.
|
||||
|
||||
## Remaining limitations
|
||||
|
||||
No public CI runner performs live actions, holds credentials, measures portable
|
||||
RSS, or proves a particular provider's image capability. Live evidence is only
|
||||
credible when an operator supplies all exact opt-ins and completes the matrix
|
||||
on a dedicated account. The deterministic gate is therefore the required CI
|
||||
acceptance record; a live report supplements it and must state any skipped
|
||||
capability, endpoint fallback, or manually recoverable artifact explicitly.
|
||||
Reference in New Issue
Block a user