补齐 Direct 回合跨页面恢复
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m25s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m57s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled

新增活动 Direct 回合只读快照与状态更新
接入重进项目的忙碌态恢复和运行中项目面板
补充生命周期规范、决策记录与定向测试
This commit is contained in:
kdletters
2026-09-15 22:25:55 +08:00
parent 3cd772b875
commit 9bbefdc371
15 changed files with 632 additions and 3 deletions
@@ -5,6 +5,7 @@ use std::future::Future;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
mod user_input;
pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id};
@@ -266,6 +267,25 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String
#[derive(Debug)]
struct DirectTaonierActiveInvocation {
invocation_id: String,
project_name: Option<String>,
started_at: u64,
status: String,
activity: Option<String>,
updated_at: u64,
sequence: u64,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectActiveTurnSnapshot {
pub(crate) project_path: String,
pub(crate) project_name: Option<String>,
pub(crate) turn_id: String,
pub(crate) started_at: u64,
pub(crate) status: String,
pub(crate) activity: Option<String>,
pub(crate) updated_at: u64,
pub(crate) sequence: u64,
}
static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock<
@@ -303,6 +323,18 @@ impl DirectTaonierActiveInvocationGuard {
root.clone(),
DirectTaonierActiveInvocation {
invocation_id: invocation_id.to_string(),
project_name: root
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string),
started_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default(),
status: "accepted".to_string(),
activity: Some("request-accepted".to_string()),
updated_at: 0,
sequence: 0,
},
);
}
@@ -331,6 +363,57 @@ impl Drop for DirectTaonierActiveInvocationGuard {
}
}
pub(crate) fn list_direct_active_turns() -> Result<Vec<DirectActiveTurnSnapshot>, String> {
let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|_| "Direct 调用身份锁已损坏".to_string())?;
let mut turns = active
.iter()
.map(|(root, invocation)| DirectActiveTurnSnapshot {
project_path: root.to_string_lossy().into_owned(),
project_name: invocation.project_name.clone(),
turn_id: invocation.invocation_id.clone(),
started_at: invocation.started_at,
status: invocation.status.clone(),
activity: invocation.activity.clone(),
updated_at: invocation.updated_at,
sequence: invocation.sequence,
})
.collect::<Vec<_>>();
turns.sort_by(|left, right| left.project_path.cmp(&right.project_path));
Ok(turns)
}
pub(crate) fn update_direct_active_turn(
root: &Path,
turn_id: &str,
status: &str,
activity: Option<&str>,
sequence: u64,
updated_at: u64,
) {
let Ok(root) = root.canonicalize() else {
return;
};
let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else {
return;
};
let Ok(mut active) = active.lock() else {
return;
};
let Some(invocation) = active.get_mut(&root) else {
return;
};
if invocation.invocation_id != turn_id || sequence < invocation.sequence {
return;
}
invocation.status = status.to_string();
invocation.activity = activity.map(str::to_string);
invocation.updated_at = updated_at;
invocation.sequence = sequence;
}
pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<String, String> {
let root = root
.canonicalize()
@@ -4766,6 +4849,36 @@ mod tests {
.expect("lost-response replay after the original turn finishes");
}
#[test]
fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() {
let root = tempfile::tempdir().expect("active snapshot root");
let turn_id = "client-turn-snapshot-0001";
let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id)
.expect("active snapshot turn");
update_direct_active_turn(
root.path(),
turn_id,
"streaming",
Some("response-finalization"),
3,
42,
);
let snapshot = list_direct_active_turns()
.expect("list active turns")
.into_iter()
.find(|turn| turn.turn_id == turn_id)
.expect("snapshot entry");
assert_eq!(snapshot.status, "streaming");
assert_eq!(snapshot.activity.as_deref(), Some("response-finalization"));
assert_eq!(snapshot.sequence, 3);
assert_eq!(snapshot.updated_at, 42);
drop(guard);
assert!(list_direct_active_turns()
.expect("list after completion")
.into_iter()
.all(|turn| turn.turn_id != turn_id));
}
#[test]
fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() {
let root = tempfile::tempdir().expect("temp dir");
@@ -74,15 +74,23 @@ impl DirectGameCreatorTurnUpdateEmitter {
if !status_is_allowed || !activity_is_allowed {
return;
}
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1;
let updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64;
update_direct_active_turn(
Path::new(&self.project_path),
&self.turn_id,
status,
activity,
sequence,
updated_at,
);
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let _ = app.emit(
"game-creator-direct-turn-update",
GameCreatorDirectTurnUpdateEvent {
@@ -5279,6 +5279,12 @@ pub(crate) async fn read_agent_runtime_error_detail(
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns(
) -> Result<Vec<DirectActiveTurnSnapshot>, String> {
list_direct_active_turns()
}
#[tauri::command]
pub(crate) async fn subscribe_direct_project_thread(
project_path: String,
@@ -2770,6 +2770,7 @@ fn main() {
read_local_conversation,
read_direct_project_conversation,
read_agent_runtime_error_detail,
list_game_creator_direct_active_turns,
subscribe_direct_project_thread,
consume_direct_project_thread,
read_direct_project_history_slice,
+24
View File
@@ -57,6 +57,7 @@ import type {
DesignView,
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
GameCreatorDirectActiveTurn,
GameCreatorDirectTurnUpdateEvent,
GameCreatorLlmConfigStatus,
GameCreatorManifestInvalidatedEvent,
@@ -1955,6 +1956,29 @@ export function App({
};
const bootstrap = async () => {
try {
const activeTurns = await directInvoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
const activeTurn = activeTurns.find((turn) =>
projectPathsMatchForInvalidation(turn.projectPath, projectPath),
);
if (activeTurn && !activeDirectCodexTurnRef.current) {
activeDirectCodexTurnRef.current = {
projectPath,
turnId: activeTurn.turnId,
lastSequence: 0,
receivedDirectUpdate: true,
};
setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`);
setChatAgentBusy(true);
setDirectCodexStatus('running');
setDirectCodexProgress('正在处理');
setDirectCodexProgressUpdatedAt(Date.now());
}
} catch {
// 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。
}
const result = await directInvoke<DirectThreadSubscriptionBootstrap>(
'subscribe_direct_project_thread',
{ projectPath },
@@ -1258,3 +1258,14 @@ export type TauriInvoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
export type GameCreatorDirectActiveTurn = {
projectPath: string;
projectName?: string | null;
turnId: string;
startedAt: number;
status: string;
activity?: string | null;
updatedAt: number;
sequence: number;
};
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types';
/**
* 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。
* "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。
*/
export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000;
/**
* 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把
* "读不到"告诉用户;但即便告诉,也只能说读取失败,不得改写成业务失败、
* 权限问题或审批结论。
*/
export const DIRECT_ACTIVE_TURNS_READ_ATTEMPTS = 3;
const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300;
/**
* 当前进程里仍在跑的 Direct 回合。
*
* 回合属于项目而不是页面:离开项目界面不会终止它,所以"谁在跑"必须从 Rust 的
* 活动回合注册表读,而不是从当前页面的组件状态推断。读取失败保留上一份快照——
* 读不到不等于"没有在跑",调用方不能据此阻断发送或清空状态。
*/
export function useDirectActiveTurns({
invoke,
enabled,
pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
}: {
invoke: TauriInvoke | null | undefined;
enabled: boolean;
pollIntervalMs?: number;
}) {
const [activeTurns, setActiveTurns] = useState<GameCreatorDirectActiveTurn[]>(
[],
);
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
const mountedRef = useRef(true);
const inFlightRef = useRef<Promise<void> | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refreshActiveTurns = useCallback(async () => {
if (!invoke) {
return;
}
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
if (inFlightRef.current) {
return inFlightRef.current;
}
const request = (async () => {
for (
let attempt = 1;
attempt <= DIRECT_ACTIVE_TURNS_READ_ATTEMPTS;
attempt++
) {
try {
const turns = await invoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
if (!mountedRef.current) {
return;
}
setActiveTurns(Array.isArray(turns) ? turns : []);
setSnapshotReadFailed(false);
inFlightRef.current = null;
return;
} catch {
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
await new Promise((resolve) =>
window.setTimeout(
resolve,
DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt,
),
);
}
}
}
// 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。
if (mountedRef.current) {
setSnapshotReadFailed(true);
}
inFlightRef.current = null;
})();
inFlightRef.current = request;
return request;
}, [invoke]);
useEffect(() => {
if (!enabled || !invoke) {
setActiveTurns([]);
setSnapshotReadFailed(false);
return;
}
void refreshActiveTurns();
const timer = window.setInterval(
() => void refreshActiveTurns(),
Math.max(1_000, pollIntervalMs),
);
return () => window.clearInterval(timer);
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
}
@@ -0,0 +1,119 @@
import type { GameCreatorDirectActiveTurn } from '../../app/types';
import { projectNameFromPath } from '../agent-runtime';
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
/**
* 左上角的"正在运行的项目"面板。
*
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
* 整块不渲染,不留空白占位。
*/
export type ActiveProjectRunsPanelProps = {
activeTurns: GameCreatorDirectActiveTurn[];
currentProjectPath?: string | null;
readFailed?: boolean;
onOpenProject?: (projectPath: string) => void;
};
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
accepted: '已受理',
running: '创作中',
streaming: '生成中',
finalizing: '收尾中',
completed: '已完成',
failed: '已失败',
};
function activeTurnStatusLabel(status: string) {
return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中';
}
function formatActiveTurnElapsed(startedAt: number, now: number) {
const elapsedMs = now - startedAt;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return '';
}
const totalMinutes = Math.floor(elapsedMs / 60_000);
if (totalMinutes < 1) {
return '不到 1 分钟';
}
if (totalMinutes < 60) {
return `${totalMinutes} 分钟`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes}`;
}
function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) {
const snapshotName = turn.projectName?.trim();
return snapshotName || projectNameFromPath(turn.projectPath);
}
export function ActiveProjectRunsPanel({
activeTurns,
currentProjectPath = null,
readFailed = false,
onOpenProject,
}: ActiveProjectRunsPanelProps) {
if (activeTurns.length === 0) {
if (!readFailed) {
return null;
}
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
<span className="launcher-runs-panel-note" role="status">
</span>
</aside>
);
}
const now = Date.now();
const orderedTurns = [...activeTurns].sort(
(left, right) => left.startedAt - right.startedAt,
);
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
<header className="launcher-runs-panel-header">
<span className="launcher-runs-panel-dot" aria-hidden="true" />
<strong></strong>
</header>
<ul className="launcher-runs-panel-list">
{orderedTurns.map((turn) => {
const name = activeTurnDisplayName(turn);
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
const isCurrent = Boolean(
currentProjectPath &&
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
);
return (
<li key={`${turn.projectPath}:${turn.turnId}`}>
<button
type="button"
className="launcher-runs-panel-item"
aria-current={isCurrent ? 'true' : undefined}
disabled={!onOpenProject}
onClick={() => onOpenProject?.(turn.projectPath)}
>
<span className="launcher-runs-panel-name" title={name}>
{name}
</span>
<span className="launcher-runs-panel-meta">
{[activeTurnStatusLabel(turn.status), elapsed]
.filter(Boolean)
.join(' · ')}
</span>
</button>
</li>
);
})}
</ul>
</aside>
);
}
@@ -25,8 +25,10 @@ import {
type ProjectManifestSnapshotSource,
rereadAuthoritativeProjectManifestSnapshot,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel';
import {
DeveloperAgentDialogs,
DeveloperAgentPanel,
@@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({
homeCreationBusy,
homeCreationRecoverableProjectPath,
} = homeProject;
const directInvoke = resolveTauriInvoke();
const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({
invoke: directInvoke,
enabled: true,
});
const switchedToGameRuntime =
gameRuntimeSwitch !== null &&
currentProjectContext !== null &&
@@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({
</header>
) : null}
<ActiveProjectRunsPanel
activeTurns={activeTurns}
currentProjectPath={currentProjectContext?.projectPath ?? null}
readFailed={snapshotReadFailed}
onOpenProject={(nextProjectPath) => {
setProjectPath(nextProjectPath);
void openProject(nextProjectPath, 'open');
}}
/>
{launcherView === 'home' ? (
<HomeView
hasPromo={launcherNotifications.length > 0}
+74
View File
@@ -903,6 +903,80 @@ textarea {
overflow: hidden;
}
.launcher-runs-panel {
position: absolute;
top: 16px;
left: 24px;
z-index: 3;
width: min(280px, calc(100% - 260px));
padding: 10px 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent);
box-shadow: 0 8px 24px rgb(31 24 16 / 8%);
}
.launcher-runs-panel-header {
display: flex;
align-items: center;
gap: 7px;
color: var(--platform-text-strong);
font-size: 12px;
}
.launcher-runs-panel-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #4d9f54;
box-shadow: 0 0 0 3px rgb(77 159 84 / 14%);
}
.launcher-runs-panel-list {
display: grid;
gap: 4px;
margin: 7px 0 0;
padding: 0;
list-style: none;
}
.launcher-runs-panel-item {
display: grid;
width: 100%;
gap: 2px;
padding: 6px 7px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.launcher-runs-panel-item:hover,
.launcher-runs-panel-item[aria-current='true'] {
background: rgb(255 255 255 / 72%);
}
.launcher-runs-panel-item:disabled {
cursor: default;
}
.launcher-runs-panel-name {
overflow: hidden;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.launcher-runs-panel-meta,
.launcher-runs-panel-note {
color: var(--platform-text-muted);
font-size: 11px;
}
.launcher-promo {
display: flex;
align-items: center;
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
afterEach(() => cleanup());
it('按开始时间展示正在运行的项目并支持进入项目', () => {
const onOpenProject = vi.fn();
render(
<ActiveProjectRunsPanel
activeTurns={[
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
]}
onOpenProject={onOpenProject}
/>,
);
const items = screen.getAllByRole('button');
expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
true,
false,
]);
fireEvent.click(items[0]);
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
});
it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
render(<ActiveProjectRunsPanel activeTurns={[]} readFailed />);
expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
});