原子化隔离 Agent 结果认领

为多 ready all-join 增加持久 claim journal 和全锁预取
补齐旧 partial claim、跨 action 重放与未观察完成门禁
修复 Agent DB 撕裂尾行后的幂等认领审计
保证 readyIsolatedJoins 完整输出并封堵多 owner 冲突
补充锁竞争、迁移恢复和全量回归验证
同步 Runtime V1.35 技术方案与共享决策
This commit is contained in:
AIGameCreator App
2026-07-18 04:59:08 +08:00
parent edfeeff701
commit 6299faa81e
7 changed files with 2170 additions and 101 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,7 @@
use super::agent::{sanitize_prompt_context, write_agent_runtime_json_sidecar_with_max_bytes};
use super::agent::{
read_agent_runtime_json_sidecar_with_max_bytes, sanitize_prompt_context,
write_agent_runtime_json_sidecar_with_max_bytes,
};
use super::mcp::GAME_CREATOR_MCP_CALL_TOOL;
use super::project::{
normalize_relative_path, resolve_local_project_path, unix_timestamp, validate_project_root,
@@ -28,6 +31,8 @@ pub(crate) const ISOLATED_AGENT_RESULT_SCHEMA_VERSION: &str =
"game-creator-isolated-agent-result.v1";
pub(crate) const ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION: &str =
"game-creator-isolated-agent-join-delivery.v1";
pub(crate) const ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION: &str =
"game-creator-isolated-agent-join-claim.v1";
pub(crate) const ISOLATED_AGENT_JOIN_PROMPT_SCHEMA_VERSION: &str =
"game-creator-isolated-agent-join-prompt.v1";
pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str =
@@ -72,6 +77,7 @@ const ISOLATED_AGENT_INSTANCE_DIR: &str = ".agent/runtime/isolated-agents/instan
const ISOLATED_AGENT_GROUP_DIR: &str = ".agent/runtime/isolated-agents/groups";
const ISOLATED_AGENT_RESULT_DIR: &str = ".agent/runtime/isolated-agents/results";
const ISOLATED_AGENT_JOIN_DELIVERY_DIR: &str = ".agent/runtime/isolated-agents/join-deliveries";
const ISOLATED_AGENT_JOIN_CLAIM_DIR: &str = ".agent/runtime/isolated-agents/join-claims";
const ISOLATED_AGENT_PRIVATE_MEMORY_DIR: &str = ".agent/runtime/isolated-agents/memory";
const ISOLATED_AGENT_RECORD_MAX_BYTES: usize = 512 * 1024;
const ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES: usize = 64 * 1024;
@@ -169,6 +175,14 @@ pub(crate) struct IsolatedAgentJoinDeliveryRecord {
pub(crate) updated_at: u64,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum IsolatedAgentJoinClaimStatus {
Prepared,
Committed,
Observed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct IsolatedAgentTerminalTask {
pub(crate) agent_id: String,
@@ -192,8 +206,8 @@ pub(crate) struct IsolatedAgentVerificationGateSnapshot {
pub(crate) last_verification_status: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct JoinDispatch {
pub(crate) parent_agent_id: String,
pub(crate) parent_session_id: String,
@@ -205,6 +219,18 @@ pub(crate) struct JoinDispatch {
pub(crate) prompt: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct IsolatedAgentJoinClaimRecord {
pub(crate) schema_version: String,
pub(crate) parent_agent_id: String,
pub(crate) parent_run_id: String,
pub(crate) action_id: String,
pub(crate) status: IsolatedAgentJoinClaimStatus,
pub(crate) joins: Vec<JoinDispatch>,
pub(crate) updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct IsolatedAgentBuildResult {
pub(crate) result: GameCreationIsolatedAgentChildResult,
@@ -789,8 +815,22 @@ pub(crate) fn isolated_join_completion_barrier_at(
"动态隔离 Agent group",
|record| validate_isolated_group_record(root, record),
)?;
let claims = list_isolated_join_claims_at(root)?;
let journaled_claimed_groups = claims
.iter()
.filter(|claim| {
claim.parent_agent_id == parent_agent_id && claim.parent_run_id == parent_run_id
})
.flat_map(|claim| {
claim
.joins
.iter()
.map(|join| (claim.action_id.clone(), join.delegation_group_id.clone()))
})
.collect::<BTreeSet<_>>();
let mut waiting_groups = 0usize;
let mut ready_unclaimed_groups = 0usize;
let mut unjournaled_claimed_groups = 0usize;
for group in groups.into_iter().filter(|group| {
group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id
}) {
@@ -798,21 +838,185 @@ pub(crate) fn isolated_join_completion_barrier_at(
waiting_groups = waiting_groups.saturating_add(1);
continue;
};
let claimed = read_isolated_join_delivery_at(root, &join)?.is_some_and(|delivery| {
delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent
});
if !claimed {
ready_unclaimed_groups = ready_unclaimed_groups.saturating_add(1);
match read_isolated_join_delivery_at(root, &join)? {
Some(delivery)
if delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent =>
{
let claimed_by_action_id = delivery
.claimed_by_action_id
.as_deref()
.ok_or_else(|| "动态隔离 Agent 已认领 delivery 缺少 actionId".to_string())?;
if !journaled_claimed_groups.contains(&(
claimed_by_action_id.to_string(),
join.delegation_group_id.clone(),
)) {
unjournaled_claimed_groups = unjournaled_claimed_groups.saturating_add(1);
}
}
_ => {
ready_unclaimed_groups = ready_unclaimed_groups.saturating_add(1);
}
}
}
if waiting_groups == 0 && ready_unclaimed_groups == 0 {
let unobserved_claims = claims
.iter()
.filter(|claim| {
claim.parent_agent_id == parent_agent_id
&& claim.parent_run_id == parent_run_id
&& claim.status != IsolatedAgentJoinClaimStatus::Observed
})
.count();
if waiting_groups == 0
&& ready_unclaimed_groups == 0
&& unjournaled_claimed_groups == 0
&& unobserved_claims == 0
{
return Ok(None);
}
Ok(Some(format!(
"waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · 必须调用 agent.run_status 取得并认领 all-join 后再继续"
"waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · unjournaledClaimedGroups={unjournaled_claimed_groups} · unobservedJoinClaims={unobserved_claims} · 必须调用 agent.run_status 取得并持久观察 all-join 后再继续"
)))
}
pub(crate) fn read_isolated_join_claim_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,
action_id: &str,
) -> Result<Option<IsolatedAgentJoinClaimRecord>, String> {
let relative_path =
isolated_join_claim_relative_path(parent_agent_id, parent_run_id, action_id);
let claim = read_agent_runtime_json_sidecar_with_max_bytes::<IsolatedAgentJoinClaimRecord>(
root,
&relative_path,
"动态隔离 Agent join claim",
ISOLATED_AGENT_RECORD_MAX_BYTES,
)?;
if let Some(claim) = &claim {
validate_isolated_join_claim_record(root, claim)?;
if claim.parent_agent_id != parent_agent_id
|| claim.parent_run_id != parent_run_id
|| claim.action_id != action_id
{
return Err("动态隔离 Agent join claim 文件与请求身份不一致".to_string());
}
}
Ok(claim)
}
pub(crate) fn write_isolated_join_claim_at(
root: &Path,
claim: &IsolatedAgentJoinClaimRecord,
) -> Result<(), String> {
validate_isolated_join_claim_record(root, claim)?;
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&isolated_join_claim_relative_path(
&claim.parent_agent_id,
&claim.parent_run_id,
&claim.action_id,
),
"动态隔离 Agent join claim",
claim,
ISOLATED_AGENT_RECORD_MAX_BYTES,
)
}
pub(crate) fn list_isolated_join_claims_at(
root: &Path,
) -> Result<Vec<IsolatedAgentJoinClaimRecord>, String> {
let dir = resolve_local_project_path(root, ISOLATED_AGENT_JOIN_CLAIM_DIR)?;
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(format!(
"读取动态隔离 Agent join claim 目录失败:{}: {error}",
dir.display()
))
}
};
let mut stems = BTreeSet::new();
for entry in entries {
let entry =
entry.map_err(|error| format!("读取动态隔离 Agent join claim 条目失败:{error}"))?;
let file_name = entry
.file_name()
.into_string()
.map_err(|_| "动态隔离 Agent join claim 文件名不是 UTF-8".to_string())?;
let stem = if let Some(stem) = file_name.strip_suffix(".json") {
Some(stem)
} else {
file_name
.strip_prefix('.')
.and_then(|value| value.strip_suffix(".json.previous"))
};
let Some(stem) = stem.filter(|value| value.starts_with("claim-")) else {
continue;
};
if stem.len() != "claim-".len() + 64
|| !stem["claim-".len()..]
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
return Err("动态隔离 Agent join claim 文件名无效".to_string());
}
stems.insert(stem.to_string());
}
let mut claims = Vec::with_capacity(stems.len());
for stem in stems {
let relative_path = format!("{ISOLATED_AGENT_JOIN_CLAIM_DIR}/{stem}.json");
let claim = read_agent_runtime_json_sidecar_with_max_bytes::<IsolatedAgentJoinClaimRecord>(
root,
&relative_path,
"动态隔离 Agent join claim",
ISOLATED_AGENT_RECORD_MAX_BYTES,
)?
.ok_or_else(|| format!("动态隔离 Agent join claim 在枚举后消失:{stem}"))?;
validate_isolated_join_claim_record(root, &claim)?;
if isolated_join_claim_relative_path(
&claim.parent_agent_id,
&claim.parent_run_id,
&claim.action_id,
) != relative_path
{
return Err("动态隔离 Agent join claim 文件名与记录身份不一致".to_string());
}
claims.push(claim);
}
let mut owner_by_group = BTreeMap::<String, (String, String, String)>::new();
for claim in &claims {
for join in &claim.joins {
let owner = (
claim.parent_agent_id.clone(),
claim.parent_run_id.clone(),
claim.action_id.clone(),
);
if let Some(existing) = owner_by_group.insert(join.delegation_group_id.clone(), owner) {
return Err(format!(
"动态隔离 Agent join group 同时归属多个 claim journal{} / {}:{}:{} / {}:{}:{}",
join.delegation_group_id,
existing.0,
existing.1,
existing.2,
claim.parent_agent_id,
claim.parent_run_id,
claim.action_id
));
}
}
}
Ok(claims)
}
pub(crate) fn isolated_join_claim_lock_id(
parent_agent_id: &str,
parent_run_id: &str,
action_id: &str,
) -> String {
isolated_join_claim_stem(parent_agent_id, parent_run_id, action_id)
}
pub(crate) fn read_isolated_join_delivery_at(
root: &Path,
join: &JoinDispatch,
@@ -1554,6 +1758,23 @@ fn isolated_join_delivery_relative_path(group_id: &str) -> String {
format!("{ISOLATED_AGENT_JOIN_DELIVERY_DIR}/{group_id}.json")
}
fn isolated_join_claim_relative_path(
parent_agent_id: &str,
parent_run_id: &str,
action_id: &str,
) -> String {
format!(
"{ISOLATED_AGENT_JOIN_CLAIM_DIR}/{}.json",
isolated_join_claim_stem(parent_agent_id, parent_run_id, action_id)
)
}
fn isolated_join_claim_stem(parent_agent_id: &str, parent_run_id: &str, action_id: &str) -> String {
let identity = format!("{parent_agent_id}\n{parent_run_id}\n{action_id}");
let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes()));
format!("claim-{fingerprint}")
}
fn isolated_private_memory_relative_path(instance_id: &str) -> String {
format!("{ISOLATED_AGENT_PRIVATE_MEMORY_DIR}/{instance_id}.json")
}
@@ -1572,6 +1793,48 @@ fn validate_safe_id(value: &str, label: &str, max_chars: usize) -> Result<(), St
Ok(())
}
fn validate_isolated_join_claim_record(
root: &Path,
claim: &IsolatedAgentJoinClaimRecord,
) -> Result<(), String> {
if claim.schema_version != ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION {
return Err("动态隔离 Agent join claim schemaVersion 不受支持".to_string());
}
validate_safe_id(&claim.parent_agent_id, "parentAgentId", 96)?;
validate_safe_id(&claim.parent_run_id, "parentRunId", 160)?;
validate_safe_id(&claim.action_id, "actionId", 256)?;
if claim.joins.is_empty() || claim.joins.len() > 16 {
return Err("动态隔离 Agent join claim 数量无效".to_string());
}
let mut previous_group_id: Option<&str> = None;
for join in &claim.joins {
if join.parent_agent_id != claim.parent_agent_id
|| join.parent_run_id != claim.parent_run_id
|| join.source != "agent-isolated-join"
{
return Err("动态隔离 Agent join claim 与父 run 身份不一致".to_string());
}
validate_safe_id(&join.parent_agent_id, "join.parentAgentId", 96)?;
validate_safe_id(&join.parent_session_id, "join.parentSessionId", 160)?;
validate_safe_id(&join.parent_run_id, "join.parentRunId", 160)?;
validate_safe_id(&join.parent_action_id, "join.parentActionId", 256)?;
validate_safe_id(&join.delegation_group_id, "join.delegationGroupId", 160)?;
validate_safe_id(&join.join_run_id, "join.joinRunId", 160)?;
if previous_group_id.is_some_and(|previous| previous >= join.delegation_group_id.as_str()) {
return Err(
"动态隔离 Agent join claim 必须按 delegationGroupId 严格排序且不能重复".to_string(),
);
}
let current = build_join_dispatch_if_ready_at(root, &join.delegation_group_id)?
.ok_or_else(|| "动态隔离 Agent join claim 对应 group 尚未 ready".to_string())?;
if current != *join {
return Err("动态隔离 Agent join claim 与当前 durable join 结果冲突".to_string());
}
previous_group_id = Some(&join.delegation_group_id);
}
Ok(())
}
fn is_private_or_sensitive_path(path: &str) -> bool {
let path = path.trim_start_matches("./").to_ascii_lowercase();
path == ".agent"
@@ -2150,6 +2413,27 @@ mod tests {
claimed.queued_run_id.as_deref(),
Some(&*dispatch.join_run_id)
);
let unjournaled =
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
.unwrap()
.expect("claimed delivery without journal must block completion");
assert!(
unjournaled.contains("unjournaledClaimedGroups=1"),
"{unjournaled}"
);
write_isolated_join_claim_at(
temp.path(),
&IsolatedAgentJoinClaimRecord {
schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(),
parent_agent_id: dispatch.parent_agent_id.clone(),
parent_run_id: dispatch.parent_run_id.clone(),
action_id: "run-status-action-1".to_string(),
status: IsolatedAgentJoinClaimStatus::Observed,
joins: vec![dispatch.clone()],
updated_at: unix_timestamp(),
},
)
.unwrap();
assert_eq!(
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
.unwrap(),
@@ -1608,6 +1608,58 @@ pub(crate) fn append_agent_db_record_if_missing_for_action(
)
}
pub(crate) fn append_agent_db_record_if_missing_for_action_and_delegation_group(
root: &Path,
record_type: &str,
action_id: &str,
delegation_group_id: &str,
record: serde_json::Value,
) -> Result<bool, String> {
let matches_identity = !record_type.trim().is_empty()
&& !action_id.trim().is_empty()
&& !delegation_group_id.trim().is_empty()
&& record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type)
&& record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id)
&& record
.get("delegationGroupId")
.and_then(serde_json::Value::as_str)
== Some(delegation_group_id)
&& record.get("schemaVersion").is_none()
&& record.get("updatedAt").is_none();
if !matches_identity {
return Err("Agent 本地索引 action/group 幂等记录身份不匹配".to_string());
}
#[cfg(test)]
take_agent_db_record_failure_injection(root, Some(record_type))?;
let append_class = agent_db_record_append_class(&record);
let path = root.join(".agent/agent.db");
let directory = open_agent_db_directory(root, true)?
.ok_or_else(|| "创建项目 .agent 目录失败".to_string())?;
let append_lock = project_append_lock_for(&path)?;
let _process_guard = append_lock.lock_process("Agent 本地索引")?;
verify_agent_db_directory_current(&directory)?;
let mut storage = open_agent_db_storage(directory, true, true)?
.ok_or_else(|| "创建 Agent 本地索引失败".to_string())?;
verify_agent_db_storage_current(&storage)?;
repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?;
verify_agent_db_storage_current(&storage)?;
if validate_agent_db_action_delegation_group_records_unlocked(
&mut storage.file,
&storage.path,
record_type,
action_id,
delegation_group_id,
&record,
)? {
return Ok(false);
}
let line = serialize_agent_db_record(record)?;
validate_agent_db_append_class_record_size(append_class, &line)?;
append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?;
Ok(true)
}
pub(crate) fn append_agent_db_agent_message_if_missing(
root: &Path,
agent_id: &str,
@@ -2080,6 +2132,82 @@ pub(crate) fn read_agent_db_records_bounded(
Ok((records.into_iter().collect(), truncated))
}
fn validate_agent_db_action_delegation_group_records_unlocked(
file: &mut File,
path: &Path,
record_type: &str,
action_id: &str,
delegation_group_id: &str,
expected: &serde_json::Value,
) -> Result<bool, String> {
let length = file
.metadata()
.map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))?
.len();
if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES {
return Err(format!(
"Agent 本地索引超过 {} 字节扫描上限:{}",
AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES,
path.display()
));
}
file.seek(SeekFrom::Start(0))
.map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?;
let mut reader = BufReader::new(file);
let mut record_count = 0_usize;
let mut exact_matches = 0_usize;
while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? {
if !line.complete {
return Err(format!(
"Agent 本地索引 action/group 全量扫描发现不完整 JSONL 尾记录:{}",
path.display()
));
}
if line.content.iter().all(|byte| byte.is_ascii_whitespace()) {
continue;
}
record_count = record_count.saturating_add(1);
if record_count > AGENT_DB_MAX_SCAN_RECORDS {
return Err(format!(
"Agent 本地索引超过 {} 条记录扫描上限:{}",
AGENT_DB_MAX_SCAN_RECORDS,
path.display()
));
}
let record = serde_json::from_slice::<serde_json::Value>(&line.content)
.map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?;
let matches_key = record.get("recordType").and_then(serde_json::Value::as_str)
== Some(record_type)
&& record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id)
&& record
.get("delegationGroupId")
.and_then(serde_json::Value::as_str)
== Some(delegation_group_id);
if !matches_key {
continue;
}
if !agent_db_stored_record_matches_expected_payload(&record, expected) {
return Err(format!(
"Agent 本地索引 action/group 幂等记录内容冲突:{record_type}/{action_id}/{delegation_group_id}"
));
}
exact_matches = exact_matches.saturating_add(1);
if exact_matches > 1 {
return Err(format!(
"Agent 本地索引 action/group 幂等记录重复:{record_type}/{action_id}/{delegation_group_id}"
));
}
}
if exact_matches == 0 && record_count >= AGENT_DB_MAX_SCAN_RECORDS {
return Err(format!(
"Agent 本地索引已达到 {} 条记录扫描上限,无法追加 action/group 审计:{}",
AGENT_DB_MAX_SCAN_RECORDS,
path.display()
));
}
Ok(exact_matches == 1)
}
fn validate_agent_db_action_records_unlocked(
file: &mut File,
path: &Path,
@@ -8353,6 +8481,21 @@ mod agent_db_security_tests {
})
}
fn isolated_join_claim_audit_record(
delegation_group_id: &str,
join_run_id: &str,
) -> serde_json::Value {
serde_json::json!({
"recordType": "agent.runtime.agent.isolated_join.claimed_by_parent",
"agentId": "project-supervisor",
"runId": "parent-run-1",
"parentActionId": "parent-action-1",
"delegationGroupId": delegation_group_id,
"joinRunId": join_run_id,
"actionId": TEST_ACTION_ID,
})
}
fn provider_request_id(hex: char) -> String {
format!("provider-request-{}", hex.to_string().repeat(64))
}
@@ -8658,6 +8801,131 @@ mod agent_db_security_tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn action_group_append_repairs_torn_tail_and_scans_past_bounded_history() {
const RECORD_TYPE: &str = "agent.runtime.agent.isolated_join.claimed_by_parent";
const DELEGATION_GROUP_ID: &str = "delegation-group-1";
let root = unique_agent_db_test_root("action-group-tail-repair");
let record = isolated_join_claim_audit_record(DELEGATION_GROUP_ID, "join-run-1");
assert!(
append_agent_db_record_if_missing_for_action_and_delegation_group(
&root,
RECORD_TYPE,
TEST_ACTION_ID,
DELEGATION_GROUP_ID,
record.clone(),
)
.expect("append initial action/group audit")
);
let path = root.join(".agent/agent.db");
let mut file = fs::OpenOptions::new()
.append(true)
.open(&path)
.expect("open action/group Agent DB fixture");
file.write_all(b"{}\n".repeat(AGENT_DB_MAX_BOUNDED_RECORDS + 1).as_slice())
.expect("write records beyond bounded history");
file.write_all(br#"{"recordType":"torn-action-group"#)
.expect("write torn Agent DB tail");
file.flush().expect("flush torn Agent DB fixture");
drop(file);
assert!(
!append_agent_db_record_if_missing_for_action_and_delegation_group(
&root,
RECORD_TYPE,
TEST_ACTION_ID,
DELEGATION_GROUP_ID,
record,
)
.expect("repair tail and find action/group audit from file head")
);
let content = fs::read_to_string(&path).expect("read repaired action/group Agent DB");
assert!(!content.contains("torn-action-group"));
let exact_matches = content
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.expect("repaired Agent DB contains complete JSONL")
})
.filter(|stored| {
stored.get("recordType").and_then(serde_json::Value::as_str) == Some(RECORD_TYPE)
&& stored.get("actionId").and_then(serde_json::Value::as_str)
== Some(TEST_ACTION_ID)
&& stored
.get("delegationGroupId")
.and_then(serde_json::Value::as_str)
== Some(DELEGATION_GROUP_ID)
})
.count();
assert_eq!(exact_matches, 1);
fs::remove_dir_all(root).ok();
}
#[test]
fn action_group_append_preserves_failure_injection_and_rejects_content_conflicts() {
const RECORD_TYPE: &str = "agent.runtime.agent.isolated_join.claimed_by_parent";
const DELEGATION_GROUP_ID: &str = "delegation-group-1";
let root = unique_agent_db_test_root("action-group-conflict");
fs::create_dir_all(root.join(".agent/runtime")).expect("create Agent DB runtime directory");
fs::write(
root.join(".agent/runtime/test-fail-next-agent-db-record"),
RECORD_TYPE,
)
.expect("arm Agent DB failure injection");
let record = isolated_join_claim_audit_record(DELEGATION_GROUP_ID, "join-run-1");
let injected_error = append_agent_db_record_if_missing_for_action_and_delegation_group(
&root,
RECORD_TYPE,
TEST_ACTION_ID,
DELEGATION_GROUP_ID,
record.clone(),
)
.expect_err("failure injection must run before append");
assert!(injected_error.contains("测试注入 Agent DB 记录失败"));
assert!(
append_agent_db_record_if_missing_for_action_and_delegation_group(
&root,
RECORD_TYPE,
TEST_ACTION_ID,
DELEGATION_GROUP_ID,
record,
)
.expect("append action/group audit after injected failure")
);
let conflicting =
isolated_join_claim_audit_record(DELEGATION_GROUP_ID, "join-run-conflict");
let conflict_error = append_agent_db_record_if_missing_for_action_and_delegation_group(
&root,
RECORD_TYPE,
TEST_ACTION_ID,
DELEGATION_GROUP_ID,
conflicting,
)
.expect_err("same action/group key with different content must fail closed");
assert!(conflict_error.contains("内容冲突"), "{conflict_error}");
let second_group = isolated_join_claim_audit_record("delegation-group-2", "join-run-2");
assert!(
append_agent_db_record_if_missing_for_action_and_delegation_group(
&root,
RECORD_TYPE,
TEST_ACTION_ID,
"delegation-group-2",
second_group,
)
.expect("a different delegation group is a distinct audit key")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn generic_append_rejects_action_receipts() {
let root = unique_agent_db_test_root("generic-receipt-rejected");
File diff suppressed because it is too large Load Diff
@@ -4842,3 +4842,14 @@
- 原子与恢复:新单动作在 confirmation 与 OS launcher 前拒绝,不产生 spawn、revision 或项目副作用。新多 action batch 在选择 confirmation 模式前逐项校验,任一 denied member 使整批 abort,允许成员也不执行;只保留 `aborted / nextActionIndex=0` batch 事实,不发布独立 pending sidecar。旧 pending、approval 与旧 batch 真正进入执行器时仍重验当前边界;旧 executing 未知结果继续按既有 reconciliation 规则处理,绝不 replay。
- 验证方式:新增恶意 `bash -lc` sibling 写入回归,覆盖单动作、两动作 batch、策略快照和旧 executing pending 的执行器重验;断言 sibling 文件、nested delivery、独立 pending sidecar 和 revision 变化均为 0。工具作用域单测逐项覆盖拒绝集合与保留工具;同时运行 isolated 30 项、mixed 3 项、Supervisor collaboration 27 项、Provider batch 12 项和 Tauri 全量回归。
- 真实验收边界:V1.31/V1.32 已以 isolated mutation 为 0 的真实 Provider suite 证明 mixed 协作、all-join、Runner 恢复和唯一回复;V1.34 只做安全收紧,本切片不为此重跑两套 Provider,也不能把旧 PASS 当作未来新 child 写入语义的证据。只有后续 scope-aware OS sandbox 能把有效 `writeScopes` 变成项目根其余部分只读、链接/挂载不可逃逸且所有后代继承的强制边界,并通过独立跨平台门禁后,才可在新决策中重新评估命令工具;其余拒绝能力仍需各自单独评审。
## 2026-07-18 AI 游戏创作 Agent Runtime V1.35 多 ready isolated all-join 原子认领
- 背景:同一父 run 的一次 `agent.run_status` 可以同时看到多个 ready isolated all-join。若逐个取得锁并立即改写 delivery,后一个 join 锁竞争会让前一个 group 留在部分认领状态,破坏整次 action 的可恢复原子边界。
- 锁边界:先按 `delegationGroupId` 去重排序,再按该顺序一次性预取全部 join delivery 锁;全部锁就绪前不得创建 claim sidecar 或改写 delivery。任一后续 join 锁忙时释放已取得的锁,并保证零 delivery mutation、零 claim sidecar。
- 持久恢复:全锁就绪后,同一 action 使用一个 durable claim journal,按 `prepared -> committed -> observed` 单向推进。发生部分 commit 或 Runner 退出时,恢复必须复用同一 action journal、按相同顺序幂等补齐未提交 group,不创建新 action、新 journal 或重复 delivery claim。
- Observation 与完成:只认领可完整放入本轮 `readyIsolatedJoins` 观察预算的有序前缀,该区块固定置于 `agent.run_status` detail 首部;剩余 group 保持 ready,不能把已认领结果截断后让模型猜测。只有成功 observation 已持久写入 pending sidecar 后才能标记 `observed`;任一未观察 claim 都继续阻断 finalization。每个 group 的审计以 `actionId + delegationGroupId` 唯一,恢复只补缺失记录,不重复追加。
- 旧状态恢复:每个 `claimed-by-parent` delivery 必须被同一 `claimedByActionId + delegationGroupId` 的 journal 覆盖,无 journal delivery 继续阻断完成。`agent.run_status` 先重放已有未观察 claim;随后每轮只为一个稳定排序的旧 action 合成 journal 并完整输出,恢复 action 不取得 delivery。原 action 已有 journal 但遗漏 group 时不得扩写或倒退状态,同一 group 归属其他 action journal 时按身份冲突失败关闭;pending observation 只能标记本轮完整输出的 claim。
- 审计恢复:isolated group 审计通过 Agent DB 专用锁内幂等入口追加;同一锁内先修复 JSONL 截断尾行,再从文件头扫描有效数据库的完整记录范围,以 `recordType + actionId + delegationGroupId` 核对完整 payload。重复键、内容冲突或物理容量越界均失败关闭。
- Mixed 恢复:isolated claim 已提交、同一 `run_status` 后续 static receipt 认领失败时,下一 action 先完整重放旧 isolated claim,再继续 static 认领;旧 delivery/journal 仍绑定原 action,不产生第二份 isolated claim。恢复 observation 成功持久化后才能把旧 claim 标为 `observed`
- 验收边界:覆盖后一个 join 锁冲突、mixed static 锁失败后新 action 重放 isolated 结果、Agent DB torn tail 后 prepared/partial claim 恢复、多旧 action 逐轮迁移、已有 journal 单调性与跨 action group 归属冲突;完整 observation 必须实际包含被标记 observed 的全部 group。`isolated` 36/36、`project_supervisor` 42/42、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 已通过,Tauri/Rust 全量为 915 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider 验收,不得把本地结果扩大解释为外部模型链路已重新通过。V1.35 不等于 V1.34 的 scope-aware OS sandbox 已完成;后者仍未完成,V1.34 的动态 isolated child 工具禁用边界继续有效。
@@ -1219,6 +1219,32 @@ V1.31 与 V1.32 的真实 Provider suite 已分别证明 mixed static/isolated
后续只有 scope-aware OS sandbox 能把 child 的有效 `writeScopes` 转换为 OS 强制边界,保证项目根其余部分只读、链接和挂载不能逃逸、所有 shell/构建器/hook/后代进程继承同一限制,并通过跨平台越界写与恢复测试后,才可在新的版本决策中重新评估 `project.verify / command.exec / command.start / command.stdin / preview.start`。scope-aware sandbox 是重新开放命令的必要条件而非自动授权;`project.git_commit`、委派、共享控制面写入、素材生成和 MCP 仍需各自的独立安全决策,模板或项目 policy 不得提前开放。
## V1.35 多 ready isolated all-join 原子认领与恢复
同一父 run 的一次 `agent.run_status` 可能同时看到多个 ready isolated all-join。V1.35 把这些 group 收口到同一个 action 级认领协议,避免前一个 join 已发生 delivery mutation、后一个 join 因锁竞争失败而留下半完成认领。
### 全锁预取与持久认领
- Runtime 先按 `delegationGroupId` 对当前父 run 的 ready all-join 去重排序,再按该稳定顺序一次性预取全部 join delivery 锁。只有全部锁均已取得,才允许创建 durable claim journal 或改写任一 delivery。
- 任一后续 join 锁忙时,必须释放本次已经取得的锁并保持零 delivery mutation、零 claim sidecar;不得先认领先序 group,也不得留下可被恢复流程误判为部分提交的 journal。
- 全锁就绪后,以当前 `actionId` 和完整有序 group 集合创建同一个 durable claim journal,并按 `prepared -> committed -> observed` 单向推进。`prepared` 后发生部分 delivery commit 或 Runner 退出时,恢复必须复用同一 action journal、按相同锁顺序幂等补齐尚未提交的 group,再推进到 `committed`;不得生成新 action、新 claim sidecar 或重复认领已经绑定的 delivery。
- Runtime 只认领能够完整放入本轮 `readyIsolatedJoins` 私有观察预算的有序前缀,剩余 ready group 保持未认领并由后续 action 继续取得;`readyIsolatedJoins` 固定置于 `agent.run_status` detail 首部,不能再被普通状态、claimed 目录或 mixed static receipt 截掉。单个 group 已超过完整观察上限时在任何 claim mutation 前失败关闭。
### Observation、完成门禁与审计
- `committed` 只表示全部 delivery 已绑定当前 action,不表示模型已经观察结果。只有成功 `agent.run_status` observation 已持久写入该 action 的 pending sidecar 后,claim journal 才能标记为 `observed`observation 或 sidecar 持久化失败时保持未观察状态并由同一 action 恢复补齐。
- 若 isolated claim 已 `committed`,但同一次 mixed `agent.run_status` 随后的 static receipt 认领失败,下一次带新 actionId 的 `agent.run_status` 必须先完整重放旧 claim 的 ready 结果,再继续认领 static receipt;旧 delivery 和 journal 仍绑定原 action,不为恢复 action 新建第二份 isolated claim。只有这次恢复 observation 持久化成功后,旧 claim 才能转为 `observed`
- 完成门禁要求每个 `claimed-by-parent` delivery 都被同一 `claimedByActionId + delegationGroupId` 的 journal 覆盖;旧版本遗留的无 journal delivery 继续计入 `unjournaledClaimedGroups`,不能仅凭 delivery 已 claimed 清除门禁。`agent.run_status` 先重放已有未观察 claim;没有未观察 claim 时,每轮只按稳定 action 顺序为一个旧 `claimedByActionId` 合成 journal 并完整重放,恢复 action 不取得该 delivery,也不创建自己的 claim。多个旧 action 不得一次合并后超过观察预算。
- 旧 delivery 对应的原 action 已存在但未覆盖该 group 的 journal 时失败关闭,不能扩写 journal、改变 group 集合或把 `Committed / Observed` 倒退为 `Prepared`;同一 group 出现在其他 action journal 时按身份冲突处理。只有 observation 中完整出现的 group 集合可推进对应 claim 为 `observed`,不能顺带标记本轮未输出的其它 claim。
- 同一父 run 仍存在任一未 `observed` 或无 journal 的 claimed delivery 时,finalization 必须继续失败关闭,不能写入最终 assistant。
- 每个 group 的认领审计以 `actionId + delegationGroupId` 为唯一键;部分提交恢复、Runner 重启和 observation 重投影都只能补齐缺失审计,不能为同一 action/group 追加重复记录。该幂等追加必须在 Agent DB append 锁内先修复 JSONL 截断尾行,再从文件头扫描有效数据库的完整记录范围并核对既有 payload;尾行撕裂、重复键或内容冲突都不能绕过唯一性。
### 定向验收与边界
2026-07-18 定向验收新增“后一个 join 锁冲突”“isolated 已提交、后续 static delivery 锁失败后由新 action 完整重放”“Agent DB torn tail 后 prepared/partial claim 重放”和“多旧 action 逐轮迁移”回归,并覆盖已有 journal 不扩写/不倒退、跨 action group 归属冲突与 mixed partial claim 恢复;完整 observation 必须实际包含被标记 observed 的全部 `delegationGroupId``isolated` 36/36、`project_supervisor` 42/42、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 已通过,Tauri/Rust 全量为 915 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider 验收,不得把本地全量结果扩大解释为外部模型链路已重新通过。
V1.35 只收紧多个 ready isolated all-join 的认领原子性、恢复和完成门禁,不等于 V1.34 所述 scope-aware OS sandbox 已落地。该 sandbox 仍未完成,V1.34 对动态 isolated child 的命令及其它高风险工具禁用边界继续有效。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
@@ -596,3 +596,5 @@ game-project/
- 2026-07-17 V1.32 最终代码已完成独立真实 Provider PASS:首批 mixed batch、三 isolated child、Runner 强杀恢复、专业返工、宿主验证、唯一最终回复与零重复/残留/泄漏同时成立。真实报告计数、隔离重试配置和仍待收敛的 tool-plan repair 成本统一以 Runtime 文档 V1.32 章节与共享决策记录为准。
- 2026-07-18 起,同一 Runtime 文档的“V1.34 动态隔离子 Agent writeScopes 命令绕过封堵”作为 isolated child 的现行能力事实源。在 scope-aware OS sandbox 完成前,动态 child 无条件禁用 `project.verify / project.git_commit / command.exec / command.start / command.stdin / preview.start / agent.delegate / agent.spawn_isolated / project.restore / agent.schedule_ready / canvas.asset_generate / task.create / task.update / blackboard.write` 和全部 MCP;原生工具策略统一显示 `denied`,模板、项目 policy 与用户确认均不能放宽。保留固定只读 `command.run_limited`、同身份 `command.output_read / command.poll / command.terminate`、既有预览的 `preview.validate`,以及严格位于 `writeScopes` 内的 `file.write / file.patch / file.delete / project.patchset`
- V1.34 的新单动作在 confirmation 和 OS launcher 前拒绝;新多 action 原生 batch 只要含一个 denied member 就在独立 pending-action sidecar、confirmation、OS spawn、revision 和任何成员项目副作用前整批 abort,只保留 `aborted / nextActionIndex=0` batch 事实。旧 pending / approval / batch 真正进入执行器时仍重新应用当前 child 边界,旧 executing 未知结果继续进入既有 reconciliation。该安全收紧由恶意 sibling 写入、策略快照、batch、旧 pending 执行器重验和 isolated/mixed/collaboration/provider-batch 回归证明;不因本切片重跑已通过且 isolated mutation 为 0 的 V1.31/V1.32 外部 Provider suite。通用命令只有在后续 scope-aware OS sandbox 对所有后代强制同一 `writeScopes` 并通过独立决策与测试后才可重新评估开放。
- 2026-07-18 起,同一 Runtime 文档的“V1.35 多 ready isolated all-join 原子认领与恢复”作为 `agent.run_status` 同父 run 多 group 认领的现行事实源。Runtime 按 `delegationGroupId` 排序并一次性预取全部 join 锁;任一后续锁忙时保持零 delivery mutation、零 claim sidecar。全锁就绪后,同一 action 的 durable claim journal 按 `prepared -> committed -> observed` 推进;部分 commit 或 Runner 恢复只能复用该 journal 幂等补齐。只认领可完整放入优先 `readyIsolatedJoins` 观察预算的有序前缀,未观察旧 claim 可由后续 action 完整重放,但不创建第二份 isolated claim。每个 claimed delivery 必须由匹配原 action/group 的 journal 覆盖;无 journal 的旧 delivery 每轮只迁移一个原 action,已有 journal 不得扩写或状态倒退,跨 action group 归属冲突失败关闭。成功 observation 写入 pending sidecar 后只能把本轮完整输出的 claim 标记 `observed`,任一未观察或无 journal claim 继续阻断 finalization;每个 group 审计按 `actionId + delegationGroupId` 唯一,并在 Agent DB 锁内修复 torn tail、全量核对后幂等追加。
- V1.35 定向验收覆盖后一个 join 锁冲突、mixed static 锁失败后新 action 重放 isolated 结果、Agent DB torn tail 后 prepared/partial claim 恢复、多旧 action 逐轮迁移、已有 journal 单调性与跨 action group 归属冲突;`isolated` 36/36、`project_supervisor` 42/42、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 已通过,Tauri/Rust 全量为 915 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider 验收。该协议不等于 V1.34 的 scope-aware OS sandbox 已完成;后者仍未完成,动态 isolated child 的现行禁用边界保持不变。