补齐Agent会话分叉能力
新增 Session 分叉命令与开发窗口交互 统一 Session 变更和 Runtime 入队线性化锁 补齐持久化、失败回滚、并发边界与前端隔离测试 同步 Runtime 技术方案和项目决策记录
This commit is contained in:
@@ -1277,6 +1277,35 @@ pub(crate) fn start_game_creator_agent_background_task_for_session_at(
|
||||
.map(|(result, _run_id)| result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn start_game_creator_agent_background_task_with_session_lane_hook_at<F>(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: Option<&str>,
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
after_lane_acquired: F,
|
||||
) -> Result<AgentRuntimeResult, String>
|
||||
where
|
||||
F: FnOnce(),
|
||||
{
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
validate_project_root(root)?;
|
||||
with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || {
|
||||
after_lane_acquired();
|
||||
start_game_creator_agent_background_task_with_link_in_session_lane_at(
|
||||
root,
|
||||
&agent_id,
|
||||
session_id,
|
||||
task,
|
||||
run_id,
|
||||
"agent-background-task",
|
||||
None,
|
||||
)
|
||||
.map(|(result, _run_id)| result)
|
||||
})
|
||||
}
|
||||
|
||||
fn start_game_creator_agent_background_task_with_run_id_for_session_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -1318,6 +1347,22 @@ fn start_game_creator_agent_background_task_with_link_at(
|
||||
) -> Result<(AgentRuntimeResult, String), String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
validate_project_root(root)?;
|
||||
with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || {
|
||||
start_game_creator_agent_background_task_with_link_in_session_lane_at(
|
||||
root, &agent_id, session_id, task, run_id, source, task_link,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn start_game_creator_agent_background_task_with_link_in_session_lane_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: Option<&str>,
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
source: &str,
|
||||
task_link: Option<&AgentRuntimeTaskLink>,
|
||||
) -> Result<(AgentRuntimeResult, String), String> {
|
||||
let isolated_instance = agent_id
|
||||
.starts_with("child-")
|
||||
.then(|| resolve_isolated_agent_instance_at(root, &agent_id))
|
||||
@@ -1454,7 +1499,7 @@ fn start_game_creator_agent_background_task_with_link_at(
|
||||
} else {
|
||||
"后台任务从队首开始执行"
|
||||
};
|
||||
let state = start_game_creator_agent_runtime_task_for_session_at(
|
||||
let state = start_game_creator_agent_runtime_task_for_session_in_session_lane_at(
|
||||
root,
|
||||
&agent_id,
|
||||
Some(&next_task.session_id),
|
||||
@@ -1469,7 +1514,7 @@ fn start_game_creator_agent_background_task_with_link_at(
|
||||
let result =
|
||||
read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?;
|
||||
let root = root.to_path_buf();
|
||||
let background_agent_id = agent_id.clone();
|
||||
let background_agent_id = agent_id.to_string();
|
||||
let background_task = next_task.task;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _runtime_lock = runtime_lock;
|
||||
@@ -18444,6 +18489,30 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at(
|
||||
) -> Result<AgentRuntimeState, String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
validate_project_root(root)?;
|
||||
with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 启动", || {
|
||||
start_game_creator_agent_runtime_task_for_session_in_session_lane_at(
|
||||
root,
|
||||
&agent_id,
|
||||
session_id,
|
||||
task,
|
||||
run_id,
|
||||
source,
|
||||
current_action,
|
||||
plan,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: Option<&str>,
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
source: &str,
|
||||
current_action: &str,
|
||||
plan: Vec<String>,
|
||||
) -> Result<AgentRuntimeState, String> {
|
||||
let isolated_instance = agent_id
|
||||
.starts_with("child-")
|
||||
.then(|| resolve_isolated_agent_instance_at(root, &agent_id))
|
||||
|
||||
@@ -944,6 +944,25 @@ pub(crate) fn create_game_creator_agent_session(
|
||||
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn fork_game_creator_agent_session(
|
||||
project_path: String,
|
||||
agent_id: String,
|
||||
source_session_id: String,
|
||||
title: String,
|
||||
) -> Result<AgentConversationSessionListResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
fork_game_creator_agent_session_at(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
source_session_id.trim(),
|
||||
title.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_active_game_creator_agent_session(
|
||||
project_path: String,
|
||||
|
||||
@@ -757,6 +757,10 @@ struct AgentConversationSessionRecord {
|
||||
archived_at: Option<u64>,
|
||||
message_count: u64,
|
||||
legacy: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
forked_from_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
forked_message_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -1449,6 +1453,7 @@ fn main() {
|
||||
delete_local_game_memory,
|
||||
list_game_creator_agent_sessions,
|
||||
create_game_creator_agent_session,
|
||||
fork_game_creator_agent_session,
|
||||
set_active_game_creator_agent_session,
|
||||
archive_game_creator_agent_session,
|
||||
read_local_conversation,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import {
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Home,
|
||||
Image,
|
||||
LogOut,
|
||||
@@ -597,6 +598,8 @@ interface AgentConversationSessionRecord {
|
||||
archivedAt: number | null;
|
||||
messageCount: number;
|
||||
legacy: boolean;
|
||||
forkedFromSessionId?: string | null;
|
||||
forkedMessageCount?: number | null;
|
||||
}
|
||||
|
||||
interface AgentConversationSessionListResult {
|
||||
@@ -630,6 +633,30 @@ function createAgentChatRunId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function formatAgentConversationSessionMeta(
|
||||
session: AgentConversationSessionRecord,
|
||||
sessions: AgentConversationSessionRecord[],
|
||||
activeSessionId: string | null,
|
||||
) {
|
||||
const details = [String(session.messageCount)];
|
||||
if (session.sessionId === activeSessionId) {
|
||||
details.push('活动');
|
||||
}
|
||||
if (session.forkedFromSessionId) {
|
||||
const source = sessions.find(
|
||||
(candidate) => candidate.sessionId === session.forkedFromSessionId,
|
||||
);
|
||||
const sourceLabel = source?.title ?? session.forkedFromSessionId;
|
||||
details.push(
|
||||
session.forkedMessageCount === null ||
|
||||
session.forkedMessageCount === undefined
|
||||
? `分支自 ${sourceLabel}`
|
||||
: `分支自 ${sourceLabel}(${session.forkedMessageCount} 条)`,
|
||||
);
|
||||
}
|
||||
return details.join(' · ');
|
||||
}
|
||||
|
||||
function agentRuntimePlanStepsFromPlan(plan: string[]): AgentRuntimePlanStep[] {
|
||||
return plan
|
||||
.filter((item) => item.trim().length > 0)
|
||||
@@ -3333,7 +3360,9 @@ export function WorkspaceLauncher({
|
||||
function handleAgentChatMessagesScroll(event: UIEvent<HTMLDivElement>) {
|
||||
const messageList = event.currentTarget;
|
||||
const distanceFromBottom =
|
||||
messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight;
|
||||
messageList.scrollHeight -
|
||||
messageList.scrollTop -
|
||||
messageList.clientHeight;
|
||||
agentChatShouldFollowLatestRef.current =
|
||||
distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
|
||||
}
|
||||
@@ -4653,6 +4682,51 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentChatForkSession() {
|
||||
const projectPathForChat = validateAgentChatProjectPath();
|
||||
const agent = selectedLauncherAgentChatAgent();
|
||||
const sourceSession = selectedLauncherAgentChatSession();
|
||||
if (
|
||||
!projectPathForChat ||
|
||||
!agent ||
|
||||
!sourceSession ||
|
||||
agentChatBusy ||
|
||||
agentChatBackgroundBusy
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setAgentChatStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
setAgentChatBusy(true);
|
||||
setAgentChatStatus('正在分叉会话');
|
||||
try {
|
||||
const result = await invoke<AgentConversationSessionListResult>(
|
||||
'fork_game_creator_agent_session',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
sourceSessionId: sourceSession.sessionId,
|
||||
title: '',
|
||||
},
|
||||
);
|
||||
await loadAgentChatConversation(
|
||||
agent.id,
|
||||
projectPathForChat,
|
||||
result.activeSessionId,
|
||||
result,
|
||||
);
|
||||
setAgentChatStatus(`已从“${sourceSession.title}”分叉新会话`);
|
||||
} catch (error) {
|
||||
setAgentChatStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
setAgentChatBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentChatArchiveSession() {
|
||||
const projectPathForChat = validateAgentChatProjectPath();
|
||||
const agent = selectedLauncherAgentChatAgent();
|
||||
@@ -6234,6 +6308,21 @@ export function WorkspaceLauncher({
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="分叉当前 Agent 会话"
|
||||
title="从当前历史分叉会话"
|
||||
disabled={
|
||||
agentChatBusy ||
|
||||
agentChatBackgroundBusy ||
|
||||
agentChatLegacySessionMode ||
|
||||
currentAgentChatSessionMutationBlocked ||
|
||||
!currentAgentChatSession
|
||||
}
|
||||
onClick={() => void handleAgentChatForkSession()}
|
||||
>
|
||||
<GitFork size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="归档当前 Agent 会话"
|
||||
@@ -6272,11 +6361,13 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
>
|
||||
<span>{session.title}</span>
|
||||
<small>{`${session.messageCount}${
|
||||
session.sessionId === agentChatActiveSessionId
|
||||
? ' · 活动'
|
||||
: ''
|
||||
}`}</small>
|
||||
<small>
|
||||
{formatAgentConversationSessionMeta(
|
||||
session,
|
||||
agentChatSessions,
|
||||
agentChatActiveSessionId,
|
||||
)}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{currentAgentChatArchivedSessions.map((session) => (
|
||||
@@ -6294,7 +6385,11 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
>
|
||||
<span>{session.title}</span>
|
||||
<small>已归档</small>
|
||||
<small>{`已归档 · ${formatAgentConversationSessionMeta(
|
||||
session,
|
||||
agentChatSessions,
|
||||
agentChatActiveSessionId,
|
||||
)}`}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1472,7 +1472,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates, switches, archives, and isolates developer Agent sessions', async () => {
|
||||
it('creates, forks, switches, archives, and isolates developer Agent sessions', async () => {
|
||||
type SessionRecord = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
@@ -1481,11 +1481,17 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
archivedAt: number | null;
|
||||
messageCount: number;
|
||||
legacy: boolean;
|
||||
forkedFromSessionId?: string | null;
|
||||
forkedMessageCount?: number | null;
|
||||
};
|
||||
const legacySessionId = 'agent-session-design-director';
|
||||
const roleSessionId = 'agent-session-design-director-role-spec';
|
||||
const createdSessionId = 'agent-session-design-director-created';
|
||||
const forkedSessionId = 'agent-session-design-director-forked';
|
||||
const archivedForkedSessionId =
|
||||
'agent-session-design-director-archived-forked';
|
||||
let activeSessionId = roleSessionId;
|
||||
let forkInvocationCount = 0;
|
||||
const sessions: SessionRecord[] = [
|
||||
{
|
||||
sessionId: legacySessionId,
|
||||
@@ -1556,6 +1562,33 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
messages.set(createdSessionId, []);
|
||||
return sessionResult();
|
||||
}
|
||||
if (command === 'fork_game_creator_agent_session') {
|
||||
const sourceSessionId = String(args?.sourceSessionId);
|
||||
const source = sessions.find(
|
||||
(candidate) => candidate.sessionId === sourceSessionId,
|
||||
);
|
||||
const nextForkedSessionId =
|
||||
forkInvocationCount === 0
|
||||
? forkedSessionId
|
||||
: archivedForkedSessionId;
|
||||
activeSessionId = nextForkedSessionId;
|
||||
forkInvocationCount += 1;
|
||||
sessions.push({
|
||||
sessionId: nextForkedSessionId,
|
||||
title: `分支:${source?.title ?? '会话'}`,
|
||||
createdAt: 3,
|
||||
updatedAt: 3,
|
||||
archivedAt: null,
|
||||
messageCount: messages.get(sourceSessionId)?.length ?? 0,
|
||||
legacy: false,
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
forkedMessageCount: messages.get(sourceSessionId)?.length ?? 0,
|
||||
});
|
||||
messages.set(nextForkedSessionId, [
|
||||
...(messages.get(sourceSessionId) ?? []),
|
||||
]);
|
||||
return sessionResult();
|
||||
}
|
||||
if (command === 'archive_game_creator_agent_session') {
|
||||
const session = sessions.find(
|
||||
(candidate) => candidate.sessionId === args?.sessionId,
|
||||
@@ -1603,6 +1636,35 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '分叉当前 Agent 会话' }),
|
||||
);
|
||||
expect(await screen.findByText('默认会话历史')).not.toBeNull();
|
||||
expect(await screen.findByText(/分支自 默认会话(1 条)/)).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('fork_game_creator_agent_session', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
sourceSessionId: legacySessionId,
|
||||
title: '',
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '只属于分叉会话的问题' },
|
||||
});
|
||||
selectDeveloperAgentChatMode('chat');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
expect(await screen.findByText('只属于分叉会话的问题')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
sessionId: forkedSessionId,
|
||||
prompt: '只属于分叉会话的问题',
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^默认会话/ }));
|
||||
expect(await screen.findByText('默认会话历史')).not.toBeNull();
|
||||
expect(screen.queryByText('只属于分叉会话的问题')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^分支:默认会话/ }));
|
||||
expect(await screen.findByText('只属于分叉会话的问题')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建 Agent 会话' }));
|
||||
expect(await screen.findByText('暂无对话')).not.toBeNull();
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
@@ -1633,6 +1695,20 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '分叉当前 Agent 会话' }),
|
||||
);
|
||||
expect(await screen.findByText(/分支自 新会话/)).not.toBeNull();
|
||||
expect(screen.getByLabelText('Agent 聊天内容')).toHaveProperty(
|
||||
'disabled',
|
||||
false,
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('fork_game_creator_agent_session', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
sourceSessionId: createdSessionId,
|
||||
title: '',
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
|
||||
expect(await screen.findByText('只属于新会话的问题')).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
@@ -1641,7 +1717,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
{
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
sessionId: createdSessionId,
|
||||
sessionId: archivedForkedSessionId,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2196,9 +2272,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
});
|
||||
});
|
||||
expect(
|
||||
await within(messageList).findByText(
|
||||
'已连接 Agent LLM,上游正在重试',
|
||||
),
|
||||
await within(messageList).findByText('已连接 Agent LLM,上游正在重试'),
|
||||
).not.toBeNull();
|
||||
expect(messageList.scrollTop).toBe(120);
|
||||
|
||||
@@ -2408,6 +2482,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
'value',
|
||||
'steer',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '分叉当前 Agent 会话' }),
|
||||
).toHaveProperty('disabled', true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '先补充角色背面规范' },
|
||||
@@ -3848,13 +3925,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
expect(await screen.findByText('Runtime 状态读取失败')).not.toBeNull();
|
||||
expect(screen.getByText('runtime json broken')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '折叠 Runtime 详情' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '折叠 Runtime 详情' }));
|
||||
expect(screen.queryByText('runtime json broken')).toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '展开 Runtime 详情' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 Runtime 详情' }));
|
||||
expect(screen.getByText('runtime json broken')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -4543,6 +4543,14 @@
|
||||
- 接口:Tauri 使用 `steer_game_creator_agent_runtime_task`;CLI 使用 `--agent-steer <project> <agentId> <sessionId> <runId> <steerId> --stdin`。开发窗口与项目内 Agent 面板使用同一默认 steer / 显式排队交互,并把 cancelling 显示为“正在取消”。
|
||||
- 验收:确定性 Rust 已覆盖幂等、冲突、并发 sequence、限制、错误状态、conversation、context/applied 崩溃修复、Provider in-flight 中断、旧写入计划零执行、自动动作 cursor 门禁、确认延后和 finalization 竞态;Runner/CLI 与两个 App 入口定向测试通过。仓库外真实 Provider same-run 专项已 PASS:一次 Provider 中断、原 run 唯一、五阶段 ledger、2 条 user/1 条 assistant、追加正文和已加载密钥零公共泄漏,并实际完成 Runner v1 到 v2 的空闲升级。V1.13 Runner kill 仍需独立复验,不把本次专项结果外推到强杀恢复。
|
||||
|
||||
## 2026-07-14 AI 游戏创作 Agent Runtime V1.14 会话分叉
|
||||
|
||||
- 决策:开发 Agent 窗口新增 `codex fork` 风格的 Session 分叉。分叉从 active、archived 或 legacy Session 完整复制分叉瞬间已持久化的 conversation,保留 role、content、agentId、messageId 和时间,创建带 `forkedFromSessionId / forkedMessageCount` 的新 active Session;源会话和后续消息互不写入,不复制 task/event、Runtime state、pending action、process session、finalization、run history、私有长期记忆或项目黑板,也不推进项目 revision。
|
||||
- 决策:Session 新建、切换、归档、分叉与 Runtime 入队 / 启动共用 per-Agent session lane gate。未显式传 `sessionId` 的 Runtime 只能在线性化点内解析 active Session;Runtime 先入队时分叉看到未结束任务并拒绝,分叉先提交时后续 Runtime 读取新 active,不能成功分叉后把任务或用户消息写回旧会话。
|
||||
- 决策:task journal 对 Session 变更失败关闭,只把 `completed / failed / cancelled` 且 phase 非 `needs-reconciliation` 视为终态;未知、矛盾、损坏和不可读父/子任务日志均阻断。分叉文件先 `create_new` 完整写入,再原子更新 catalog;catalog 写失败删除未登记文件,Session list 获取 catalog lock,不能把提交中的文件提前暴露为恢复会话。
|
||||
- 接口:Tauri 新增 `fork_game_creator_agent_session(projectPath, agentId, sourceSessionId, title)`;开发 Agent 窗口提供分叉按钮、来源与复制消息数显示,成功后按返回的 activeSessionId 加载历史。运行中或 reconciliation 禁用;归档 Session 保持只读,但 lane 空闲时仍可作为分叉源。
|
||||
- 验收:Tauri 全量 639 项中 635 通过、4 项真实浏览器 opt-in 用例按设计忽略;分叉定向覆盖空会话、消息与 messageId 精确复制、active / archived / legacy、源与分支隔离、重复分叉、非 active 源任务、委派 child、损坏 journal、catalog 失败清理、Runtime 入队竞态和未提交文件不可见。客户端测试目录 268/268 通过,覆盖精确源 Session、复制历史、新 Session 后续写入、切回源会话隔离、归档源分叉和忙碌禁用;shell typecheck 通过。
|
||||
|
||||
## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环
|
||||
|
||||
- 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。
|
||||
|
||||
@@ -642,6 +642,18 @@ V1.13 补齐 Codex CLI 风格的运行中 steering:用户可在 Agent 仍处
|
||||
|
||||
2026-07-14 `agent-runtime:steer-real-e2e` 使用仓库外真实 Provider 配置通过 same-run 专项:一次 steer 命中 planning Provider await 并返回 `providerInterrupted=true`,task 仅包含原 run,ledger 精确形成 `prepared / conversation-persisted / queued / applied / closed`,conversation 为 2 条 user 和 1 条 assistant,追加正文与已加载密钥在公共持久面命中均为 0。专项同时实际完成旧 Runner v1 空闲退出与 v2 替换。该套件不包含 Runner 强杀恢复,因此 V1.13 的 kill 场景仍保留为独立复验项,不借用本次 PASS 扩大结论。
|
||||
|
||||
## V1.14 Agent 会话分叉
|
||||
|
||||
V1.14 对标 `codex fork`,允许开发者从任意已有静态 Agent 会话创建一条独立后续。分叉不是新建空白会话,也不是复制运行中的 task:新 Session 只继承分叉瞬间已经持久化的 conversation,源 Session 保持原样,之后两边的消息、run 和最终回复互不写入对方。
|
||||
|
||||
- Tauri 新增 `fork_game_creator_agent_session(projectPath, agentId, sourceSessionId, title)`;开发 Agent 窗口在会话工具栏提供分叉按钮。源 Session 可以是 active、archived 或 legacy Session,成功后新 Session 立即成为 active 并加载复制后的历史;默认标题为 `分支:<源标题>`,仍受 80 字符上限约束。
|
||||
- catalog 的 Session 记录新增可选 `forkedFromSessionId / forkedMessageCount`,旧 v1 catalog 缺字段时按 `None` 读取,不批量迁移。分叉生成新的不可预测 sessionId,复制前完整解析源 JSONL,保留 role、content、agentId、messageId 和原时间;不复制 Runtime state、task/event、pending action、process session、finalization journal 或 run history,也不复制 / 分裂 Agent 私有长期记忆和项目黑板。
|
||||
- 分叉前必须确认当前 active Session 与源 Session 都没有 pending、running、waiting-for-confirmation、cancelling、needs-reconciliation 或未结束委派 child。Session 新建、切换、归档、分叉和 Runtime 入队 / 启动先获取同一条 per-Agent session lane gate,再按源 conversation append lock -> Session catalog lock -> task/event 持久化的顺序推进;未显式传 `sessionId` 的 Runtime 必须在线性化点内解析 active Session,不能在分叉提交后把任务或用户消息写回旧 active。
|
||||
- task journal 只把 `completed / failed / cancelled` 且 phase 非 `needs-reconciliation` 视为终态;未知状态、矛盾状态、损坏 JSONL 和不可读 child journal 一律失败关闭。新 conversation 文件先以 `create_new` 创建并完整写入,再更新 catalog;catalog 写入失败时删除新文件,进程在文件创建后崩溃则由现有 orphan JSONL 恢复逻辑发现为恢复会话。Session list 获取 catalog lock,因此不能在 catalog 提交前把临时 JSONL 暴露为恢复会话。分叉只修改 `.agent` 控制面,不推进项目 revision,不伪造复制消息的 conversation audit;后续新消息和新 run 使用新 Session 身份正常审计。
|
||||
- 前端在请求成功后直接使用返回的 `activeSessionId` 重载会话,并展示来源和分叉消息数。运行中或 reconciliation 状态禁用分叉;归档会话保持只读但允许在 Agent lane 空闲时作为分叉源。
|
||||
|
||||
确定性验收必须覆盖 active / archived / legacy 源、空会话、消息与 messageId 精确复制、源与分叉后续隔离、provenance 持久化、运行中父任务和委派 child 阻断、非 active 源任务阻断、损坏 task journal 失败关闭、默认 Session Runtime 入队与分叉线性化、未提交分叉文件不可见、非法源 ID、catalog 写入失败清理以及重复点击创建不同 Session。前端测试必须证明按钮调用精确源 Session、成功后加载复制历史并切换 active、后续消息写入新 Session 且源会话不变、归档源可分叉、Runtime 忙时按钮禁用。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `npm run ai-game-creator-shell:typecheck`
|
||||
|
||||
@@ -44,6 +44,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod
|
||||
|
||||
2026-07-14 起,同一文档的“V1.13 当前 Run 追加指令与 Provider 中断”作为运行中补充要求的新事实源。开发窗口和项目内 Agent 面板在匹配静态 Agent、Session 和非终态 run 时默认调用 `steer_game_creator_agent_runtime_task`,显式“排队新任务”才继续创建新 run。正文只进入私有 steer ledger 与幂等 user conversation;公共 Runtime、event、Agent DB、Runner RPC 和结果只保留 steerId、sequence、messageId、SHA-256、长度、状态和中断标记。Runner 只中断 planning / final reply 的 Provider await,工具、副作用、确认、process session、Git commit、receipt 和 finalization 都不强杀;Provider 返回、每个 terminal observation 和 finalization 前复核 cursor,发现新指令即丢弃旧计划剩余动作或旧回复并在同一 run 重新规划。`--agent-steer ... --stdin` 提供无 UI 开发验收入口。确定性链路已证明同 ID 幂等、并发 sequence、容量与状态拒绝、context/applied 崩溃修复、Provider in-flight 旧 `file.write` 计划零执行、确认动作保持原 fingerprint、finalization 双向门禁和两个前端入口;仓库外真实 Provider 的 same-run 专项也已证明一次 Provider 中断、原 run 唯一、五阶段 ledger、2 条 user/1 条 assistant 及正文和密钥零公共泄漏。V1.13 Runner 强杀恢复仍是独立复验项,不包含在该专项 PASS 中。
|
||||
|
||||
2026-07-14 起,同一文档的“V1.14 Agent 会话分叉”补齐 `codex fork` 风格的开发会话分支。开发 Agent 窗口可从 active、archived 或 legacy Session 复制截至当前的持久 conversation,创建带来源记录的新 active Session;源会话不变,后续消息与 run 按新 Session 隔离。分叉不复制 Runtime / pending action / process session,不推进项目 revision,并在当前 Agent 或委派 child 未终态时拒绝执行;Session 变更与 Runtime 入队共用 per-Agent lane gate,损坏任务日志失败关闭,catalog 提交前的分叉文件不会被列表暴露。
|
||||
|
||||
2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。
|
||||
|
||||
2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。
|
||||
|
||||
Reference in New Issue
Block a user