补齐Agent委派结果回执闭环

持久化父子任务关联并将各类终态结果幂等回执给父Agent

补齐并发锁、取消抑制、恢复补漏和对话落盘失败门禁

展示委派来源并完善Runtime回归测试与技术文档
This commit is contained in:
AIGameCreator App
2026-07-10 23:40:33 +08:00
parent f9e61beb54
commit b65541ce4c
8 changed files with 2289 additions and 109 deletions
File diff suppressed because it is too large Load Diff
@@ -148,6 +148,12 @@ struct AgentRuntimeState {
#[serde(default)]
source: String,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
parent_run_id: Option<String>,
#[serde(default)]
delegation_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
@@ -357,6 +363,12 @@ struct AgentRuntimeTaskRecord {
#[serde(default)]
source: String,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
parent_run_id: Option<String>,
#[serde(default)]
delegation_id: Option<String>,
#[serde(default)]
task: String,
#[serde(default)]
status: String,
@@ -365,6 +377,8 @@ struct AgentRuntimeTaskRecord {
#[serde(default)]
current_action: String,
#[serde(default)]
terminal_detail: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
updated_at: u64,
@@ -633,6 +633,73 @@ fn ensure_agent_session_has_no_live_tasks(
session_id, record.run_id, record.status, record.phase
));
}
let parent_run_ids = latest_by_run
.values()
.filter(|record| record.session_id == session_id)
.map(|record| record.run_id.as_str())
.collect::<Vec<_>>();
if parent_run_ids.is_empty() {
return Ok(());
}
let task_dir = root.join(".agent/runtime/tasks");
let entries = match fs::read_dir(&task_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"读取 Agent Runtime 任务目录失败:{}: {error}",
task_dir.display()
));
}
};
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"读取 Agent Runtime 任务目录项失败:{}: {error}",
task_dir.display()
)
})?;
if !entry
.file_type()
.map_err(|error| format!("读取 Agent Runtime 任务文件类型失败:{error}"))?
.is_file()
{
continue;
}
let path = entry.path();
let mut delegated_latest_by_run = BTreeMap::<String, AgentRuntimeTaskRecord>::new();
let file = File::open(&path)
.map_err(|error| format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()))?;
for line in BufReader::new(file).lines() {
let line = line.map_err(|error| {
format!("读取 Agent Runtime 任务失败:{}: {error}", path.display())
})?;
if let Ok(record) = serde_json::from_str::<AgentRuntimeTaskRecord>(line.trim()) {
delegated_latest_by_run.insert(record.run_id.clone(), record);
}
}
let delegated_child = delegated_latest_by_run.values().find(|record| {
record.parent_agent_id.as_deref() == Some(agent_id)
&& record
.parent_run_id
.as_deref()
.is_some_and(|parent_run_id| parent_run_ids.contains(&parent_run_id))
&& (matches!(
record.status.as_str(),
"pending" | "running" | "waiting-for-confirmation" | "cancelling"
) || record.phase == "needs-reconciliation")
});
if let Some(record) = delegated_child {
return Err(format!(
"Session {} 的父任务 {} 仍有委派子任务 {}{} / {}),不能切换或归档",
session_id,
record.parent_run_id.as_deref().unwrap_or("-"),
record.run_id,
record.status,
record.phase
));
}
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+38 -2
View File
@@ -235,6 +235,9 @@ interface AgentRuntimeState {
sessionId: string;
runId: string;
source: string;
parentAgentId?: string | null;
parentRunId?: string | null;
delegationId?: string | null;
status: string;
phase: string;
currentTask: string;
@@ -331,10 +334,14 @@ interface AgentRuntimeTaskRecord {
sessionId: string;
runId: string;
source: string;
parentAgentId?: string | null;
parentRunId?: string | null;
delegationId?: string | null;
task: string;
status: string;
phase: string;
currentAction: string;
terminalDetail?: string | null;
error: string | null;
updatedAt: number;
}
@@ -811,6 +818,28 @@ function agentRuntimeCanConfirm(status: string) {
return status === 'waiting-for-confirmation';
}
function formatAgentRuntimeDelegationSource(runtime: {
source: string;
parentAgentId?: string | null;
parentRunId?: string | null;
delegationId?: string | null;
}) {
if (runtime.source === 'agent-delegate-receipt') {
return [
'来源:委派回执',
runtime.delegationId ? `委派:${runtime.delegationId}` : null,
]
.filter(Boolean)
.join(' · ');
}
const parts = [
runtime.parentAgentId ? `委派自:${runtime.parentAgentId}` : null,
runtime.parentRunId ? `父 run${runtime.parentRunId}` : null,
runtime.delegationId ? `委派:${runtime.delegationId}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : null;
}
function AgentRuntimeStatusPanel({
runtime,
error,
@@ -861,6 +890,7 @@ function AgentRuntimeStatusPanel({
const currentGoal = runtime.currentGoal ?? runtime.currentTask;
const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase);
const pendingToolAction = runtime.pendingToolAction ?? null;
const delegationSource = formatAgentRuntimeDelegationSource(runtime);
const canCancel =
Boolean(runtime.runId) &&
(agentRuntimeCanCancel(runtime.status) ||
@@ -941,7 +971,7 @@ function AgentRuntimeStatusPanel({
</button>
</div>
) : null}
<small>{`task: ${runtime.taskId} · ${runtime.source}`}</small>
<small>{`task: ${runtime.taskId} · ${delegationSource ?? runtime.source}`}</small>
{runtime.runId ? <small>{`run: ${runtime.runId}`}</small> : null}
{currentGoal ? <p>{`当前目标:${currentGoal}`}</p> : null}
{runtime.currentTask ? <p>{runtime.currentTask}</p> : null}
@@ -11645,10 +11675,15 @@ function sameAgentRuntimeTasks(
return (
other &&
task.runId === other.runId &&
task.source === other.source &&
task.parentAgentId === other.parentAgentId &&
task.parentRunId === other.parentRunId &&
task.delegationId === other.delegationId &&
task.status === other.status &&
task.phase === other.phase &&
task.task === other.task &&
task.currentAction === other.currentAction &&
task.terminalDetail === other.terminalDetail &&
task.updatedAt === other.updatedAt
);
})
@@ -12430,9 +12465,10 @@ function formatAgentPolicySummary(policy: ProjectPermissionPolicy) {
}
function formatAgentRecentRuntimeTask(task: AgentRuntimeTaskRecord) {
const delegationSource = formatAgentRuntimeDelegationSource(task);
return `${task.status} / ${task.phase} · ${
task.task || task.currentAction || task.runId
}`;
}${delegationSource ? ` · ${delegationSource}` : ''}`;
}
function formatAgentDialogLlmStatus(
@@ -2448,7 +2448,10 @@ describe('AI 游戏创作 App 界面边界', () => {
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'launcher-agent-task-running',
source: 'agent-background-task',
source: 'agent-delegate',
parentAgentId: 'game-director',
parentRunId: 'game-director-run-1',
delegationId: 'delegation-design-1',
status: 'running',
phase: 'planning',
currentTask: '正在处理上一条任务',
@@ -2478,7 +2481,10 @@ describe('AI 游戏创作 App 界面边界', () => {
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'launcher-agent-task-running',
source: 'agent-background-task',
source: 'agent-delegate',
parentAgentId: 'game-director',
parentRunId: 'game-director-run-1',
delegationId: 'delegation-design-1',
task: '正在处理上一条任务',
status: 'running',
phase: 'planning',
@@ -2486,6 +2492,19 @@ describe('AI 游戏创作 App 界面边界', () => {
error: null,
updatedAt: 5000,
};
const delegateReceiptTask = {
...runningTask,
runId: 'delegate-receipt-gameplay-1',
source: 'agent-delegate-receipt',
parentAgentId: null,
parentRunId: null,
delegationId: 'delegation-gameplay-1',
task: '接收 Gameplay Agent 委派结果',
status: 'completed',
phase: 'completed',
currentAction: '根据委派结果继续任务',
updatedAt: 4999,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'check_game_creator_llm_config') {
@@ -2530,7 +2549,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: runningRuntimeState.taskQueue,
recentEvents: [],
recentTasks: [runningTask],
recentTasks: [delegateReceiptTask, runningTask],
};
}
if (command === 'start_game_creator_agent_runtime_task') {
@@ -2553,10 +2572,15 @@ describe('AI 游戏创作 App 界面边界', () => {
},
recentEvents: [],
recentTasks: [
delegateReceiptTask,
runningTask,
{
...runningTask,
runId: String(args?.runId ?? 'launcher-agent-task-pending'),
source: 'agent-background-task',
parentAgentId: null,
parentRunId: null,
delegationId: null,
task: String(args?.task ?? ''),
status: 'pending',
phase: 'queued',
@@ -2577,6 +2601,16 @@ describe('AI 游戏创作 App 界面边界', () => {
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
expect(
screen.getByText(
'task: design-director · 委派自:game-director · 父 rungame-director-run-1 · 委派:delegation-design-1',
),
).not.toBeNull();
expect(
screen.getByText(
'completed / completed · 接收 Gameplay Agent 委派结果 · 来源:委派回执 · 委派:delegation-gameplay-1',
),
).not.toBeNull();
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
target: { value: '排队整理第二个需求' },
@@ -4136,4 +4136,5 @@
- 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。
- 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。
- 决策:后台 Agent loop 只有空 actions 才算收束;三轮预算耗尽仍有动作时写 `failed / budget-exhausted``loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。
- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败和默认工具白名单一致性;前端分别覆盖主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command
- 决策:`agent.delegate` 子任务必须 durable 保存 `parentAgentId / parentRunId / delegationId`,其中 `delegationId` 从已持久化工具动作的 `actionId` 派生,不能使用执行时随机值;终态任务记录必须保存经过统一凭据清洗和安全截断的 `terminalDetail`,不能依赖可能被后续 run 覆盖的 Agent 全局 state。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态后,Runtime 必须在 delegation 级 OS 文件锁内按固定 receipt runId 幂等生成且至多生成一次 `agent.delegate.result` 回执;不同委派并发写同一目标 Agent 时,runId 分配与 pending 追加还必须在目标 Agent 任务账本 OS 锁内原子完成。失败、排队或活跃取消、预算耗尽与成功同等需要回执,`needs-reconciliation` 只有最终取消后才回执。父 Agent 通过既有队列接收 `source=agent-delegate-receipt` 的续跑任务,回执 prompt 禁止重复同一委派,并携带完整的已清洗 `terminalDetail`,不能只保留 UI 摘要;排队期间不提前写入父会话,真正执行时才幂等落盘,用户消息或回执消息落盘失败时不得进入 LLM。回执任务必须保留父 run 关联,真正开始或恢复前再次核验父 run,关联缺失或父 run 不存在时失败关闭;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活。父 Session 存在未结束委派时禁止切换或归档,极端竞态下回执回落到父 Agent 当前可写 Session。续跑继续遵守同 Agent FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障,不允许直接重入、插队或重复投递;恢复必须先恢复 pending action / reconciliation 屏障,再补齐“子终态已落盘、回执未入队”的崩溃窗口
- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;前端分别覆盖主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。
@@ -66,6 +66,7 @@ Agent Runtime 负责:
- 2026-07-10 补充:后台任务工具箱已加入 `project.diff`。Agent 可在 loop 中基于已存在 checkpoint 查看当前项目新增、修改和删除摘要;Runtime 复用 `project.diff` 项目权限策略,策略要求确认或拒绝时不执行 diff,observation 只包含 checkpoint id、三类计数和项目相对路径,不返回本机绝对路径或文件正文。
- 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。
- 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。
- 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`
- 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/<agentId>.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。
- 2026-07-10 补充:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。普通前台聊天仍可使用角色上下文。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。
- 2026-07-10 补充:同一 Agent 的前台直接聊天、流式聊天和后台任务统一使用 `.agent/runtime/locks/<agentId>.lock` OS 文件锁。前台聊天不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的聊天结果改判为失败。不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。