完善Agent持久动作与隔离任务恢复
收紧Agent DB容量、句柄锁、终态回执和动作历史身份校验 补齐pending动作、task、event、observation与receipt崩溃恢复幂等 扩展跨平台路径清洗、仓库上下文门禁和瞬时LLM重试 让隔离父run持久等待并通过parent-wake恢复同一run 修复确认动作阶段投影与最近工具状态推进 扩充Rust安全回归和真实Provider E2E验收 同步Runtime技术方案、实施计划与项目决策记录
This commit is contained in:
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=3,Runner 强杀恢复身份稳定,验证与桌面/移动浏览器证据通过,副作用重放、重复 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 / gate,repository 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。认领后取消尚未执行的固定 continuation;continuation 已开始时拒绝认领。父 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
Reference in New Issue
Block a user