补齐单Agent运行中追加指令
新增同一 Run 持久化追加指令、Provider 中断与安全重规划链路 补齐开发窗口、项目 Agent 面板和 CLI 的 steer 入口与状态交互 升级 Runner 协议并支持旧 Runner 空闲退出后平滑替换 增加真实 Provider steer 验收脚本并修复 finalization 与 Git 提交回归 同步 Runtime 技术方案、实施计划和共享决策记录
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
|
||||
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
|
||||
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
|
||||
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
const manifestPath = path.join(appRoot, 'src-tauri/Cargo.toml');
|
||||
const agentId = 'code-prototype';
|
||||
const runId = `steer-real-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const projectRoot = path.join(os.tmpdir(), `genarrative-${runId}`);
|
||||
const marker = `STEER_REAL_${randomUUID().replaceAll('-', '').toUpperCase()}`;
|
||||
const instruction = `运行中补充:继续保持只读,不调用写入、命令、预览或生成工具;最终回复必须原样包含 ${marker},并明确说明已按追加指令更新。`;
|
||||
const timeoutMs = 8 * 60 * 1000;
|
||||
const pollMs = 200;
|
||||
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const evidence = {
|
||||
providerInterrupted: false,
|
||||
steerAttempts: 0,
|
||||
taskRunIds: [],
|
||||
steerStatuses: [],
|
||||
userMessageCount: 0,
|
||||
assistantMessageCount: 0,
|
||||
publicInstructionLeakCount: 0,
|
||||
loadedKeyLeakCount: 0,
|
||||
};
|
||||
let status = 'FAIL';
|
||||
let error = null;
|
||||
let binary = null;
|
||||
|
||||
try {
|
||||
const config = await loadConfig(options.configDir);
|
||||
const secrets = collectSecrets(config);
|
||||
assert(isAgentConfigured(config, agentId), 'llm-not-configured');
|
||||
binary = await prepareCliBinary();
|
||||
await runCli(
|
||||
[
|
||||
'--agent-enqueue',
|
||||
'--init',
|
||||
projectRoot,
|
||||
agentId,
|
||||
runId,
|
||||
'这是只读验收。不要修改文件、不要执行命令、不要预览或生成资产;请仔细分析后给出一句简短结论。',
|
||||
],
|
||||
null,
|
||||
120_000,
|
||||
);
|
||||
|
||||
const initial = await waitForRuntime(
|
||||
(runtime) => runtime.state.runId === runId && isSteerable(runtime.state),
|
||||
60_000,
|
||||
);
|
||||
const sessionId = initial.state.sessionId;
|
||||
assert(sessionId, 'missing-session-id');
|
||||
|
||||
let latestSteerId = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const steerId = `steer-real-${attempt}-${randomUUID().slice(0, 8)}`;
|
||||
const result = await runCli(
|
||||
[
|
||||
'--agent-steer',
|
||||
projectRoot,
|
||||
agentId,
|
||||
sessionId,
|
||||
runId,
|
||||
steerId,
|
||||
'--stdin',
|
||||
],
|
||||
instruction,
|
||||
120_000,
|
||||
);
|
||||
assert(!result.stdout.includes(instruction), 'steer-body-stdout-leak');
|
||||
const steer = parsePrefixedJson(result.stdout, 'steerJson=');
|
||||
evidence.steerAttempts = attempt;
|
||||
latestSteerId = steerId;
|
||||
evidence.providerInterrupted ||= steer.providerInterrupted === true;
|
||||
if (evidence.providerInterrupted) break;
|
||||
await waitForRuntime(
|
||||
(runtime) =>
|
||||
runtime.state.runId === runId &&
|
||||
Number(runtime.state.appliedSteerCursor ?? 0) >= attempt &&
|
||||
isSteerable(runtime.state),
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
assert(latestSteerId, 'steer-not-accepted');
|
||||
assert(evidence.providerInterrupted, 'provider-not-interrupted');
|
||||
|
||||
const terminal = await waitForRuntime(
|
||||
(runtime) =>
|
||||
runtime.recentTasks?.some(
|
||||
(task) => task.runId === runId && isTerminalTask(task),
|
||||
) === true,
|
||||
timeoutMs,
|
||||
);
|
||||
const terminalTask = terminal.recentTasks.find(
|
||||
(task) => task.runId === runId && isTerminalTask(task),
|
||||
);
|
||||
assert(terminalTask?.status === 'completed', 'steered-run-not-completed');
|
||||
|
||||
const taskRecords = await readJsonl(
|
||||
path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`),
|
||||
);
|
||||
evidence.taskRunIds = [...new Set(taskRecords.map((record) => record.runId))];
|
||||
assert(
|
||||
evidence.taskRunIds.length === 1 && evidence.taskRunIds[0] === runId,
|
||||
'steer-created-new-run',
|
||||
);
|
||||
assert(
|
||||
!taskRecords.some((record) => JSON.stringify(record).includes(instruction)),
|
||||
'steer-body-task-leak',
|
||||
);
|
||||
|
||||
const steerRecords = await readJsonl(
|
||||
path.join(
|
||||
projectRoot,
|
||||
'.agent/runtime/steers',
|
||||
agentId,
|
||||
`${runId}.jsonl`,
|
||||
),
|
||||
);
|
||||
evidence.steerStatuses = steerRecords.map((record) => record.status);
|
||||
assert(evidence.steerStatuses.includes('prepared'), 'steer-prepared-missing');
|
||||
assert(evidence.steerStatuses.includes('queued'), 'steer-queued-missing');
|
||||
assert(evidence.steerStatuses.includes('applied'), 'steer-applied-missing');
|
||||
assert(evidence.steerStatuses.at(-1) === 'closed', 'steer-ledger-not-closed');
|
||||
assert(
|
||||
steerRecords
|
||||
.filter((record) => record.status === 'prepared')
|
||||
.every((record) => typeof record.instruction === 'string'),
|
||||
'steer-prepared-body-missing',
|
||||
);
|
||||
assert(
|
||||
steerRecords
|
||||
.filter((record) => record.status !== 'prepared')
|
||||
.every((record) => record.instruction == null),
|
||||
'steer-body-transition-leak',
|
||||
);
|
||||
|
||||
const conversationPath =
|
||||
sessionId === `agent-session-${agentId}`
|
||||
? path.join(
|
||||
projectRoot,
|
||||
'.agent/conversations/agents',
|
||||
`${agentId}.jsonl`,
|
||||
)
|
||||
: path.join(
|
||||
projectRoot,
|
||||
'.agent/conversations/agents',
|
||||
agentId,
|
||||
'sessions',
|
||||
`${sessionId}.jsonl`,
|
||||
);
|
||||
const messages = await readJsonl(conversationPath);
|
||||
evidence.userMessageCount = messages.filter(
|
||||
(message) => message.role === 'user',
|
||||
).length;
|
||||
evidence.assistantMessageCount = messages.filter(
|
||||
(message) => message.role === 'assistant',
|
||||
).length;
|
||||
assert(
|
||||
evidence.userMessageCount === evidence.steerAttempts + 1,
|
||||
'steer-user-message-count-invalid',
|
||||
);
|
||||
assert(evidence.assistantMessageCount === 1, 'assistant-count-invalid');
|
||||
const assistant = messages.find((message) => message.role === 'assistant');
|
||||
assert(assistant?.content.includes(marker), 'final-assistant-missing-marker');
|
||||
|
||||
const publicPaths = [
|
||||
path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`),
|
||||
path.join(projectRoot, '.agent/runtime/events', `${agentId}.jsonl`),
|
||||
path.join(projectRoot, '.agent/runtime/agents', `${agentId}.json`),
|
||||
path.join(projectRoot, '.agent/agent.db'),
|
||||
];
|
||||
evidence.publicInstructionLeakCount = await countNeedleInFiles(
|
||||
publicPaths,
|
||||
instruction,
|
||||
);
|
||||
assert(evidence.publicInstructionLeakCount === 0, 'steer-body-public-leak');
|
||||
evidence.loadedKeyLeakCount = await countNeedlesInTree(
|
||||
projectRoot,
|
||||
secrets,
|
||||
);
|
||||
assert(evidence.loadedKeyLeakCount === 0, 'loaded-key-project-leak');
|
||||
status = 'PASS';
|
||||
} catch (caught) {
|
||||
error = caught instanceof Error ? caught.message : String(caught);
|
||||
if (error === 'llm-not-configured') status = 'BLOCKED';
|
||||
} finally {
|
||||
if (!options.keepProject) {
|
||||
await fs.rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
status,
|
||||
suite: 'steer',
|
||||
runId,
|
||||
projectKept: options.keepProject,
|
||||
evidence,
|
||||
error,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = status === 'PASS' ? 0 : status === 'BLOCKED' ? 2 : 1;
|
||||
}
|
||||
|
||||
function parseArguments(args) {
|
||||
let configDir = null;
|
||||
let keepProject = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === '--config-dir') {
|
||||
configDir = args[++index];
|
||||
} else if (args[index] === '--keep-project') {
|
||||
keepProject = true;
|
||||
} else {
|
||||
throw new Error(`unknown-argument:${args[index]}`);
|
||||
}
|
||||
}
|
||||
assert(configDir && path.isAbsolute(configDir), 'config-dir-not-absolute');
|
||||
const resolved = path.resolve(configDir);
|
||||
assert(!isInside(repoRoot, resolved), 'config-dir-inside-repository');
|
||||
return { configDir: resolved, keepProject };
|
||||
}
|
||||
|
||||
async function loadConfig(configDir) {
|
||||
const content = await fs.readFile(
|
||||
path.join(configDir, 'game-creator.config.json'),
|
||||
'utf8',
|
||||
);
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
function isAgentConfigured(config, targetAgentId) {
|
||||
const effective = {
|
||||
apiKey: config.llm?.apiKey,
|
||||
baseUrl: config.llm?.baseUrl,
|
||||
model: config.llm?.model,
|
||||
...(config.agentLlm?.[targetAgentId] ?? {}),
|
||||
};
|
||||
return ['apiKey', 'baseUrl', 'model'].every(
|
||||
(key) => typeof effective[key] === 'string' && effective[key].trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function collectSecrets(config) {
|
||||
const secrets = [];
|
||||
for (const candidate of [
|
||||
config.llm?.apiKey,
|
||||
config.editorApi?.apiKey,
|
||||
...Object.values(config.agentLlm ?? {}).map((agent) => agent?.apiKey),
|
||||
]) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length >= 8) {
|
||||
secrets.push(candidate.trim());
|
||||
}
|
||||
}
|
||||
return [...new Set(secrets)];
|
||||
}
|
||||
|
||||
async function prepareCliBinary() {
|
||||
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
|
||||
await runProcess(
|
||||
cargo,
|
||||
['build', '--quiet', '--manifest-path', manifestPath],
|
||||
null,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const metadata = await runProcess(
|
||||
cargo,
|
||||
[
|
||||
'metadata',
|
||||
'--format-version',
|
||||
'1',
|
||||
'--no-deps',
|
||||
'--manifest-path',
|
||||
manifestPath,
|
||||
],
|
||||
null,
|
||||
120_000,
|
||||
);
|
||||
const parsed = JSON.parse(metadata.stdout);
|
||||
return path.join(
|
||||
parsed.target_directory,
|
||||
'debug',
|
||||
`genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runCli(args, input = null, commandTimeoutMs = 60_000) {
|
||||
return runProcess(
|
||||
binary,
|
||||
[...args, '--config-dir', options.configDir],
|
||||
input,
|
||||
commandTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRuntime(predicate, waitMs) {
|
||||
const deadline = Date.now() + waitMs;
|
||||
let latest = null;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await runCli([
|
||||
'--agent-runtime-status',
|
||||
projectRoot,
|
||||
agentId,
|
||||
]);
|
||||
latest = parsePrefixedJson(result.stdout, 'runtimeJson=');
|
||||
if (predicate(latest)) return latest;
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
throw new Error(
|
||||
`runtime-timeout:${latest?.state?.status ?? 'missing'}/${latest?.state?.phase ?? 'missing'}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isSteerable(runtime) {
|
||||
return (
|
||||
['running', 'waiting-for-confirmation'].includes(runtime.status) &&
|
||||
!['cancelling', 'finalizing', 'needs-reconciliation'].includes(
|
||||
runtime.phase,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isTerminalTask(task) {
|
||||
return ['completed', 'failed', 'cancelled'].includes(task.status);
|
||||
}
|
||||
|
||||
function parsePrefixedJson(stdout, prefix) {
|
||||
const line = stdout
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.startsWith(prefix));
|
||||
assert(line, `missing-output:${prefix}`);
|
||||
return JSON.parse(line.slice(prefix.length));
|
||||
}
|
||||
|
||||
async function readJsonl(file) {
|
||||
const content = await fs.readFile(file, 'utf8');
|
||||
return content
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
async function countNeedleInFiles(files, needle) {
|
||||
let count = 0;
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file).catch(() => Buffer.alloc(0));
|
||||
count += countNeedle(content, Buffer.from(needle));
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function countNeedlesInTree(root, needles) {
|
||||
let count = 0;
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const target = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += await countNeedlesInTree(target, needles);
|
||||
} else if (entry.isFile()) {
|
||||
const content = await fs.readFile(target);
|
||||
for (const needle of needles) count += countNeedle(content, Buffer.from(needle));
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function countNeedle(haystack, needle) {
|
||||
if (needle.length === 0) return 0;
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
while (offset <= haystack.length - needle.length) {
|
||||
const index = haystack.indexOf(needle, offset);
|
||||
if (index < 0) break;
|
||||
count += 1;
|
||||
offset = index + needle.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function runProcess(program, args, input, commandTimeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(program, args, {
|
||||
cwd: appRoot,
|
||||
env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
}, commandTimeoutMs);
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(new Error(`command-failed:${code ?? signal}:${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
if (input === null) child.stdin.end();
|
||||
else child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
function isInside(parent, target) {
|
||||
const relative = path.relative(parent, target);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function assert(condition, code) {
|
||||
if (!condition) throw new Error(code);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -31,6 +31,13 @@ pub(crate) enum CliCommand {
|
||||
run_id: String,
|
||||
action_id: String,
|
||||
},
|
||||
AgentSteer {
|
||||
project_path: PathBuf,
|
||||
agent_id: String,
|
||||
session_id: String,
|
||||
run_id: String,
|
||||
steer_id: String,
|
||||
},
|
||||
AgentResume {
|
||||
project_path: PathBuf,
|
||||
},
|
||||
@@ -49,6 +56,7 @@ impl CliCommand {
|
||||
Self::AgentTask { .. }
|
||||
| Self::AgentEnqueue { .. }
|
||||
| Self::AgentConfirm { .. }
|
||||
| Self::AgentSteer { .. }
|
||||
| Self::AgentResume { .. }
|
||||
)
|
||||
}
|
||||
@@ -72,6 +80,7 @@ impl CliCommand {
|
||||
Self::AgentChat { project_path, .. }
|
||||
| Self::AgentRuntimeStatus { project_path, .. }
|
||||
| Self::AgentConfirm { project_path, .. }
|
||||
| Self::AgentSteer { project_path, .. }
|
||||
| Self::AgentResume { project_path }
|
||||
| Self::AgentRun { project_path, .. } => Some((project_path, false)),
|
||||
Self::LlmStatus | Self::RunnerStatus => None,
|
||||
@@ -216,6 +225,33 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
|
||||
lines
|
||||
}
|
||||
|
||||
fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result<String, String> {
|
||||
const MAX_INSTRUCTION_BYTES: usize = 4 * 1024;
|
||||
const MAX_STDIN_BYTES: u64 = 8 * 1024;
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_STDIN_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("从 stdin 读取 Agent 追加指令失败:{error}"))?;
|
||||
if bytes.len() as u64 > MAX_STDIN_BYTES {
|
||||
return Err(format!(
|
||||
"Agent 追加指令 stdin 超过 {MAX_STDIN_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
let instruction = String::from_utf8(bytes)
|
||||
.map_err(|_| "Agent 追加指令 stdin 必须是 UTF-8 文本".to_string())?;
|
||||
let instruction = instruction.trim();
|
||||
if instruction.is_empty() {
|
||||
return Err("Agent 追加指令 stdin 不能为空".to_string());
|
||||
}
|
||||
if instruction.len() > MAX_INSTRUCTION_BYTES {
|
||||
return Err(format!(
|
||||
"Agent 追加指令 stdin 超过 {MAX_INSTRUCTION_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
Ok(instruction.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, String> {
|
||||
if args.first().map(String::as_str) == Some("--llm-status") {
|
||||
return Ok(Some(CliCommand::LlmStatus));
|
||||
@@ -248,6 +284,23 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, S
|
||||
action_id: args[4].trim().to_string(),
|
||||
}));
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("--agent-steer") {
|
||||
const USAGE: &str = "用法:--agent-steer <本地项目绝对路径> <agentId> <sessionId> <runId> <steerId> --stdin";
|
||||
if args.len() != 7 || args.last().map(String::as_str) != Some("--stdin") {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let values = &args[1..6];
|
||||
if values.iter().any(|value| value.trim().is_empty()) {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
return Ok(Some(CliCommand::AgentSteer {
|
||||
project_path: PathBuf::from(&values[0]),
|
||||
agent_id: values[1].trim().to_string(),
|
||||
session_id: values[2].trim().to_string(),
|
||||
run_id: values[3].trim().to_string(),
|
||||
steer_id: values[4].trim().to_string(),
|
||||
}));
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("--agent-resume") {
|
||||
if args.len() != 2 {
|
||||
return Err("用法:--agent-resume <本地项目绝对路径>".to_string());
|
||||
@@ -542,6 +595,41 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
CliCommand::AgentSteer {
|
||||
project_path,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
steer_id,
|
||||
} => {
|
||||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
|
||||
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
|
||||
let instruction = read_cli_agent_steer_instruction(&mut std::io::stdin().lock())?;
|
||||
let mut result = steer_game_creator_agent_runtime_task_at(
|
||||
&project_path,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
&run_id,
|
||||
&steer_id,
|
||||
&instruction,
|
||||
"cli",
|
||||
)?;
|
||||
if !result.provider_interrupted {
|
||||
result.provider_interrupted =
|
||||
steer_external_agent_runner(&project_path, &agent_id, &run_id, &steer_id)?;
|
||||
}
|
||||
println!("agent.steer.accepted");
|
||||
println!("agentId={agent_id}");
|
||||
println!("sessionId={session_id}");
|
||||
println!("runId={run_id}");
|
||||
println!("steerId={steer_id}");
|
||||
println!(
|
||||
"steerJson={}",
|
||||
serde_json::to_string(&result)
|
||||
.map_err(|error| format!("序列化 Agent steer 结果失败:{error}"))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
CliCommand::AgentResume { project_path } => {
|
||||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
|
||||
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
|
||||
@@ -629,3 +717,94 @@ fn initialize_cli_agent_project(project_path: &Path, initialize: bool) -> Result
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn parses_agent_steer_with_stdin_only_contract() {
|
||||
let command = parse_cli_command(&[
|
||||
"--agent-steer".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
"code-prototype".to_string(),
|
||||
"session-7".to_string(),
|
||||
"run-9".to_string(),
|
||||
"steer-11".to_string(),
|
||||
"--stdin".to_string(),
|
||||
])
|
||||
.expect("parse agent steer")
|
||||
.expect("agent steer command");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
CliCommand::AgentSteer {
|
||||
project_path: PathBuf::from("/tmp/game-project"),
|
||||
agent_id: "code-prototype".to_string(),
|
||||
session_id: "session-7".to_string(),
|
||||
run_id: "run-9".to_string(),
|
||||
steer_id: "steer-11".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(command.requires_external_agent_runner());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_steer_rejects_missing_params_and_argv_instruction() {
|
||||
assert!(parse_cli_command(&["--agent-steer".to_string()]).is_err());
|
||||
assert!(parse_cli_command(&[
|
||||
"--agent-steer".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
"code-prototype".to_string(),
|
||||
"session-7".to_string(),
|
||||
"run-9".to_string(),
|
||||
"steer-11".to_string(),
|
||||
])
|
||||
.is_err());
|
||||
assert!(parse_cli_command(&[
|
||||
"--agent-steer".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
"code-prototype".to_string(),
|
||||
"session-7".to_string(),
|
||||
"run-9".to_string(),
|
||||
"steer-11".to_string(),
|
||||
"正文不能出现在 argv".to_string(),
|
||||
])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_agent_steer_instruction_only_from_stdin() {
|
||||
let mut stdin = Cursor::new(" 先停下当前方案,改用键盘操作。\n");
|
||||
assert_eq!(
|
||||
read_cli_agent_steer_instruction(&mut stdin).as_deref(),
|
||||
Ok("先停下当前方案,改用键盘操作。")
|
||||
);
|
||||
let mut empty = Cursor::new(" \r\n\t");
|
||||
assert!(read_cli_agent_steer_instruction(&mut empty).is_err());
|
||||
let mut exact_with_newline = Cursor::new(format!("{}\n", "x".repeat(4 * 1024)));
|
||||
assert_eq!(
|
||||
read_cli_agent_steer_instruction(&mut exact_with_newline)
|
||||
.expect("read exact-size instruction")
|
||||
.len(),
|
||||
4 * 1024
|
||||
);
|
||||
let mut oversized = Cursor::new(vec![b'x'; 4 * 1024 + 1]);
|
||||
assert!(read_cli_agent_steer_instruction(&mut oversized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_steer_requires_external_config_dir() {
|
||||
let mut command = CliCommand::AgentSteer {
|
||||
project_path: std::env::current_dir().expect("current directory"),
|
||||
agent_id: "code-prototype".to_string(),
|
||||
session_id: "session-7".to_string(),
|
||||
run_id: "run-9".to_string(),
|
||||
steer_id: "steer-11".to_string(),
|
||||
};
|
||||
let error = prepare_cli_command_paths(&mut command, None)
|
||||
.expect_err("agent steer must require config dir");
|
||||
assert!(error.contains("--config-dir"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +435,38 @@ pub(crate) fn start_game_creator_agent_runtime_task(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn steer_game_creator_agent_runtime_task(
|
||||
project_path: String,
|
||||
agent_id: String,
|
||||
session_id: String,
|
||||
run_id: String,
|
||||
steer_id: String,
|
||||
instruction: String,
|
||||
) -> Result<AgentRuntimeSteerResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||||
let mut result = steer_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
session_id.trim(),
|
||||
run_id.trim(),
|
||||
steer_id.trim(),
|
||||
instruction.trim(),
|
||||
"tauri",
|
||||
)?;
|
||||
if !result.provider_interrupted
|
||||
&& external_agent_runner_enabled()
|
||||
&& !external_agent_runner_is_server_process()
|
||||
{
|
||||
result.provider_interrupted =
|
||||
steer_external_agent_runner(root, agent_id.trim(), run_id.trim(), steer_id.trim())?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn cancel_game_creator_agent_runtime_task(
|
||||
project_path: String,
|
||||
|
||||
@@ -946,33 +946,19 @@ fn commit_local_git_worktree_at_with_hook(
|
||||
));
|
||||
}
|
||||
|
||||
let update_result = run_git_commit_owned(
|
||||
&root,
|
||||
&command,
|
||||
&[
|
||||
"update-ref".to_string(),
|
||||
"--create-reflog".to_string(),
|
||||
"-m".to_string(),
|
||||
GIT_COMMIT_REFLOG_MESSAGE.to_string(),
|
||||
"HEAD".to_string(),
|
||||
commit_head.clone(),
|
||||
expected_head.to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
GitCommandInput::None,
|
||||
"原子更新 Git 分支",
|
||||
);
|
||||
let update_result = update_git_head_transaction(&root, &command, &commit_head, expected_head);
|
||||
if let Err(error) = update_result {
|
||||
let current_ref = run_git(
|
||||
&root,
|
||||
&command,
|
||||
&["rev-parse", "--verify", &preflight.branch_ref],
|
||||
);
|
||||
if matches!(
|
||||
current_ref.as_deref(),
|
||||
Ok(current)
|
||||
if current.trim() != expected_head && current.trim() != commit_head
|
||||
update_ref_failure_is_explicit_expected_old_competition(
|
||||
&root,
|
||||
&command,
|
||||
&git_dir,
|
||||
&preflight.branch_ref,
|
||||
expected_head,
|
||||
&commit_head,
|
||||
error.message(),
|
||||
),
|
||||
Ok(true)
|
||||
) {
|
||||
return Err(cleanup_pre_ref_index_lock(
|
||||
&index_lock_path,
|
||||
@@ -981,11 +967,17 @@ fn commit_local_git_worktree_at_with_hook(
|
||||
));
|
||||
}
|
||||
return Err(LocalGitCommitError::reconciliation(format!(
|
||||
"Git 分支更新结果无法安全确认:{}",
|
||||
"Git ref/reflog 事务结果无法安全确认,或遗留 ref/reflog lock:{}",
|
||||
error.message()
|
||||
)));
|
||||
}
|
||||
|
||||
ensure_no_git_ref_transaction_locks(&git_dir, &preflight.branch_ref).map_err(|error| {
|
||||
LocalGitCommitError::reconciliation(format!(
|
||||
"Git 分支已前移,但检测到遗留 ref/reflog lock:{error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Err(error) = hook(LocalGitCommitHookPoint::AfterUpdateRef) {
|
||||
return Err(LocalGitCommitError::reconciliation(format!(
|
||||
"Git 分支已前移,但后续步骤失败:{error}"
|
||||
@@ -1003,23 +995,13 @@ fn commit_local_git_worktree_at_with_hook(
|
||||
LocalGitCommitError::reconciliation(format!("Git 分支已前移,但安装新 index 失败:{error}"))
|
||||
})?;
|
||||
|
||||
let branch_after =
|
||||
run_git(&root, &command, &["symbolic-ref", "--quiet", "HEAD"]).map_err(|error| {
|
||||
verify_git_head_and_reflogs(&root, &command, &preflight.branch_ref, &commit_head).map_err(
|
||||
|error| {
|
||||
LocalGitCommitError::reconciliation(format!(
|
||||
"Git 分支已前移,但无法复核附着分支:{error}"
|
||||
"Git 分支已前移,但 HEAD / branch ref 或双 reflog 复核失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let head_after =
|
||||
run_git(&root, &command, &["rev-parse", "--verify", "HEAD"]).map_err(|error| {
|
||||
LocalGitCommitError::reconciliation(format!(
|
||||
"Git 分支已前移,但无法复核新 HEAD:{error}"
|
||||
))
|
||||
})?;
|
||||
if branch_after.trim() != preflight.branch_ref || head_after.trim() != commit_head {
|
||||
return Err(LocalGitCommitError::reconciliation(
|
||||
"Git 分支已前移,但最终 HEAD 或附着分支不一致",
|
||||
));
|
||||
}
|
||||
},
|
||||
)?;
|
||||
let remaining_changed_count =
|
||||
read_remaining_changed_count(&root, &command).map_err(|error| {
|
||||
LocalGitCommitError::reconciliation(format!(
|
||||
@@ -1374,6 +1356,136 @@ fn resolved_commit_blob_mode(
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
fn update_git_head_transaction(
|
||||
root: &Path,
|
||||
command: &GitInspectCommandContext,
|
||||
commit_head: &str,
|
||||
expected_head: &str,
|
||||
) -> Result<(), LocalGitCommitError> {
|
||||
let input = format!("start\nupdate HEAD {commit_head} {expected_head}\nprepare\ncommit\n");
|
||||
run_git_commit_owned(
|
||||
root,
|
||||
command,
|
||||
&[
|
||||
"update-ref".to_string(),
|
||||
"--create-reflog".to_string(),
|
||||
"-m".to_string(),
|
||||
GIT_COMMIT_REFLOG_MESSAGE.to_string(),
|
||||
"--stdin".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
GitCommandInput::Bytes(input.as_bytes()),
|
||||
"原子更新 Git ref/reflog 事务",
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn update_ref_failure_is_explicit_expected_old_competition(
|
||||
root: &Path,
|
||||
command: &GitInspectCommandContext,
|
||||
git_dir: &Path,
|
||||
branch_ref: &str,
|
||||
expected_head: &str,
|
||||
commit_head: &str,
|
||||
error_message: &str,
|
||||
) -> Result<bool, String> {
|
||||
ensure_no_git_ref_transaction_locks(git_dir, branch_ref)?;
|
||||
let attached_branch =
|
||||
read_attached_branch_ref_from_head(git_dir).map_err(|error| error.message().to_string())?;
|
||||
if attached_branch != branch_ref {
|
||||
return Ok(false);
|
||||
}
|
||||
let current_branch = run_git(root, command, &["rev-parse", "--verify", branch_ref])?;
|
||||
let current_head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?;
|
||||
let current_branch = current_branch.trim().to_ascii_lowercase();
|
||||
let current_head = current_head.trim().to_ascii_lowercase();
|
||||
let expected_head = expected_head.to_ascii_lowercase();
|
||||
let commit_head = commit_head.to_ascii_lowercase();
|
||||
if current_branch != current_head
|
||||
|| current_branch == expected_head
|
||||
|| current_branch == commit_head
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let detail = error_message.to_ascii_lowercase();
|
||||
Ok(detail.contains("cannot lock ref")
|
||||
&& detail.contains(&format!(
|
||||
"is at {current_branch} but expected {expected_head}"
|
||||
)))
|
||||
}
|
||||
|
||||
fn verify_git_head_and_reflogs(
|
||||
root: &Path,
|
||||
command: &GitInspectCommandContext,
|
||||
branch_ref: &str,
|
||||
commit_head: &str,
|
||||
) -> Result<(), String> {
|
||||
let attached_branch = run_git(root, command, &["symbolic-ref", "--quiet", "HEAD"])?;
|
||||
let head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?;
|
||||
let branch = run_git(root, command, &["rev-parse", "--verify", branch_ref])?;
|
||||
if attached_branch.trim() != branch_ref
|
||||
|| head.trim() != commit_head
|
||||
|| branch.trim() != commit_head
|
||||
{
|
||||
return Err("最终 HEAD、附着分支或 branch ref 不一致".to_string());
|
||||
}
|
||||
|
||||
for reference in ["HEAD", branch_ref] {
|
||||
let reflog = run_git(
|
||||
root,
|
||||
command,
|
||||
&["reflog", "show", "-1", "--format=%H%x09%gs", reference],
|
||||
)?;
|
||||
let reflog = reflog.trim_end_matches(['\r', '\n']);
|
||||
let Some((reflog_head, reflog_message)) = reflog.split_once('\t') else {
|
||||
return Err(format!("{reference} reflog 缺少结构化尾项"));
|
||||
};
|
||||
if reflog_head != commit_head || reflog_message != GIT_COMMIT_REFLOG_MESSAGE {
|
||||
return Err(format!("{reference} reflog 未同步到受控提交"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_no_git_ref_transaction_locks(git_dir: &Path, branch_ref: &str) -> Result<(), String> {
|
||||
for lock_path in git_ref_transaction_lock_paths(git_dir, branch_ref)
|
||||
.map_err(|error| error.message().to_string())?
|
||||
{
|
||||
match fs::symlink_metadata(&lock_path) {
|
||||
Ok(_) => {
|
||||
let relative = lock_path.strip_prefix(git_dir).unwrap_or(&lock_path);
|
||||
return Err(format!("存在 Git 事务锁 {}", relative.display()));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(format!("检查 Git 事务锁失败:{error}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git_ref_transaction_lock_paths(
|
||||
git_dir: &Path,
|
||||
branch_ref: &str,
|
||||
) -> Result<Vec<PathBuf>, LocalGitCommitError> {
|
||||
validate_local_branch_ref(branch_ref)?;
|
||||
let branch_path = git_dir.join(branch_ref);
|
||||
let branch_reflog = git_dir.join("logs").join(branch_ref);
|
||||
Ok(vec![
|
||||
append_lock_suffix(&git_dir.join("HEAD")),
|
||||
append_lock_suffix(&branch_path),
|
||||
git_dir.join("packed-refs.lock"),
|
||||
append_lock_suffix(&git_dir.join("logs/HEAD")),
|
||||
append_lock_suffix(&branch_reflog),
|
||||
])
|
||||
}
|
||||
|
||||
fn append_lock_suffix(path: &Path) -> PathBuf {
|
||||
let mut value = path.as_os_str().to_os_string();
|
||||
value.push(".lock");
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn cleanup_pre_ref_index_lock(
|
||||
lock_path: &Path,
|
||||
lock_file: &mut Option<File>,
|
||||
@@ -1396,9 +1508,17 @@ fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io:
|
||||
#[cfg(windows)]
|
||||
fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
|
||||
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn MoveFileExW(
|
||||
existing_file_name: *const u16,
|
||||
new_file_name: *const u16,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
let source = index_lock_path
|
||||
.as_os_str()
|
||||
@@ -2171,6 +2291,8 @@ fn build_sandboxed_git_command(
|
||||
.env("GIT_PAGER", "cat")
|
||||
.env("PAGER", "cat")
|
||||
.env("TERM", "dumb")
|
||||
.env("LC_ALL", "C")
|
||||
.env("LANG", "C")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1")
|
||||
.env("GIT_ATTR_NOSYSTEM", "1")
|
||||
.env("GIT_LFS_SKIP_SMUDGE", "1")
|
||||
@@ -2872,6 +2994,49 @@ mod tests {
|
||||
assert!(root.join(".git/index.lock").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_expected_old_competition_with_leftover_reflog_lock_needs_reconciliation() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
let root = fixture.path();
|
||||
init_git_commit_fixture(root, true);
|
||||
fs::write(root.join("game.txt"), "change\n").expect("modify file");
|
||||
let (head, fingerprint) = git_commit_snapshot(root);
|
||||
let competitor = git_output(
|
||||
root,
|
||||
&[
|
||||
"commit-tree",
|
||||
"HEAD^{tree}",
|
||||
"-p",
|
||||
"HEAD",
|
||||
"-m",
|
||||
"competitor",
|
||||
],
|
||||
);
|
||||
let mut hook = |point| {
|
||||
if point == LocalGitCommitHookPoint::BeforeUpdateRef {
|
||||
git(root, &["update-ref", "refs/heads/main", &competitor, &head]);
|
||||
fs::write(root.join(".git/logs/HEAD.lock"), "leftover reflog lock\n")
|
||||
.expect("create leftover reflog lock");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let error = commit_local_git_worktree_at_with_hook(
|
||||
root,
|
||||
"must reconcile",
|
||||
&["game.txt".to_string()],
|
||||
&head,
|
||||
&fingerprint,
|
||||
&mut hook,
|
||||
)
|
||||
.expect_err("leftover reflog lock makes update-ref outcome unsafe");
|
||||
|
||||
assert!(error.needs_reconciliation());
|
||||
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), competitor);
|
||||
assert!(root.join(".git/logs/HEAD.lock").is_file());
|
||||
assert!(root.join(".git/index.lock").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_failure_after_ref_update_needs_reconciliation() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
@@ -2919,6 +3084,25 @@ mod tests {
|
||||
assert!(!index_lock.exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn git_commit_windows_index_install_uses_replace_existing_semantics() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
let index = fixture.path().join("index");
|
||||
let index_lock = fixture.path().join("index.lock");
|
||||
fs::write(&index, b"existing windows index").expect("write existing index");
|
||||
fs::write(&index_lock, b"replacement windows index").expect("write replacement index");
|
||||
|
||||
install_git_index_lock(&index_lock, &index)
|
||||
.expect("MoveFileExW must atomically replace an existing index");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(&index).expect("read replaced windows index"),
|
||||
b"replacement windows index"
|
||||
);
|
||||
assert!(!index_lock.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_git_output_reports_discarded_tail_bytes() {
|
||||
let input = vec![b'x'; GIT_INSPECT_OUTPUT_MAX_BYTES + 17];
|
||||
|
||||
@@ -216,6 +216,12 @@ struct AgentRuntimeState {
|
||||
#[serde(default)]
|
||||
tool_policy: AgentRuntimeToolPolicySnapshot,
|
||||
#[serde(default)]
|
||||
applied_steer_cursor: u64,
|
||||
#[serde(default)]
|
||||
applied_steer_refs: Vec<AgentRuntimeSteerRef>,
|
||||
#[serde(default)]
|
||||
queued_steer_count: u32,
|
||||
#[serde(default)]
|
||||
last_response: Option<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
@@ -223,6 +229,21 @@ struct AgentRuntimeState {
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentRuntimeSteerRef {
|
||||
#[serde(default)]
|
||||
steer_id: String,
|
||||
#[serde(default)]
|
||||
sequence: u64,
|
||||
#[serde(default)]
|
||||
message_id: String,
|
||||
#[serde(default)]
|
||||
instruction_sha256: String,
|
||||
#[serde(default)]
|
||||
content_chars: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentRuntimeToolPolicySnapshot {
|
||||
@@ -424,6 +445,16 @@ struct AgentRuntimeResult {
|
||||
recent_tasks: Vec<AgentRuntimeTaskRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentRuntimeSteerResult {
|
||||
runtime: AgentRuntimeResult,
|
||||
steer_id: String,
|
||||
sequence: u64,
|
||||
status: String,
|
||||
provider_interrupted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorAgentRuntimeUpdateEvent {
|
||||
@@ -1383,6 +1414,7 @@ fn main() {
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
start_game_creator_agent_runtime_task,
|
||||
steer_game_creator_agent_runtime_task,
|
||||
cancel_game_creator_agent_runtime_task,
|
||||
retry_game_creator_agent_runtime_task,
|
||||
confirm_game_creator_agent_runtime_task,
|
||||
|
||||
@@ -3919,13 +3919,12 @@ impl Default for ProjectPermissionPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_PERMISSION_MANDATORY_CONFIRM_COMMANDS: &[&str] =
|
||||
&[
|
||||
"project.git_commit",
|
||||
"command.start",
|
||||
"command.stdin",
|
||||
"command.terminate",
|
||||
];
|
||||
const PROJECT_PERMISSION_MANDATORY_CONFIRM_COMMANDS: &[&str] = &[
|
||||
"project.git_commit",
|
||||
"command.start",
|
||||
"command.stdin",
|
||||
"command.terminate",
|
||||
];
|
||||
|
||||
pub(crate) fn read_project_permission_policy_at(
|
||||
root: &Path,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4533,3 +4533,12 @@
|
||||
- 验收门禁:Runner-kill 套件必须分别从全量 task、event、Agent DB、runtime state 和 process record 证明专用 reconciliation 各精确一次,并证明新 boot reconnect 为 0。activity / output 和可选证据目录只有 `ENOENT` 可视为空;权限、I/O 和 JSON 损坏必须让验收失败,runtime state 是必需证据并纳入公共正文泄漏扫描。
|
||||
- 决策:模型即使在 prompt 明确禁止后仍可能把 `command.poll` 私有正文或短值复述到最终回复;只要本 run 存在非空私有 poll 输出,finalization 在 assistant journal 写入前就把模型回复整体收束为固定安全摘要。原始 PTY 正文仍只留在 owning Agent 私有 context,不能依赖模型自律或按长度猜 token 维持公共边界;没有私有 poll 正文的普通回复保持原样。
|
||||
- 验收:真实 `gpt-5.5` `process-session` 与 `process-session-runner-kill` 均已 PASS。普通套件证明唯一 start、连续 cursor、精确 challenge/echo、graceful terminal 和零公共正文泄漏;强杀套件从 task / event / Agent DB / process record 各证明 1 条专用 reconciliation,runtime state 身份一致,项目进程清零、新 boot 保持同 run / session,reconnect / replay / final 均为 0。真实主机临时范围 32768-60999 被约 2.8 万连接占满时,Runner 使用范围外 loopback 端口完成两套验收。终审回归另通过 44 项 process-session 定向测试、Tauri 全量 587 passed / 4 ignored、Windows GNU check、客户端 typecheck、4961 文件编码检查、rustfmt、Prettier 和 diff check。
|
||||
|
||||
## 2026-07-14 AI 游戏创作 Agent Runtime V1.13 当前 Run 追加指令
|
||||
|
||||
- 决策:运行中输入默认形成 same-run steer,保持 `taskId / sessionId / runId` 不变;只有开发者显式选择“排队新任务”才创建新 run。动态隔离 child 首版拒绝 steer,终态、cancelling、finalizing 和 needs-reconciliation 同样拒绝。
|
||||
- 决策:私有事实源为 `.agent/runtime/steers/<agentId>/<runId>.jsonl`,按 `prepared / conversation-persisted / queued / applied / closed` 只追加推进。正文只出现在 prepared 与确定性 messageId 的 user conversation;公共 task/event/Agent DB/Runner RPC 只保存身份、sequence、SHA-256、长度和状态。同 steerId 同 SHA 幂等,不同正文冲突;单条 4 KiB、单 run 16 条且总计 16 KiB。
|
||||
- 决策:context bundle 和 Runtime state 保存 applied cursor 与安全 refs,pending action 指纹绑定 planned cursor。Provider 前、Provider 后、terminal observation 后和 finalization 前消费或复核;context 先持久化、applied 后追加,恢复以 context cursor 修复缺失 applied audit。自动动作进入 executing 时与 steer acceptance 使用同一项目写锁;确认中、approved 或 executing 动作保持原 fingerprint,terminal receipt 后才消费。
|
||||
- 决策:Runner typed `runtime.steer` 只携带 `root / agent / runId / steerId`,并先核对 durable ledger。中断 registry 只包围 planning 和 final reply HTTP future;不得 abort worker 或中断任何工具和副作用。prepared finalization journal、completed 终态与 steer acceptance 共用项目锁形成双向门禁,completed 前在同锁内关闭 ledger。新增方法把 Runner 协议提升为 v2;旧协议 Runner 只允许在 `shutdown_if_idle` 确认空闲并释放 endpoint 后升级,仍有任务时禁止强杀替换。
|
||||
- 接口: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 仍需独立复验,不把本次专项结果外推到强杀恢复。
|
||||
|
||||
@@ -624,6 +624,24 @@ V1.12 首个切片补齐“修改、验证、审阅、提交”的单 Agent 本
|
||||
|
||||
Git 专项证据证明:真实 Provider 只创建 1 个提交并精确包含 2 个目标路径;原始 commit object 的消息 SHA-256、提交 parent、HEAD、tree、空 staged index、提交后所选路径状态、封闭字段专用审计、terminal receipt、HEAD reflog 与 branch reflog 全部一致,预存 disposable sentinel 继续保持未跟踪且未被夹带。Runner 在主流程早期真实强杀后恢复原 run / session 且身份稳定;副作用重放、重复 action / message / receipt、提交正文或 Git identity 公共持久化、Provider Key、敏感诱饵和报告泄漏均为 0,disposable 项目按 sentinel 自动清理。V1.12 不再仅有确定性本地结论;remote、分支管理和其它 Git 写操作仍保持未开放。
|
||||
|
||||
## V1.13 当前 Run 追加指令与 Provider 中断
|
||||
|
||||
V1.13 补齐 Codex CLI 风格的运行中 steering:用户可在 Agent 仍处于非终态时向同一 `taskId / sessionId / runId` 追加要求,Runtime 在安全边界丢弃过期计划或最终回复并重新规划,不再把每次补充都排成新任务。显式“排队新任务”仍保留,但不是运行中输入的默认行为。
|
||||
|
||||
- 私有事实源固定为 `.agent/runtime/steers/<agentId>/<runId>.jsonl`,状态按 `prepared -> conversation-persisted -> queued -> applied -> closed` 只追加推进。首条 `prepared` 保存正文,公共 task/event/Agent DB、Runner RPC 和终态结果只保存 `steerId / sequence / messageId / instructionSha256 / contentChars / status / providerInterrupted`,不得保存正文。
|
||||
- 身份固定绑定 `projectId / agentId / taskId / sessionId / runId / source / steerId`。调用方生成并在重试时复用 `steerId`;同 ID 同 SHA 幂等返回原结果,同 ID 不同 SHA 拒绝为 `steer-id-conflict`。`sequence` 在项目写锁内从 ledger 单调递增,不能使用时间戳。conversation 使用由完整身份派生的确定性 `messageId`,恢复时必须核对 role、正文 SHA 和 Agent 身份后补齐缺失阶段。
|
||||
- 首版只接受静态父 Agent 的 steer;动态 `child-*` 拒绝。单条正文最多 4 KiB,每 run 最多 16 条、总计最多 16 KiB,拒绝空白、NUL 和非法控制字符。`completed / failed / cancelled / cancelling / needs-reconciliation`、finalization 已 prepared 或 steer ledger 已 closed 时拒绝,错误 session/run/source 也拒绝。
|
||||
- `AgentRuntimeState`、context bundle 和 pending action 分别保存 `appliedSteerCursor`、有界 `appliedSteerRefs` 和 `plannedSteerCursor`。Provider prompt 通过 refs 从 conversation 回读并复核正文,单独渲染有序“运行中用户追加指令”;后序指令可修正前序业务目标,但不能覆盖系统规则、工具策略、确认和沙箱边界。
|
||||
- 消费顺序固定为:先把 queued steer refs 与 cursor 持久化进 context bundle,再追加 applied 审计;若崩溃发生在两步之间,以 context cursor 为准补 audit,不能重复注入。Provider 请求前消费全部 queued;Provider 返回后、每个 terminal observation 后和最终回复返回后都复核 cursor。发现新 steer 时,旧工具计划剩余 action 或旧最终回复全部作废,在同一 run 重新规划。
|
||||
- `pending-confirmation / approved / executing` 动作不被 steer 暗中拒绝或强杀。显式批准继续执行原 fingerprint;terminal receipt 落盘后再消费 steer。自动动作从准备进入 `executing` 时必须与 steer acceptance 使用同一项目写锁复核 `plannedSteerCursor`,避免“检查后、执行前”插入指令。`waiting-for-isolated-join` 可以接收但不能越过 join barrier。
|
||||
- finalization 在项目写锁内先检查 response steer cursor,再写入并推进 finalization journal;prepared journal 一旦存在,steer acceptance 即拒绝。assistant 与 completed 投影全部成功后、释放同一把锁之前追加 `closed`,避免恢复时因项目 revision 漂移而丢弃 prepared journal 后留下不可重开的 ledger。steer 先获得锁则旧回复 stale;finalization 先获得锁则后续 steer 被 journal 或终态拒绝,不能出现 completed assistant 与已接受 steer 并存。
|
||||
- Runner 新增 typed `runtime.steer`,请求只携带 `root / agent / runId / steerId`。Runner 只对 planning 和 final reply 两个纯 Provider await 注册可中断句柄;收到 steer 时 `tokio::select!` 丢弃 HTTP future并返回 `providerInterrupted=true`,不得 abort worker task,也不得中断工具、副作用、确认、process session、Git commit、receipt 或 finalization。中断只表示客户端停止等待,不能承诺上游停止推理或计费。该方法把 Runner 协议提升为 v2;客户端发现旧协议进程时只请求其 `shutdown_if_idle`,确认 endpoint 与实例锁释放后再启动新版,旧 Runner 仍有任务时明确阻止升级而不是强杀。
|
||||
- Tauri 新增 `steer_game_creator_agent_runtime_task`;CLI 新增 `--agent-steer <project> <agentId> <sessionId> <runId> <steerId> --stdin`,正文只能从 stdin 读取。开发窗口和项目内 Agent 面板都默认对匹配的非终态 run 调用 steer,并提供显式“排队新任务”入口;`cancelling` 只能显示“正在取消”。
|
||||
|
||||
确定性验收必须覆盖 steerId 幂等与正文冲突、并发 sequence、容量限制、错误身份和终态拒绝、conversation 恰好一次、四个 ledger/context 崩溃窗口、Provider in-flight 旧计划零 action、多 action 在 action 1 后停止、确认/approved/executing 延后消费、finalization 双向竞态、Runner requestId 防重、CLI stdin 和两个前端入口。真实 Provider E2E 还必须证明 task 队列未新增、run/session 不变、最终 assistant 唯一、副作用零重放、Runner 强杀恢复身份稳定,以及正文和已加载密钥在公共持久面泄漏为 0。
|
||||
|
||||
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 扩大结论。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `npm run ai-game-creator-shell:typecheck`
|
||||
|
||||
@@ -42,6 +42,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod
|
||||
|
||||
2026-07-14 V1.12 真实 `gpt-5.5` 验收已通过。现有 `llm-runtime` disposable 套件要求模型在完整修改、验证和审阅链路末尾自行创建唯一受控提交,并从原始 commit object、真实 Git parent / HEAD / tree、空 staged index、提交后所选路径状态、封闭字段专用审计、terminal receipt 和 HEAD / branch 双 reflog 验真。最终收紧版形成 151 条 task、258 条 event、266 条 Agent DB、17 次代表性成功工具执行、7 套确认生命周期、9 个实际副作用 action 和 44 条 receipt;唯一提交精确包含 2 个目标路径,预存 sentinel 未被夹带,project revision 保持 3。Runner 强杀恢复后 run / session 身份稳定,副作用重放、重复 action / message / receipt、密钥和诱饵泄漏均为 0,disposable 项目已自动清理。
|
||||
|
||||
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-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、重复副作用与密钥扫描继续全部通过。
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --",
|
||||
"ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke",
|
||||
"ai-game-creator-shell:agent-runtime:real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:real-e2e --",
|
||||
"ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --",
|
||||
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
|
||||
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && cargo test -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test -p platform-agent --manifest-path server-rs/Cargo.toml game_creation && cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && npm run ai-game-creator-shell:agent-run:smoke",
|
||||
"check:native-shells": "node scripts/check-native-shells.mjs"
|
||||
|
||||
Reference in New Issue
Block a user