完善Agent持久动作与隔离任务恢复

收紧Agent DB容量、句柄锁、终态回执和动作历史身份校验
补齐pending动作、task、event、observation与receipt崩溃恢复幂等
扩展跨平台路径清洗、仓库上下文门禁和瞬时LLM重试
让隔离父run持久等待并通过parent-wake恢复同一run
修复确认动作阶段投影与最近工具状态推进
扩充Rust安全回归和真实Provider E2E验收
同步Runtime技术方案、实施计划与项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-13 20:09:26 +08:00
parent e714c8c2b6
commit 0d643ddbbb
10 changed files with 7811 additions and 246 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -100,6 +100,14 @@ pub(crate) enum IsolatedAgentJoinDeliveryStatus {
Suppressed,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum IsolatedAgentJoinDeliveryTarget {
#[default]
Continuation,
ParentWake,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct IsolatedAgentJoinDeliveryRecord {
@@ -109,6 +117,8 @@ pub(crate) struct IsolatedAgentJoinDeliveryRecord {
pub(crate) delegation_group_id: String,
pub(crate) join_run_id: String,
pub(crate) status: IsolatedAgentJoinDeliveryStatus,
#[serde(default)]
pub(crate) delivery_target: IsolatedAgentJoinDeliveryTarget,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) queued_run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -582,6 +592,43 @@ pub(crate) fn reconcile_all_isolated_groups_at(root: &Path) -> Result<Vec<JoinDi
Ok(dispatches)
}
pub(crate) fn isolated_join_completion_barrier_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,
) -> Result<Option<String>, String> {
validate_safe_id(parent_agent_id, "parentAgentId", 96)?;
validate_safe_id(parent_run_id, "parentRunId", 160)?;
let groups = list_json_records(
root,
ISOLATED_AGENT_GROUP_DIR,
"动态隔离 Agent group",
|record| validate_isolated_group_record(root, record),
)?;
let mut waiting_groups = 0usize;
let mut ready_unclaimed_groups = 0usize;
for group in groups.into_iter().filter(|group| {
group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id
}) {
let Some(join) = build_join_dispatch_if_ready_at(root, &group.delegation_group_id)? else {
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);
}
}
if waiting_groups == 0 && ready_unclaimed_groups == 0 {
return Ok(None);
}
Ok(Some(format!(
"waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · 必须调用 agent.run_status 取得并认领 all-join 后再继续"
)))
}
pub(crate) fn read_isolated_join_delivery_at(
root: &Path,
join: &JoinDispatch,
@@ -603,6 +650,38 @@ pub(crate) fn write_isolated_join_delivery_at(
status: IsolatedAgentJoinDeliveryStatus,
queued_run_id: Option<&str>,
claimed_by_action_id: Option<&str>,
) -> Result<IsolatedAgentJoinDeliveryRecord, String> {
write_isolated_join_delivery_with_target_at(
root,
join,
status,
None,
queued_run_id,
claimed_by_action_id,
)
}
pub(crate) fn write_isolated_parent_wake_join_delivery_at(
root: &Path,
join: &JoinDispatch,
) -> Result<IsolatedAgentJoinDeliveryRecord, String> {
write_isolated_join_delivery_with_target_at(
root,
join,
IsolatedAgentJoinDeliveryStatus::Dispatched,
Some(IsolatedAgentJoinDeliveryTarget::ParentWake),
None,
None,
)
}
fn write_isolated_join_delivery_with_target_at(
root: &Path,
join: &JoinDispatch,
status: IsolatedAgentJoinDeliveryStatus,
requested_target: Option<IsolatedAgentJoinDeliveryTarget>,
queued_run_id: Option<&str>,
claimed_by_action_id: Option<&str>,
) -> Result<IsolatedAgentJoinDeliveryRecord, String> {
let existing = read_isolated_join_delivery_at(root, join)?;
if let Some(existing) = &existing {
@@ -621,7 +700,15 @@ pub(crate) fn write_isolated_join_delivery_at(
existing.status, status
));
}
if requested_target.is_some_and(|target| target != existing.delivery_target) {
return Err("动态隔离 Agent join delivery 不能更改投递目标".to_string());
}
}
let delivery_target = existing
.as_ref()
.map(|record| record.delivery_target)
.or(requested_target)
.unwrap_or_default();
let queued_run_id = queued_run_id
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -667,6 +754,15 @@ pub(crate) fn write_isolated_join_delivery_at(
} else if claimed_by_action_id.is_some() {
return Err("未认领的动态隔离 Agent join 不能保存 claimedByActionId".to_string());
}
if let Some(existing) = &existing {
if existing.status == status
&& existing.delivery_target == delivery_target
&& existing.queued_run_id == queued_run_id
&& existing.claimed_by_action_id == claimed_by_action_id
{
return Ok(existing.clone());
}
}
let record = IsolatedAgentJoinDeliveryRecord {
schema_version: ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION.to_string(),
parent_agent_id: join.parent_agent_id.clone(),
@@ -674,6 +770,7 @@ pub(crate) fn write_isolated_join_delivery_at(
delegation_group_id: join.delegation_group_id.clone(),
join_run_id: join.join_run_id.clone(),
status,
delivery_target,
queued_run_id,
claimed_by_action_id,
updated_at: unix_timestamp(),
@@ -850,11 +947,22 @@ fn validate_isolated_join_delivery_record(
{
return Err("动态隔离 Agent join delivery 身份不一致".to_string());
}
if let Some(queued_run_id) = &record.queued_run_id {
validate_safe_id(queued_run_id, "queuedRunId", 160)?;
if queued_run_id != &record.join_run_id {
return Err("动态隔离 Agent join delivery 使用了非稳定 queuedRunId".to_string());
match (record.delivery_target, record.queued_run_id.as_deref()) {
(IsolatedAgentJoinDeliveryTarget::ParentWake, Some(_)) => {
return Err("动态隔离 Agent parent-wake delivery 不能含 queuedRunId".to_string());
}
(IsolatedAgentJoinDeliveryTarget::Continuation, None)
if record.status == IsolatedAgentJoinDeliveryStatus::Dispatched =>
{
return Err("动态隔离 Agent continuation delivery 缺少 queuedRunId".to_string());
}
(_, Some(queued_run_id)) => {
validate_safe_id(queued_run_id, "queuedRunId", 160)?;
if queued_run_id != record.join_run_id {
return Err("动态隔离 Agent join delivery 使用了非稳定 queuedRunId".to_string());
}
}
(_, None) => {}
}
match (record.status, record.claimed_by_action_id.as_deref()) {
(IsolatedAgentJoinDeliveryStatus::ClaimedByParent, Some(action_id)) => {
@@ -1640,6 +1748,11 @@ mod tests {
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
let second =
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[1]).unwrap();
assert!(
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
.unwrap()
.is_some_and(|detail| detail.contains("waitingGroups=1"))
);
assert!(
record_isolated_child_result_at(temp.path(), &completed_result(&first))
.unwrap()
@@ -1651,6 +1764,11 @@ mod tests {
assert_eq!(dispatch.join_run_id, group.join_run_id);
assert_eq!(dispatch.parent_session_id, "parent-session");
assert!(serde_json::from_str::<Value>(&dispatch.prompt).is_ok());
assert!(
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
.unwrap()
.is_some_and(|detail| detail.contains("readyUnclaimedGroups=1"))
);
let reconciled = reconcile_all_isolated_groups_at(temp.path()).unwrap();
assert_eq!(reconciled, vec![dispatch]);
}
@@ -1681,6 +1799,10 @@ mod tests {
dispatched.status,
IsolatedAgentJoinDeliveryStatus::Dispatched
);
assert_eq!(
dispatched.delivery_target,
IsolatedAgentJoinDeliveryTarget::Continuation
);
let claimed = write_isolated_join_delivery_at(
temp.path(),
&dispatch,
@@ -1701,6 +1823,11 @@ mod tests {
claimed.queued_run_id.as_deref(),
Some(&*dispatch.join_run_id)
);
assert_eq!(
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
.unwrap(),
None
);
assert!(write_isolated_join_delivery_at(
temp.path(),
&dispatch,
@@ -1717,6 +1844,105 @@ mod tests {
);
}
#[test]
fn parent_wake_delivery_is_persistent_idempotent_and_claim_inherits_target() {
let temp = tempdir().unwrap();
let group = create_group(
temp.path(),
"action-parent-wake",
&request(vec![("code-prototype", "game/a/**")]),
);
let instance =
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance))
.unwrap()
.unwrap();
let dispatched =
write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).unwrap();
assert_eq!(
dispatched.delivery_target,
IsolatedAgentJoinDeliveryTarget::ParentWake
);
assert_eq!(
dispatched.status,
IsolatedAgentJoinDeliveryStatus::Dispatched
);
assert!(dispatched.queued_run_id.is_none());
assert_eq!(
write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).unwrap(),
dispatched
);
assert_eq!(
read_isolated_join_delivery_at(temp.path(), &dispatch)
.unwrap()
.unwrap(),
dispatched
);
let claimed = write_isolated_join_delivery_at(
temp.path(),
&dispatch,
IsolatedAgentJoinDeliveryStatus::ClaimedByParent,
None,
Some("parent-wake-claim"),
)
.unwrap();
assert_eq!(
claimed.delivery_target,
IsolatedAgentJoinDeliveryTarget::ParentWake
);
assert!(claimed.queued_run_id.is_none());
assert_eq!(
claimed.claimed_by_action_id.as_deref(),
Some("parent-wake-claim")
);
}
#[test]
fn join_delivery_infers_legacy_continuation_and_rejects_invalid_target_combinations() {
let temp = tempdir().unwrap();
let group = create_group(
temp.path(),
"action-delivery-target-validation",
&request(vec![("code-prototype", "game/a/**")]),
);
let instance =
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance))
.unwrap()
.unwrap();
let path = isolated_join_delivery_relative_path(&dispatch.delegation_group_id);
let mut legacy = serde_json::json!({
"schemaVersion": ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION,
"parentAgentId": dispatch.parent_agent_id,
"parentRunId": dispatch.parent_run_id,
"delegationGroupId": dispatch.delegation_group_id,
"joinRunId": dispatch.join_run_id,
"status": "dispatched",
"queuedRunId": dispatch.join_run_id,
"updatedAt": unix_timestamp(),
});
write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap();
let inferred = read_isolated_join_delivery_at(temp.path(), &dispatch)
.unwrap()
.unwrap();
assert_eq!(
inferred.delivery_target,
IsolatedAgentJoinDeliveryTarget::Continuation
);
assert!(write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).is_err());
legacy["deliveryTarget"] = Value::String("parent-wake".to_string());
write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap();
assert!(read_isolated_join_delivery_at(temp.path(), &dispatch).is_err());
legacy["deliveryTarget"] = Value::String("continuation".to_string());
legacy.as_object_mut().unwrap().remove("queuedRunId");
write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap();
assert!(read_isolated_join_delivery_at(temp.path(), &dispatch).is_err());
}
#[test]
fn corrupt_record_fails_closed() {
let temp = tempdir().unwrap();
@@ -243,6 +243,8 @@ impl Default for AgentRuntimeToolPolicySnapshot {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolCallRecord {
#[serde(default)]
action_id: Option<String>,
#[serde(default)]
tool: String,
#[serde(default)]
@@ -350,6 +352,8 @@ struct AgentRuntimeEvent {
#[serde(default)]
event_type: String,
#[serde(default)]
action_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
File diff suppressed because it is too large Load Diff
@@ -1917,12 +1917,12 @@ fn unquoted_secret_value_end(value: &str, mut index: usize) -> usize {
index
}
fn redact_absolute_path_tokens(value: &str) -> String {
pub(crate) fn redact_absolute_path_tokens(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = String::with_capacity(value.len());
let mut index = 0;
while index < bytes.len() {
if starts_absolute_path(bytes, index) {
if starts_absolute_path(bytes, index) || starts_file_uri(bytes, index) {
output.push_str("<absolute-path>");
index = consume_path_token(bytes, index);
continue;
@@ -1944,7 +1944,7 @@ fn starts_absolute_path(bytes: &[u8], index: usize) -> bool {
return false;
}
if bytes[index] == b'/' {
return bytes.get(index + 1).is_none_or(|next| *next != b'/');
return bytes.get(index + 1) != Some(&b'/') || starts_forward_slash_unc_path(bytes, index);
}
if bytes[index] == b'\\' && bytes.get(index + 1) == Some(&b'\\') {
return true;
@@ -1956,6 +1956,42 @@ fn starts_absolute_path(bytes: &[u8], index: usize) -> bool {
.is_some_and(|separator| matches!(separator, b'/' | b'\\'))
}
fn starts_forward_slash_unc_path(bytes: &[u8], index: usize) -> bool {
let server_start = index + 2;
let token_end = consume_path_token(bytes, server_start);
let Some(server_end) = bytes[server_start..token_end]
.iter()
.position(|byte| *byte == b'/')
.map(|offset| server_start + offset)
else {
return false;
};
let share_start = server_end + 1;
server_end > server_start && share_start < token_end && bytes.get(share_start) != Some(&b'/')
}
fn starts_file_uri(bytes: &[u8], index: usize) -> bool {
const FILE_URI_PREFIX: &[u8] = b"file:";
let boundary = index == 0 || is_path_boundary(bytes[index - 1]);
if !boundary {
return false;
}
let Some(prefix_end) = index.checked_add(FILE_URI_PREFIX.len()) else {
return false;
};
if !bytes
.get(index..prefix_end)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(FILE_URI_PREFIX))
{
return false;
}
let token_end = consume_path_token(bytes, prefix_end);
bytes.get(prefix_end) == Some(&b'/')
&& token_end > prefix_end
&& bytes.get(prefix_end..token_end) != Some(b"//")
}
fn is_path_boundary(byte: u8) -> bool {
byte.is_ascii_whitespace()
|| matches!(
@@ -2386,6 +2422,47 @@ sketch-color = green
}
}
#[test]
fn redacts_file_uri_tokens_without_redacting_relative_text() {
assert_eq!(
redact_absolute_path_tokens(
"open file:///home/alice/project, FILE:///C:/Users/Alice/project; \
file://server/share/project file:///home/alice/My%20Project \
file:///%68ome/alice%2Fproject"
),
"open <absolute-path>, <absolute-path>; <absolute-path> <absolute-path> \
<absolute-path>"
);
assert_eq!(
redact_absolute_path_tokens(
"profile homeward docs/file.txt file:notes.txt file:// file:///"
),
"profile homeward docs/file.txt file:notes.txt file:// <absolute-path>"
);
}
#[test]
fn redacts_cross_platform_absolute_paths_without_redacting_urls() {
assert_eq!(
redact_absolute_path_tokens(concat!(
r#"open //server/share/private, \\server\share\private; "#,
"file:/home/user/private file:/C:/Users/Alice/private ",
r#"C:/Users/Alice/private C:\Users\Alice\private"#
)),
"open <absolute-path>, <absolute-path>; \
<absolute-path> <absolute-path> \
<absolute-path> <absolute-path>"
);
assert_eq!(
redact_absolute_path_tokens(
"keep docs/private file:notes.txt // not-a-path /// docs \
https://example.test/private http://localhost:3000/private"
),
"keep docs/private file:notes.txt // not-a-path /// docs \
https://example.test/private http://localhost:3000/private"
);
}
#[test]
fn secret_changes_and_git_status_do_not_change_the_fingerprint() {
let repository = TestDirectory::new("stable-fingerprint");
File diff suppressed because it is too large Load Diff
@@ -4216,3 +4216,23 @@
- 决策:context window 压缩新增 `runtime.milestones` 安全摘要,跨窗口保留已成功完成的 `agent.spawn_isolated / agent.delegate / canvas.asset_generate / preview.validate / project.patchset / project.restore / task.create`,并明确禁止无任务依据的重复高成本或副作用动作。账本只用于规划连续性,不替代 pending-action、task/event、Agent DB、revision、verification 或 finalization 事实源。
- 决策:旧 `runtime.context / runtime.milestones` 不再占普通最近 observation 槽;账本在再次压缩时合并旧摘要与新里程碑,所有 detail 先做路径、凭据和长度清洗。`project.diff` checkpoint 内容 hunk 与 `git.inspect` 工作树 hunk 分别保留最新一项,不能互相顶掉;总 bundle 仍不得超过 128 KiB。
- 验证:新增连续窗口单测证明 spawn、patchset 和 checkpointId 经两次压缩仍存在,两类大 diff 同时保留。真实 `gpt-5.5` Git E2E 曾准确捕获一次上下文遗忘导致的重复 spawn;修复后 94 条 task、156 条 event、140 条 Agent DB、12 次成功工具执行中 `git.inspect=2 / patchset=1 / spawn=1 / join=1`revision=3Runner 强杀恢复身份稳定,验证与桌面/移动浏览器证据通过,副作用重放、重复 action/message/receipt、半完成文件、密钥和诱饵泄露均为 0。
## 2026-07-13 AI 游戏创作 Agent Runtime V1.6 持久动作回执与模型回查
- 决策:继续复用 `.agent/agent.db` 作为唯一长期审计源,不新增数据库或平行事实源。每个带 `actionId` 的已落盘终态 observation 必须追加或补齐 terminal receipt,身份固定包含 `agentId / taskId / sessionId / runId / actionId / actionFingerprint / tool / executionMode / status / inputSummary / summary / safeDetail / updatedAt`
- 决策:`safeDetail` 只允许按工具类型和字段名双重白名单抽取,禁止整段复制工具 detail,禁止保存 `file.read` 源码、命令完整输出、diff 正文、消息 / 记忆 / 委派正文、密钥和绝对路径;首版只保留 `project.patchset` 的 checkpoint / revision / count 等结构化字段,无法安全还原 detail 的历史旧记录允许显式标记 `detailUnavailable`
- 决策:动作历史读取必须把 receipt 当持久输入而不是可信展示 DTO,重新验证终态 status、actionId、fingerprint、executionMode、tool 和 task / session ledger 绑定,并按当前工具白名单重新解析 `safeDetail`;无法通过二次校验的 detail 只能标记 `detailUnavailable`
- 决策:新增只读模型工具 `agent.action_history`,只能查询当前 Agent;输入支持 `runId / actionId / tool / status / limit``limit` 默认 5、上限 10,省略 `runId` 时只查当前 run。结果优先使用终态 receipt,并兼容折叠历史 terminal observation;旧记录无法还原安全 detail 时标记 `detailUnavailable`。未指定 `tool` 时默认排除 `agent.action_history` 自身,只有显式 `tool=agent.action_history` 才允许回查它,避免递归污染。
- 决策:`agent.action_history` 复用 `agent.audit` 权限,默认 `auto`,项目或 per-Agent policy 可改为 `confirm / deny`;查询不推进 revision、不改变 verification gate、不认领 join。receipt 写入失败时 durable action 必须进入 `needs-reconciliation`,恢复只按原 `actionId` 补齐 receipt,不得重放动作。
- 决策:receipt 判重命中后还必须全等复核 Session、fingerprint、tool、executionMode、status 和安全结果字段,冲突失败关闭。普通 append 禁止写 `agent.runtime.action_receipt`,幂等动作入口只接受字段完整的终态 receipt。Agent DB 只自动修复强杀造成的最后一条不完整 JSONL,中间损坏不跳过;单条记录上限 1 MiB,receipt 幂等全量扫描在文件超过 256 MiB 或记录超过 100 万条时失败关闭,禁止复用锁外快照。普通审计约在 192 MiB 或 999,936 条停止,并给字节 / 记录门槛预留 64 条最大 1 MiB terminal 记录;仅 terminal receipt、带 actionId 的终态 observation / observed 和 reconciliation 可用预留区,`command-failed / verification-failed` 也是终态。普通和终态追加都在同一 DB 句柄锁内真实计数,容量判断和判重失败关闭,不做轮转。普通读取使用最近 32 MiB 有界尾窗、最多保留 16,384 个完整 JSON object,并显式标记 `truncated`
- 决策:Agent DB 不再依赖普通路径锁文件保障安全。Unix 必须从可信项目目录句柄使用 `openat + O_NOFOLLOW` 打开,校验普通文件与 `nlink=1` 后直接对 DB 文件句柄 `flock`Windows 必须用相对 `NtCreateFile` 打开并拒绝 reparse point / hardlink,同时以独占 share 持有句柄。每次 append、尾部补换行或截断修复执行 `flush + sync_data`Unix 新建 `.agent``agent.db` 后分别同步项目根目录与 `.agent` 目录,写入前后复核身份。32 MiB 尾窗恰好落在记录边界、UTF-8 半字符或精确 1 MiB 尾记录时不得丢弃合法记录;同 UID 恶意进程的 rename / hardlink ABA 不承诺绝对隔离。
- 2026-07-13 真实验收修正:pending action 的精确 project revision / verification gate 不再拦截纯读取工具;不同 Agent 并行推进 revision 后,`file.read / project.search / git.inspect / agent.action_history` 等读取动作必须读取最新事实并返回 observation。写入、命令、验证、预览证据和 `agent.run_status` join 认领仍复核原 revision / gaterepository fingerprint gate 也继续独立生效。该修正来自真实 Provider 首轮中隔离子 Agent 因父 Agent revision 推进而把 `file.read` 误判为 `needs-reconciliation`、导致 all-join 无法形成的失败证据。
- 决策:共享项目事实读取使用项目一致性锁;等待锁后必须重读 durable pending sidecar,并与调用方完整 pending 对象逐字段一致,再核对 policy 和 repository fingerprint。`agent.run_status` 的 all-join claim 必须在同一项目锁内重验 revision / gate 并完成认领,关闭检查与认领之间的 TOCTOU。policy denied 和未知工具也先形成 durable observed pending,再写 terminal receipt;一旦 terminal observation 已持久化,必须先成功落 receipt 才响应取消,receipt 失败统一保留 `needs-reconciliation` 补写入口且不得重放工具。
- 决策:父 run 存在 `joinMode=all` 隔离组时,所有子结果终态且 ready join 被当前父 run 的 `agent.run_status` action 认领前,最终回复和 `agent.action_history` 都必须失败关闭并要求继续查询状态;只有持久 join 认领完成后才解除门禁。
- 决策:最终回复在项目锁内创建 finalization journal 前必须再次复核 all-join 已由当前父 run 持久认领;未认领按 `Stale` 回到同 run planning,不写 journal、assistant 或 completed,不能只依赖 planning 阶段旧快照。
- 决策:父 run 在 `waitingGroups > 0` 时必须持久进入 `waiting-for-isolated-join`、保存原 context cursor 并释放 Agent lane;重复 resume 只返回等待状态,不请求 LLM、不推进 loop,也不取消仍在工作的 child。最后一个 child 就绪后写 `deliveryTarget=parent-wake` 并唤醒同一 parent run / session,由模型通过持久 `agent.run_status` actionId 认领;不得创建 join continuation。活跃 planning / running 父 run 直接保留 ready join 等待认领,重复 dispatch 不创建任务。旧 delivery 缺少 target 时按 continuation 单向兼容。
- 决策:action task / event 投影的幂等阶段键为 `runId + actionId + phase`,允许同一 action 从 waiting-for-confirmation 合法推进到终态 observation,同阶段冲突仍失败关闭。Agent DB 终态 observation 扫描跳过同 action 的非终态前置记录,只对既有终态做全字段一致性检查;`recentToolCalls` 按 actionId 原位更新,避免 waiting 投影遮住最终结果。
- 决策:动作历史结构化 detail 上限 7,200 字符;超预算时只能先删除可选字段,再按最旧优先删除完整记录,不得字符截断 JSON,也不得清空 `runId / actionFingerprint` 破坏身份。运行时文本清洗必须覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...``file:///...` 及百分号编码绝对路径,统一替换为 `<absolute-path>`,并保留普通相对文本与 HTTP(S) URL。
- 决策:后台 planning 和最终回复的瞬时 LLM 错误重试上限提高为额外 5 次,覆盖 `Timeout / Connectivity / Transport` 与上游 `408 / 429 / 5xx`,按 500ms 线性递增退避;不可重试错误继续直接失败,任何重试都不得跨越工具执行或回复落盘提交点。
- 决策:本轮只交付模型工具,不新增前端动作历史弹窗;UI 继续显示最近动作投影,后续历史查看必须使用独立弹窗。
- 验证:Rust 全量 507 项中 504 通过、3 项真实浏览器 opt-in 用例按设计忽略;覆盖 receipt 折叠、组合过滤、默认值与上限、敏感清洗、旧记录、尾部修复、中间损坏失败关闭、身份冲突、句柄安全、目录同步、确认阶段到终态投影、`parent-wake` 和恢复补齐。最终真实 `gpt-5.5` V1.6 `llm-runtime` 套件中,模型实际调用 1 次 `agent.action_history` 并返回 1 条与 `.agent/agent.db` 全身份对齐的当前 run 记录;94 条 task、158 条 event、164 条 Agent DB、11 条合法工具协议、13 次成功工具执行和 24 条 terminal receipt 中,主 run receipt 为 18,递归历史、重复 receipt / action / message、receipt identity 冲突、密钥和诱饵泄漏均为 0。Runner 强杀后恢复原 run / session 且身份稳定,3 个隔离实例形成唯一 all-join 认领,本次真实竞态未创建 continuation task;动作历史只在父 run 认领 join 后执行,项目、桌面和移动验证通过。
@@ -198,7 +198,7 @@ delegationId, instanceId, templateAgentId, runId, status,
summary, artifacts[path, sha256], evidence[], verifiedRevision, error
```
同一 group 全部终态后,Runtime 使用 `.agent/runtime/isolated-agents/join-deliveries/<groupId>.json` 持久记录交付状态,并且只允许固定 `joinRunId` 入队一次。父 run 仍持有自身 lane 时,只能通过当前已持久化 `agent.run_status` 工具动作的 `actionId` 认领 ready all-join;交付记录保存 `claimedByActionId`,同一 action 崩溃重试可幂等重读,同一 run 的其他 action 不再看到该 join。认领后取消尚未执行的固定 continuationcontinuation 已开始时拒绝认领。父 run 未认领时,唯一 continuation 才在 lane 释放后执行,`dispatched -> claimed-by-parent / suppressed` 单向不可逆恢复、并发 child 终态和重复状态查询都不能重新开放交付、生成 `-dup-*` join run 或重复调用父 LLM。
同一 group 全部终态后,Runtime 使用 `.agent/runtime/isolated-agents/join-deliveries/<groupId>.json` 持久记录交付状态。父 run 仍在 planning / action 时不创建 continuation,只能通过当前已持久化 `agent.run_status` 工具动作的 `actionId` 认领 ready all-join;交付记录保存 `claimedByActionId`,同一 action 崩溃重试可幂等重读,同一 run 的其他 action 不再看到该 join。若父 run 在 child 尚未终态时返回空 actions,则持久进入 `waiting-for-isolated-join`、保存原 context cursor 并释放自身 lane,不继续请求 LLM,也不消耗后续上下文窗口;重复 resume 只读取等待状态。最后一个 child 就绪后写 `deliveryTarget=parent-wake` 并唤醒同一父 run / session,由模型继续调用 `agent.run_status` 认领,不能创建 `joinRunId` continuation。旧 delivery 缺少 `deliveryTarget` 时按 `continuation` 兼容;只有非活跃父任务或旧记录的兜底路径才保留固定 continuation。`dispatched -> claimed-by-parent / suppressed` 单向不可逆恢复、并发 child 终态和重复状态查询都不能重新开放交付、生成 `-dup-*` join run 或重复调用父 LLM。
## 5. 真实 Provider 验收
@@ -356,6 +356,38 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
修复后的真实 `gpt-5.5` 回归在 10 轮内完成并收束,唯一 spawn / patchset / join 均保持 1 次,两次 Git 审阅和两类 diff 同时留在最终 context bundle,重复副作用为 0。
## V1.6 持久动作回执与模型回查
- 复用 `.agent/agent.db` 作为唯一长期审计源,不新增数据库或平行事实源。每个带 `actionId` 的已落盘终态 observation 都必须在该审计源中追加或补齐 terminal receipt,身份固定包含 `agentId / taskId / sessionId / runId / actionId / actionFingerprint / tool / executionMode / status / inputSummary / summary / safeDetail / updatedAt`
- `safeDetail` 必须按工具类型和字段名双重白名单抽取,不能把某类工具的整段 detail 直接复制到 receipt;不得写入 `file.read` 读取的源码、命令完整输出、diff 正文、消息 / 记忆 / 委派正文、密钥或绝对路径。首版只保留 `project.patchset``checkpointId / revision / changeCount / revisionAdvanced` 等结构化字段;历史旧记录无法安全还原 detail 时允许显式标记 `detailUnavailable`,不得为补齐字段重新读取或扩散敏感正文。
- `agent.action_history` 不能把 Agent DB 中已有 receipt 当成已经可信的展示 DTO。读取时必须再次验证终态 status、actionId、64 位 fingerprint、executionMode、tool,并把 receipt 的 task / session 与同 run 的 task ledger 绑定;`safeDetail` 还要按当前工具白名单重新解析,无法通过时只返回 `detailUnavailable`,不得回显伪造或旧版本遗留的原始 detail。
- 新增只读模型工具 `agent.action_history`,只能查询当前 Agent 的持久终态动作。输入支持 `runId / actionId / tool / status / limit``limit` 默认 5、上限 10,省略 `runId` 时只查询当前 run。结果优先使用 terminal receipt,并兼容折叠历史 terminal observation;旧记录缺少安全结构化 detail 时显式返回 `detailUnavailable`
- `agent.action_history` 自身默认不出现在未指定 `tool` 过滤条件的结果中,避免模型查询动作递归污染历史;只有显式传入 `tool=agent.action_history` 时才允许查询它自身的 receipt。
- 权限复用 `agent.audit`,默认 `auto`,仍可由项目或 per-Agent policy 改为 `confirm``deny`。该工具只读,不推进 project revision、不改变 verification gate,也不认领 join。
- terminal receipt 写入失败时,对应 durable action 必须进入 `needs-reconciliation`,不得出现动作已经完成但长期历史静默缺失。恢复只能按原 `actionId` 幂等补齐 receipt,不能重放动作。
- receipt 幂等复用 `recordType + agentId + runId + actionId` 定位,但命中旧记录后必须继续全等复核 `taskId / sessionId / actionFingerprint / tool / executionMode / status` 和结果摘要,任何冲突都失败关闭。普通 Agent DB append 明确拒绝 `agent.runtime.action_receipt`;幂等动作入口只接受字段完整、身份合法且 status 终态的 receipt,不能被任意 recordType 或非终态记录借用。Agent DB 尾部因进程强杀形成不完整 JSONL 时,只允许在追加锁内修复最后一条不完整记录;中间损坏仍失败关闭。单条 JSONL 最大 1 MiB;receipt 幂等全量扫描在文件超过 256 MiB 或完整非空记录超过 100 万条时失败关闭,不能把该阈值误解成通用 append 自动轮转上限。普通审计在约 192 MiB 或 999,936 条的任一软上限停止,同时为字节容量和记录门槛预留 64 条最大 1 MiB terminal 记录;只有 terminal receipt、带 actionId 的终态 observation / observed 和 reconciliation 能使用预留区,`command-failed / verification-failed` 与通用 `failed` 同属终态,非终态记录不得消耗预留。普通和终态追加都在同一 DB 句柄锁内真实计数,连同字节容量一起失败关闭,不做静默轮转。
- Agent DB 的跨进程安全以已验证文件句柄为边界,不再依赖可被路径替换的普通 lock 文件。Unix 从可信目录句柄以 `openat + O_NOFOLLOW` 打开并校验普通文件、`nlink=1`,随后直接 `flock` DB 句柄;Windows 使用相对 `NtCreateFile`,拒绝 reparse point / hardlink,并以独占 share 持有句柄。每次 append、尾部补换行或截断修复都执行 `flush + sync_data`Unix 新建 `.agent` 后同步项目根目录,新建 `agent.db` 后同步 `.agent` 目录,写入完成后再复核路径身份。同 UID 恶意进程制造的 rename / hardlink ABA 不在 v1 绝对隔离承诺内。
- 普通历史读取使用最近 32 MiB 的有界尾窗,最多保留 16,384 个完整 JSON object,超出时返回 `truncated=true`;尾窗恰好落在记录边界、UTF-8 半字符或精确 1 MiB 最后一条记录时都不能丢弃合法完整记录。
- pending action 的精确 project revision / verification gate 复核继续约束写入、命令、验证、预览证据和会认领 join 的协调动作;`memory.read / conversation.read / asset.list / project.index / project.search / project.diff / git.inspect / file.list / file.read / task.list / agent.action_history` 等纯读取动作在其他 Agent 推进 revision 后允许读取最新事实,并把结果作为新 observation 返回。适用仓库规范的 fingerprint gate 仍独立生效,不能借只读分类绕过规范漂移。
- 共享项目事实读取必须使用项目一致性锁;等待锁后重新读取 durable pending sidecar,并与调用方携带的完整 pending 对象逐字段一致,再重验 policy 和 repository fingerprint。pending 被替换、迁移或损坏时失败关闭,不能继续使用锁外旧对象。`agent.run_status` 的 all-join claim 在同一项目锁内完成 revision / verification gate 复核与认领,避免检查通过后项目状态又发生变化。
- 父 run 创建 `joinMode=all` 的隔离组后,在所有子结果终态且 ready join 已由当前父 run 的 `agent.run_status` action 认领前,Runtime 必须阻止最终回复和 `agent.action_history`,并要求继续查询状态;认领成功后才解除门禁,避免模型绕过唯一 join 提前收束或先查询不完整动作历史。
- all-join 门禁不能只在 planning 阶段检查。最终回复取得项目锁后、创建 finalization journal 前必须再次读取持久 join 交付并确认已由当前父 run 认领;未认领时按 `Stale` 回到同 run planning,不写 journal、assistant 或 completed,关闭 planning 到最终提交之间的竞态窗口。
- all-join 等待不能占用父 Agent lane 或伪装成新的 continuation。`waitingGroups > 0` 时父 run 保存 task / state / context / audit 后直接释放 lane;最后一个 child 在父 lane 释放前后完成的竞态由持久 `parent-wake` delivery 和释放后有界复核共同关闭。父 run 已恢复 planning / running 时,重复 dispatch 只确认现状,不创建 join task。
- policy denied 和未知工具也要先落 durable observed pending,再追加 terminal receipt。terminal observation 一旦持久化,就不能在 receipt 前响应取消;receipt 成功后再结束取消流程。receipt 写入失败统一进入 `needs-reconciliation`,恢复仅补回执,不重放工具。
- action 关联的 task / event 投影按 `runId + actionId + phase` 幂等,同一动作允许从 `waiting-for-confirmation` 合法推进到终态 observation,但同阶段身份冲突继续失败关闭。Agent DB 终态 observation 扫描跳过同 action 的非终态前置记录,只对既有终态做全字段复核;`recentToolCalls` 按 actionId 原位更新,不能让 waiting 投影遮住后续 `ok / failed`
- `agent.action_history` 结构化 detail 上限 7,200 字符;超预算时先移除可选字段,再删除完整最旧项,禁止用字符截断破坏 JSON,禁止清空 `runId / actionFingerprint`。上下文清洗覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...``file:///...` 和百分号编码的 `file:` URI,绝对路径统一替换为 `<absolute-path>`,同时保留普通相对文本与 HTTP(S) URL。
- 后台 planning 与最终回复遇到 `Timeout / Connectivity / Transport` 或上游 `408 / 429 / 5xx` 时最多额外重试 5 次,按 `500 / 1000 / 1500 / 2000 / 2500ms` 退避;配置、请求、流协议、反序列化错误及其他 `4xx` 不重试。重试只发生在工具计划执行前或最终回复落盘前,不能重放已完成副作用。
- 本轮只提供模型工具,不新增前端动作历史弹窗。UI 继续显示最近动作投影;后续若增加历史查看能力,必须使用点击后打开的独立弹窗,不得在当前面板下方追加内容。
### 2026-07-13 真实验收结果
- Rust 测试已覆盖 receipt 折叠、组合过滤、默认值与上限边界、跨 Agent 隔离、敏感信息清洗、历史旧记录 `detailUnavailable`、JSONL 尾部修复与中间损坏失败关闭,以及 `needs-reconciliation` 恢复按原 `actionId` 补齐。
- 真实 Provider 已实际调用 `agent.action_history`,并证明返回的 `agentId / taskId / sessionId / runId / actionId``.agent/agent.db` identity 对齐;Runner 强制终止后恢复保持动作零重放、敏感内容零泄漏。
发布 AppData 中配置的真实 `gpt-5.5` 已通过最终 V1.6 `llm-runtime` 套件。模型实际调用 1 次 `agent.action_history` 并返回 1 条当前 Agent、当前 run 的历史,递归结果为 0;返回 identity 与 `.agent/agent.db` 全字段对齐。最终形成 94 条 task、158 条 event、164 条 Agent DB 记录、11 条合法工具协议、13 次结构化成功工具执行和 24 条 terminal receipt;主 run 含 18 条 receipt,要求覆盖的 3 类工具均有回执,重复 receipt、重复 action、重复 message、receipt identity 冲突、密钥泄漏和项目诱饵泄漏均为 0。
Runner 强制终止后恢复原 run / session 且身份稳定,project revision 为 3;项目验证、桌面 / 移动浏览器验证、3 个隔离实例和唯一 all-join 认领均通过,本次真实竞态走“活跃父 run 直接认领”路径,未创建 continuation task,真实执行顺序证明 `agent.action_history` 只在隔离结果被父 run 认领后发生。确定性 Rust 集成用例另覆盖 `parent-wake` 等待路径、多次 resume 零 LLM 请求、同父 run 唤醒和无 continuation。pending `file.read` 在其他 Agent 推进 revision 后仍读取最新事实;写入、命令、验证、预览和 join 认领保持严格 gate。
## 验收命令
- `npm run ai-game-creator-shell:typecheck`
File diff suppressed because one or more lines are too long