补齐Agent委派结果回执闭环
持久化父子任务关联并将各类终态结果幂等回执给父Agent 补齐并发锁、取消抑制、恢复补漏和对话落盘失败门禁 展示委派来源并完善Runtime回归测试与技术文档
This commit is contained in:
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
@@ -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 · 父 run:game-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 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。
|
||||
|
||||
Reference in New Issue
Block a user