完善Agent Runner恢复与真实进程验收
增加Linux临时端口耗尽时的高位loopback安全回退 修复Runner重启恢复的逐投影幂等与完整身份失败关闭 为Runtime JSONL尾部和进程恢复审计增加修复与冲突校验 强化Runner强杀E2E的分层证据与非ENOENT错误处理 阻止模型最终回复复述私有PTY输出及短值 补齐恢复回归测试、真实Provider验收与技术文档
This commit is contained in:
@@ -34,6 +34,7 @@ const processEchoPrefix = 'GENARRATIVE_PROCESS_ECHO';
|
||||
const processStoppedMarker = 'GENARRATIVE_PROCESS_STOPPED';
|
||||
const pollIntervalMs = 750;
|
||||
const runTimeoutMs = 30 * 60 * 1000;
|
||||
const processRunnerKillStartTimeoutMs = 5 * 60 * 1000;
|
||||
const commandOutputLimit = 4 * 1024 * 1024;
|
||||
const supportedToolPlanProtocols = new Set(['native_function', 'text_json']);
|
||||
const processSessionSuites = new Set([
|
||||
@@ -1042,7 +1043,7 @@ async function driveProcessRuntimeToQuiescence() {
|
||||
}
|
||||
|
||||
async function driveProcessRunnerKillScenario() {
|
||||
const deadline = Date.now() + 120_000;
|
||||
const deadline = Date.now() + processRunnerKillStartTimeoutMs;
|
||||
let runningRecord = null;
|
||||
while (Date.now() < deadline) {
|
||||
await captureProcessSessionContextEvidence();
|
||||
@@ -1056,9 +1057,9 @@ async function driveProcessRunnerKillScenario() {
|
||||
? await captureProcessTranscriptReadiness(runningRecord)
|
||||
: null;
|
||||
if (runningRecord && transcript) {
|
||||
const agentDb = await readJsonl(
|
||||
const agentDb = await readOptionalJsonl(
|
||||
path.join(state.projectRoot, '.agent/agent.db'),
|
||||
).catch(() => []);
|
||||
);
|
||||
const launchEvidence = processLaunchEvidence(
|
||||
agentDb,
|
||||
runningRecord,
|
||||
@@ -1302,6 +1303,16 @@ async function validateProcessSessionEvidence() {
|
||||
);
|
||||
const transcript = await readProcessSessionTranscript(record);
|
||||
registerProcessPrivateOutput(transcript.output, true);
|
||||
const transcriptLines = processOutputLines(transcript.output);
|
||||
assert(
|
||||
transcriptLines.filter((line) => line === state.process.readyLine)
|
||||
.length === 1 &&
|
||||
transcriptLines.filter((line) => line === state.process.echoLine)
|
||||
.length === 1 &&
|
||||
transcriptLines.filter((line) => line === processStoppedMarker).length ===
|
||||
1,
|
||||
'process-transcript-marker-count-invalid',
|
||||
);
|
||||
validateProcessSessionRecord(record, transcript, true);
|
||||
const launchEvidence = validateUniqueProcessLaunchEvidence(
|
||||
persistence.agentDb,
|
||||
@@ -1374,6 +1385,9 @@ async function validateProcessSessionEvidence() {
|
||||
processAgentDbLeakCount: publicLeaks.agentDb,
|
||||
processReceiptLeakCount: publicLeaks.receipt,
|
||||
processConversationLeakCount: publicLeaks.conversation,
|
||||
processActivityLeakCount: publicLeaks.activity,
|
||||
processOutputLeakCount: publicLeaks.output,
|
||||
processRuntimeStateLeakCount: publicLeaks.runtimeState,
|
||||
processReportLeakCount: state.process.reportLeakCount,
|
||||
secretLeakCount: state.transcriptLeakCount + state.projectLeakCount,
|
||||
lureLeakCount: state.lureLeakCount,
|
||||
@@ -1384,6 +1398,9 @@ async function validateProcessSessionEvidence() {
|
||||
'.agent/runtime/process-sessions',
|
||||
'.agent/runtime/context-bundles',
|
||||
'.agent/conversations',
|
||||
'.agent/activity.jsonl',
|
||||
'.agent/output.jsonl',
|
||||
`.agent/runtime/agents/${mainAgentId}.json`,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1393,14 +1410,96 @@ async function validateProcessRunnerKillEvidence() {
|
||||
const records = await readProcessSessionRecords();
|
||||
assert(records.length === 1, 'process-runner-kill-record-count-invalid');
|
||||
const record = records[0];
|
||||
const reconciliationRecords = records.filter(
|
||||
(candidate) =>
|
||||
candidate.status === 'needs-reconciliation' &&
|
||||
candidate.needsReconciliation === true,
|
||||
);
|
||||
const reconciliationTasks = persistence.taskSnapshot.all.filter(
|
||||
(task) =>
|
||||
task.agentId === mainAgentId &&
|
||||
task.runId === state.initialRunId &&
|
||||
task.phase === 'needs-reconciliation',
|
||||
);
|
||||
const reconciliationEvents = persistence.events.filter(
|
||||
(event) =>
|
||||
event.eventType === 'process_session.reconciled_after_runner_restart',
|
||||
);
|
||||
const reconciliationAudits = persistence.agentDb.filter(
|
||||
(audit) =>
|
||||
audit.recordType ===
|
||||
'agent.runtime.process_session.reconciled_after_runner_restart',
|
||||
);
|
||||
const reconnectRecords = records.filter(
|
||||
(candidate) => candidate.ownerBootId === state.process.newRunnerBootId,
|
||||
);
|
||||
assert(
|
||||
record.status === 'needs-reconciliation' &&
|
||||
record.needsReconciliation === true &&
|
||||
reconciliationRecords.length === 1,
|
||||
'process-runner-kill-reconciliation-record-count-invalid',
|
||||
);
|
||||
assert(
|
||||
reconciliationTasks.length === 1,
|
||||
'process-runner-kill-reconciliation-task-count-invalid',
|
||||
);
|
||||
assert(
|
||||
reconciliationEvents.length === 1,
|
||||
'process-runner-kill-reconciliation-event-count-invalid',
|
||||
);
|
||||
assert(
|
||||
reconciliationAudits.length === 1,
|
||||
'process-runner-kill-reconciliation-agent-db-count-invalid',
|
||||
);
|
||||
assert(
|
||||
reconnectRecords.length === 0,
|
||||
'process-runner-kill-reconnect-record-detected',
|
||||
);
|
||||
assert(
|
||||
record.processId === reconciliationRecords[0].processId &&
|
||||
record.agentId === mainAgentId &&
|
||||
record.taskId === reconciliationTasks[0].taskId &&
|
||||
record.runId === state.initialRunId &&
|
||||
record.conversationSessionId === state.initialSessionId &&
|
||||
record.ownerBootId === state.process.oldRunnerBootId &&
|
||||
record.ownerBootId === state.process.processOwnerBootId &&
|
||||
record.status === 'needs-reconciliation' &&
|
||||
record.needsReconciliation === true &&
|
||||
state.process.newRunnerBootId !== state.process.oldRunnerBootId,
|
||||
'process-runner-kill-reconciliation-record-invalid',
|
||||
);
|
||||
assert(
|
||||
reconciliationTasks[0].sessionId === state.initialSessionId &&
|
||||
reconciliationTasks[0].status === 'failed',
|
||||
'process-runner-kill-reconciliation-task-invalid',
|
||||
);
|
||||
assert(
|
||||
reconciliationEvents[0].agentId === mainAgentId &&
|
||||
reconciliationEvents[0].taskId === record.taskId &&
|
||||
reconciliationEvents[0].runId === state.initialRunId &&
|
||||
reconciliationEvents[0].sessionId === state.initialSessionId &&
|
||||
reconciliationEvents[0].status === 'failed' &&
|
||||
reconciliationEvents[0].phase === 'needs-reconciliation',
|
||||
'process-runner-kill-reconciliation-event-invalid',
|
||||
);
|
||||
assert(
|
||||
reconciliationAudits[0].agentId === mainAgentId &&
|
||||
reconciliationAudits[0].taskId === record.taskId &&
|
||||
reconciliationAudits[0].runId === state.initialRunId &&
|
||||
reconciliationAudits[0].sessionId === state.initialSessionId &&
|
||||
reconciliationAudits[0].processId === record.processId &&
|
||||
reconciliationAudits[0].ownerBootId === state.process.oldRunnerBootId &&
|
||||
reconciliationAudits[0].status === 'needs-reconciliation' &&
|
||||
reconciliationAudits[0].needsReconciliation === true,
|
||||
'process-runner-kill-reconciliation-agent-db-invalid',
|
||||
);
|
||||
assert(
|
||||
persistence.runtimeState.agentId === mainAgentId &&
|
||||
persistence.runtimeState.taskId === record.taskId &&
|
||||
persistence.runtimeState.runId === state.initialRunId &&
|
||||
persistence.runtimeState.sessionId === state.initialSessionId &&
|
||||
persistence.runtimeState.status === 'failed' &&
|
||||
persistence.runtimeState.phase === 'needs-reconciliation',
|
||||
'process-runner-kill-runtime-state-invalid',
|
||||
);
|
||||
const transcript = await readProcessSessionTranscript(record);
|
||||
registerProcessPrivateOutput(transcript.output, false);
|
||||
validateProcessSessionRecord(record, transcript, false);
|
||||
@@ -1432,7 +1531,8 @@ async function validateProcessRunnerKillEvidence() {
|
||||
startAudits[0].actionId === record.startActionId &&
|
||||
startAudits[0].actionFingerprint === record.startActionFingerprint &&
|
||||
startAudits[0].status === 'running' &&
|
||||
hasExpectedWorkspaceSandboxMetadata(startAudits[0]),
|
||||
hasExpectedWorkspaceSandboxMetadata(startAudits[0]) &&
|
||||
hasExpectedExecReadyMetadata(startAudits[0]),
|
||||
'process-runner-kill-start-audit-invalid',
|
||||
);
|
||||
const confirmedActionLifecycleCount = validateConfirmedActionLifecycles(
|
||||
@@ -1467,9 +1567,12 @@ async function validateProcessRunnerKillEvidence() {
|
||||
processTerminateActionCount: toolActions.terminate.size,
|
||||
processLaunchCount: launchEvidence.launchCount,
|
||||
processReadinessMarkerCount: launchEvidence.readinessMarkerCount,
|
||||
processReconciliationCount: 1,
|
||||
processReconciliationCount: reconciliationRecords.length,
|
||||
processReconciliationTaskCount: reconciliationTasks.length,
|
||||
processReconciliationEventCount: reconciliationEvents.length,
|
||||
processReconciliationAgentDbCount: reconciliationAudits.length,
|
||||
processOldBootReconciled: true,
|
||||
processReconnectCount: 0,
|
||||
processReconnectCount: reconnectRecords.length,
|
||||
processProjectCwdCleanupConfirmed:
|
||||
state.process.projectCwdProcessCleanupConfirmed,
|
||||
completedProjectionCount: noFinal.completedProjectionCount,
|
||||
@@ -1482,6 +1585,9 @@ async function validateProcessRunnerKillEvidence() {
|
||||
processAgentDbLeakCount: publicLeaks.agentDb,
|
||||
processReceiptLeakCount: publicLeaks.receipt,
|
||||
processConversationLeakCount: publicLeaks.conversation,
|
||||
processActivityLeakCount: publicLeaks.activity,
|
||||
processOutputLeakCount: publicLeaks.output,
|
||||
processRuntimeStateLeakCount: publicLeaks.runtimeState,
|
||||
processReportLeakCount: state.process.reportLeakCount,
|
||||
secretLeakCount: state.transcriptLeakCount + state.projectLeakCount,
|
||||
lureLeakCount: state.lureLeakCount,
|
||||
@@ -1491,6 +1597,9 @@ async function validateProcessRunnerKillEvidence() {
|
||||
'.agent/agent.db',
|
||||
'.agent/runtime/process-sessions',
|
||||
'.agent/conversations',
|
||||
'.agent/activity.jsonl',
|
||||
'.agent/output.jsonl',
|
||||
`.agent/runtime/agents/${mainAgentId}.json`,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1516,10 +1625,34 @@ async function readProcessPersistenceEvidence() {
|
||||
)) {
|
||||
conversations.push(...(await readJsonl(file)));
|
||||
}
|
||||
const activities = await readOptionalJsonl(
|
||||
path.join(state.projectRoot, '.agent/activity.jsonl'),
|
||||
);
|
||||
const outputs = await readOptionalJsonl(
|
||||
path.join(state.projectRoot, '.agent/output.jsonl'),
|
||||
);
|
||||
const runtimeState = await readJson(
|
||||
path.join(state.projectRoot, `.agent/runtime/agents/${mainAgentId}.json`),
|
||||
);
|
||||
assert(
|
||||
runtimeState &&
|
||||
typeof runtimeState === 'object' &&
|
||||
!Array.isArray(runtimeState),
|
||||
'process-runtime-state-evidence-invalid',
|
||||
);
|
||||
assert(taskSnapshot.all.length > 0, 'process-task-evidence-missing');
|
||||
assert(events.length > 0, 'process-event-evidence-missing');
|
||||
assert(agentDb.length > 0, 'process-agent-db-evidence-missing');
|
||||
return { taskSnapshot, events, agentDb, conversations, conversationFiles };
|
||||
return {
|
||||
taskSnapshot,
|
||||
events,
|
||||
agentDb,
|
||||
conversations,
|
||||
conversationFiles,
|
||||
activities,
|
||||
outputs,
|
||||
runtimeState,
|
||||
};
|
||||
}
|
||||
|
||||
function validateProcessToolEvidence(records, processRecord) {
|
||||
@@ -1549,30 +1682,63 @@ function validateProcessToolEvidence(records, processRecord) {
|
||||
startAudits[0].processId === processRecord.processId &&
|
||||
startAudits[0].status === 'running' &&
|
||||
hasExpectedWorkspaceSandboxMetadata(startAudits[0]) &&
|
||||
hasExpectedExecReadyMetadata(startAudits[0]) &&
|
||||
[...pollAudits, ...stdinAudits, ...terminateAudits].every(
|
||||
(audit) =>
|
||||
audit.processId === processRecord.processId &&
|
||||
hasExpectedWorkspaceSandboxMetadata(audit),
|
||||
),
|
||||
) &&
|
||||
[...pollAudits, ...terminateAudits].every(hasExpectedExecReadyMetadata),
|
||||
'process-tool-identity-invalid',
|
||||
);
|
||||
assert(
|
||||
isTerminalProcessStatus(terminateAudits[0].status) &&
|
||||
terminateAudits[0].needsReconciliation === false,
|
||||
terminateAudits[0].status === 'terminated' &&
|
||||
terminateAudits[0].needsReconciliation === false &&
|
||||
terminateAudits[0].cursor === terminateAudits[0].nextCursor,
|
||||
'process-terminate-audit-not-terminal',
|
||||
);
|
||||
|
||||
assert(
|
||||
startAudits[0].cursor === startAudits[0].nextCursor &&
|
||||
processCursorOffset(startAudits[0].cursor, processRecord.processId) === 0,
|
||||
'process-start-cursor-not-zero-consumption',
|
||||
);
|
||||
let expectedCursor = startAudits[0].nextCursor;
|
||||
let cursorAdvanceCount = 0;
|
||||
for (const audit of pollAudits) {
|
||||
const terminateAuditIndex = records.indexOf(terminateAudits[0]);
|
||||
const validatePollCursor = (audit) => {
|
||||
const cursorOffset = processCursorOffset(
|
||||
audit.cursor,
|
||||
processRecord.processId,
|
||||
);
|
||||
const nextCursorOffset = processCursorOffset(
|
||||
audit.nextCursor,
|
||||
processRecord.processId,
|
||||
);
|
||||
assert(
|
||||
isNonEmptyString(audit.cursor) &&
|
||||
isNonEmptyString(audit.nextCursor) &&
|
||||
audit.cursor === expectedCursor,
|
||||
audit.cursor === expectedCursor &&
|
||||
nextCursorOffset >= cursorOffset,
|
||||
'process-poll-cursor-chain-invalid',
|
||||
);
|
||||
if (audit.nextCursor !== audit.cursor) cursorAdvanceCount += 1;
|
||||
expectedCursor = audit.nextCursor;
|
||||
};
|
||||
for (const audit of pollAudits.filter(
|
||||
(candidate) => records.indexOf(candidate) < terminateAuditIndex,
|
||||
)) {
|
||||
validatePollCursor(audit);
|
||||
}
|
||||
assert(
|
||||
terminateAudits[0].cursor === expectedCursor,
|
||||
'process-terminate-cursor-chain-invalid',
|
||||
);
|
||||
expectedCursor = terminateAudits[0].nextCursor;
|
||||
for (const audit of pollAudits.filter(
|
||||
(candidate) => records.indexOf(candidate) > terminateAuditIndex,
|
||||
)) {
|
||||
validatePollCursor(audit);
|
||||
}
|
||||
assert(cursorAdvanceCount >= 2, 'process-poll-cursor-not-incremental');
|
||||
|
||||
@@ -1858,6 +2024,9 @@ function validateProcessPublicLeakBoundary(persistence) {
|
||||
agentDb: persistence.agentDb,
|
||||
receipt: receipts,
|
||||
conversation: persistence.conversations,
|
||||
activity: persistence.activities,
|
||||
output: persistence.outputs,
|
||||
runtimeState: [persistence.runtimeState],
|
||||
};
|
||||
const counts = {};
|
||||
for (const [surface, records] of Object.entries(surfaces)) {
|
||||
@@ -1976,6 +2145,19 @@ function processOutputLines(output) {
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function processCursorOffset(cursor, processId) {
|
||||
const prefix = `v1:${processId}:`;
|
||||
assert(
|
||||
typeof cursor === 'string' && cursor.startsWith(prefix),
|
||||
'process-cursor-identity-invalid',
|
||||
);
|
||||
const rawOffset = cursor.slice(prefix.length);
|
||||
assert(/^\d+$/u.test(rawOffset), 'process-cursor-offset-invalid');
|
||||
const offset = Number(rawOffset);
|
||||
assert(Number.isSafeInteger(offset), 'process-cursor-offset-invalid');
|
||||
return offset;
|
||||
}
|
||||
|
||||
async function readProcessSessionRecords() {
|
||||
const directory = path.join(
|
||||
state.projectRoot,
|
||||
@@ -2016,7 +2198,7 @@ async function captureProcessTranscriptReadiness(record) {
|
||||
|
||||
function validateProcessSessionRecord(record, transcript, terminalExpected) {
|
||||
assert(
|
||||
record.schemaVersion === '2' &&
|
||||
record.schemaVersion === '3' &&
|
||||
transcript.schemaVersion === '2' &&
|
||||
record.agentId === mainAgentId &&
|
||||
record.runId === state.initialRunId &&
|
||||
@@ -2039,12 +2221,20 @@ function validateProcessSessionRecord(record, transcript, terminalExpected) {
|
||||
transcript.outputSha256 === hashValue(transcript.output) &&
|
||||
record.outputBytes === transcript.outputBytes &&
|
||||
record.outputSha256 === transcript.outputSha256 &&
|
||||
hasExpectedWorkspaceSandboxMetadata(record),
|
||||
Number.isSafeInteger(record.startedAt) &&
|
||||
Number.isSafeInteger(record.sandboxReadyAt) &&
|
||||
Number.isSafeInteger(record.execEstablishedAt) &&
|
||||
record.startedAt <= record.sandboxReadyAt &&
|
||||
record.sandboxReadyAt <= record.execEstablishedAt &&
|
||||
record.execEstablishedAt <= record.terminalAt &&
|
||||
record.terminalAt <= record.updatedAt &&
|
||||
hasExpectedWorkspaceSandboxMetadata(record) &&
|
||||
hasExpectedExecReadyMetadata(record),
|
||||
'process-session-record-identity-invalid',
|
||||
);
|
||||
if (terminalExpected) {
|
||||
assert(
|
||||
isTerminalProcessStatus(record.status) &&
|
||||
record.status === 'terminated' &&
|
||||
Number.isSafeInteger(record.terminalAt) &&
|
||||
record.sourceChanged === false,
|
||||
'process-session-terminal-record-invalid',
|
||||
@@ -2122,6 +2312,15 @@ function hasExpectedWorkspaceSandboxMetadata(record) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasExpectedExecReadyMetadata(record) {
|
||||
if (process.platform !== 'linux') return true;
|
||||
return (
|
||||
record?.sandboxEstablishment === 'established' &&
|
||||
record?.targetExec === 'established' &&
|
||||
record?.launchFailureKind == null
|
||||
);
|
||||
}
|
||||
|
||||
function processLaunchEvidence(records, processRecord, transcript) {
|
||||
const actionIds = processToolActionIds(records);
|
||||
const startAudits = processDedicatedAudits(records, 'command.start');
|
||||
@@ -2141,7 +2340,8 @@ function processLaunchEvidence(records, processRecord, transcript) {
|
||||
startAudit.actionId === processRecord.startActionId &&
|
||||
startAudit.actionFingerprint === processRecord.startActionFingerprint &&
|
||||
startAudit.status === 'running' &&
|
||||
hasExpectedWorkspaceSandboxMetadata(startAudit),
|
||||
hasExpectedWorkspaceSandboxMetadata(startAudit) &&
|
||||
hasExpectedExecReadyMetadata(startAudit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3772,6 +3972,9 @@ function emptyProcessEvidence() {
|
||||
processLaunchCount: 0,
|
||||
processTerminalCount: 0,
|
||||
processReconciliationCount: 0,
|
||||
processReconciliationTaskCount: 0,
|
||||
processReconciliationEventCount: 0,
|
||||
processReconciliationAgentDbCount: 0,
|
||||
processPollCursorAdvanceCount: 0,
|
||||
processReadinessMarkerCount: 0,
|
||||
processReconnectCount: 0,
|
||||
@@ -3784,6 +3987,9 @@ function emptyProcessEvidence() {
|
||||
processAgentDbLeakCount: 0,
|
||||
processReceiptLeakCount: 0,
|
||||
processConversationLeakCount: 0,
|
||||
processActivityLeakCount: 0,
|
||||
processOutputLeakCount: 0,
|
||||
processRuntimeStateLeakCount: 0,
|
||||
processReportLeakCount: 0,
|
||||
secretLeakCount: 0,
|
||||
lureLeakCount: 0,
|
||||
@@ -3833,8 +4039,13 @@ function parseAssignedJson(output, names) {
|
||||
|
||||
async function listFiles(root) {
|
||||
const files = [];
|
||||
const metadata = await fs.lstat(root).catch(() => null);
|
||||
if (!metadata) return files;
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await fs.lstat(root);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return files;
|
||||
throw error;
|
||||
}
|
||||
if (metadata.isSymbolicLink()) return files;
|
||||
if (metadata.isFile()) return [root];
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
@@ -3886,6 +4097,15 @@ async function readJsonl(file) {
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
async function readOptionalJsonl(file) {
|
||||
try {
|
||||
return await readJsonl(file);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProjectRelative(value) {
|
||||
const candidate = path.isAbsolute(value)
|
||||
? path.resolve(value)
|
||||
|
||||
@@ -516,12 +516,30 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at(
|
||||
resume_external_agent_runner(root)?;
|
||||
return read_game_creator_agent_runtimes_at(root);
|
||||
}
|
||||
let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?;
|
||||
let reconciliation_records = active_process_session_records_at(root, None, None)?
|
||||
.into_iter()
|
||||
.filter(|record| record.needs_reconciliation || record.status == "needs-reconciliation")
|
||||
.collect::<Vec<_>>();
|
||||
if reconciliation_records
|
||||
.iter()
|
||||
.any(|record| !agent_ids.contains(&record.agent_id))
|
||||
{
|
||||
return Err("旧 Runner 的进程会话指向未知 Agent,无法安全恢复所属任务".to_string());
|
||||
}
|
||||
let mut resumed = Vec::new();
|
||||
for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? {
|
||||
for agent_id in agent_ids {
|
||||
let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(result) =
|
||||
reconcile_game_creator_agent_process_sessions_after_restart_at(root, &agent_id)?
|
||||
{
|
||||
resumed.push(result);
|
||||
drop(runtime_lock);
|
||||
continue;
|
||||
}
|
||||
let runtime_lock =
|
||||
match resume_game_creator_agent_finalization_at(root, &agent_id, runtime_lock)? {
|
||||
AgentRuntimeFinalizationResume::Recovered(result, runtime_lock) => {
|
||||
@@ -633,6 +651,97 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at(
|
||||
Ok(resumed)
|
||||
}
|
||||
|
||||
fn reconcile_game_creator_agent_process_sessions_after_restart_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<Option<AgentRuntimeResult>, String> {
|
||||
let records = active_process_session_records_at(root, Some(agent_id), None)?
|
||||
.into_iter()
|
||||
.filter(|record| record.needs_reconciliation || record.status == "needs-reconciliation")
|
||||
.collect::<Vec<_>>();
|
||||
let Some(record) = records.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if records
|
||||
.iter()
|
||||
.any(|candidate| candidate.agent_id != agent_id)
|
||||
{
|
||||
return Err("旧 Runner 的进程会话与当前 Agent 身份不一致,无法安全恢复".to_string());
|
||||
}
|
||||
if records
|
||||
.iter()
|
||||
.any(|candidate| candidate.run_id != record.run_id)
|
||||
{
|
||||
return Err("旧 Runner 的进程会话涉及多个 owning run,无法安全恢复".to_string());
|
||||
}
|
||||
let Some(task) =
|
||||
read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &record.run_id)?
|
||||
else {
|
||||
return Err("旧 Runner 的进程会话缺少所属 Agent task,无法安全恢复".to_string());
|
||||
};
|
||||
if records.iter().any(|candidate| {
|
||||
task.agent_id != candidate.agent_id
|
||||
|| task.run_id != candidate.run_id
|
||||
|| task.task_id != candidate.task_id
|
||||
|| task.session_id != candidate.conversation_session_id
|
||||
}) {
|
||||
return Err("旧 Runner 的进程会话与所属 Agent task 身份不一致,无法安全恢复".to_string());
|
||||
}
|
||||
|
||||
let mut state = agent_runtime_state_from_task_record(&task);
|
||||
state.status = "failed".to_string();
|
||||
state.phase = "needs-reconciliation".to_string();
|
||||
state.current_action = "Runner 重启后进程会话需要人工核对".to_string();
|
||||
state.waiting_on = "开发者核对旧进程会话".to_string();
|
||||
state.next_step = "核对进程树与项目状态后取消原任务,再决定是否重新投递".to_string();
|
||||
state.pending_tool_action = None;
|
||||
state.error =
|
||||
Some("旧 Runner 的活跃进程会话已停止自动恢复,禁止按 PID 重连或重放启动动作".to_string());
|
||||
state.updated_at = unix_timestamp();
|
||||
if task.phase != "needs-reconciliation" {
|
||||
append_game_creator_agent_runtime_task(root, &state)?;
|
||||
}
|
||||
refresh_game_creator_agent_runtime_task_queue(root, &mut state)?;
|
||||
write_game_creator_agent_runtime_state(root, &state)?;
|
||||
append_game_creator_agent_runtime_action_event(
|
||||
root,
|
||||
&state,
|
||||
"process_session.reconciled_after_runner_restart",
|
||||
"failed",
|
||||
"needs-reconciliation",
|
||||
"Runner 重启后发现旧 boot 的活跃进程会话,已停止自动恢复。",
|
||||
state.error.as_deref(),
|
||||
&record.start_action_id,
|
||||
)?;
|
||||
append_agent_db_process_reconciliation_if_missing_for_action(
|
||||
root,
|
||||
&state.agent_id,
|
||||
&state.run_id,
|
||||
&record.start_action_id,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.process_session.reconciled_after_runner_restart",
|
||||
"agentId": state.agent_id,
|
||||
"taskId": state.task_id,
|
||||
"sessionId": state.session_id,
|
||||
"runId": state.run_id,
|
||||
"actionId": record.start_action_id,
|
||||
"actionFingerprint": record.start_action_fingerprint,
|
||||
"tool": "runtime.process_session",
|
||||
"executionMode": "auto",
|
||||
"status": "needs-reconciliation",
|
||||
"summary": "Runner 重启后发现旧 boot 的活跃进程会话,已停止自动恢复。",
|
||||
"safeDetail": null,
|
||||
"detailUnavailable": true,
|
||||
"processId": record.process_id,
|
||||
"ownerBootId": record.owner_boot_id,
|
||||
"processStatus": record.status,
|
||||
"needsReconciliation": true,
|
||||
}),
|
||||
)?;
|
||||
emit_game_creator_agent_runtime_update(root, agent_id);
|
||||
read_game_creator_agent_runtime_at(root, agent_id).map(Some)
|
||||
}
|
||||
|
||||
pub(crate) fn wake_pending_game_creator_agent_background_tasks_at(
|
||||
root: &Path,
|
||||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||||
@@ -16698,6 +16807,32 @@ fn prepare_game_creator_agent_runtime_completed_state(
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub(crate) fn redact_agent_runtime_private_process_output_from_response(
|
||||
response: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
) -> String {
|
||||
let has_private_process_output = observations.iter().any(|observation| {
|
||||
observation.tool == "command.poll"
|
||||
&& observation.status == "ok"
|
||||
&& observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.and_then(|detail| serde_json::from_str::<serde_json::Value>(detail).ok())
|
||||
.and_then(|detail| {
|
||||
detail
|
||||
.get("output")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(|output| !output.is_empty())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if has_private_process_output {
|
||||
"持久进程交互已完成,私有进程输出已省略。".to_string()
|
||||
} else {
|
||||
response.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish_game_creator_agent_runtime_turn_at(
|
||||
root: &Path,
|
||||
state: AgentRuntimeState,
|
||||
@@ -17230,10 +17365,12 @@ where
|
||||
return Ok(AgentBackgroundFinalizationOutcome::Stale(blocker));
|
||||
}
|
||||
|
||||
let response =
|
||||
redact_agent_runtime_private_process_output_from_response(response, observations);
|
||||
let mut journal = build_game_creator_agent_runtime_finalization_journal(
|
||||
root,
|
||||
&state,
|
||||
response,
|
||||
&response,
|
||||
response_revision,
|
||||
)?;
|
||||
write_game_creator_agent_runtime_finalization_journal(root, &journal)?;
|
||||
@@ -18397,7 +18534,12 @@ fn append_game_creator_agent_runtime_event_with_action(
|
||||
&& candidate.phase == event.phase
|
||||
})
|
||||
{
|
||||
if existing.status != event.status
|
||||
if existing.agent_id != event.agent_id
|
||||
|| existing.task_id != event.task_id
|
||||
|| existing.session_id != event.session_id
|
||||
|| existing.run_id != event.run_id
|
||||
|| existing.source != event.source
|
||||
|| existing.status != event.status
|
||||
|| existing.phase != event.phase
|
||||
|| existing.summary != event.summary
|
||||
|| existing.detail != event.detail
|
||||
|
||||
@@ -942,6 +942,67 @@ pub(crate) fn append_agent_db_record_if_missing_for_action(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn append_agent_db_process_reconciliation_if_missing_for_action(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
action_id: &str,
|
||||
record: serde_json::Value,
|
||||
) -> Result<bool, String> {
|
||||
const RECORD_TYPE: &str = "agent.runtime.process_session.reconciled_after_runner_restart";
|
||||
if record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE)
|
||||
|| record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id)
|
||||
|| record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id)
|
||||
|| record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id)
|
||||
{
|
||||
return Err("Agent DB 进程恢复记录身份不匹配".to_string());
|
||||
}
|
||||
for field in [
|
||||
"taskId",
|
||||
"sessionId",
|
||||
"actionFingerprint",
|
||||
"tool",
|
||||
"executionMode",
|
||||
"status",
|
||||
"summary",
|
||||
"processId",
|
||||
"ownerBootId",
|
||||
] {
|
||||
if record
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(format!("Agent DB 进程恢复记录缺少合法字段:{field}"));
|
||||
}
|
||||
}
|
||||
if !action_id.strip_prefix("action-").is_some_and(|suffix| {
|
||||
suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}) {
|
||||
return Err("Agent DB 进程恢复记录的 actionId 无效".to_string());
|
||||
}
|
||||
let fingerprint = record["actionFingerprint"].as_str().unwrap_or_default();
|
||||
if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("Agent DB 进程恢复记录的 actionFingerprint 无效".to_string());
|
||||
}
|
||||
if record["tool"] != "runtime.process_session"
|
||||
|| record["executionMode"] != "auto"
|
||||
|| record["status"] != "needs-reconciliation"
|
||||
|| record["needsReconciliation"] != true
|
||||
{
|
||||
return Err("Agent DB 进程恢复记录状态无效".to_string());
|
||||
}
|
||||
append_agent_db_record_if_missing_for_action_internal(
|
||||
root,
|
||||
RECORD_TYPE,
|
||||
agent_id,
|
||||
run_id,
|
||||
action_id,
|
||||
record,
|
||||
|| {},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock<F>(
|
||||
root: &Path,
|
||||
record_type: &str,
|
||||
@@ -1359,6 +1420,10 @@ fn validate_agent_db_action_record_identity(
|
||||
"safeDetail",
|
||||
"detailUnavailable",
|
||||
"decision",
|
||||
"processId",
|
||||
"ownerBootId",
|
||||
"processStatus",
|
||||
"needsReconciliation",
|
||||
] {
|
||||
if existing.get(field) != expected.get(field) {
|
||||
return Err(format!(
|
||||
@@ -1530,11 +1595,16 @@ fn append_jsonl_line_unlocked(path: &Path, line: &str, error_label: &str) -> Res
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?;
|
||||
repair_truncated_jsonl_tail_unlocked(&mut file, path, error_label)?;
|
||||
let framed = format!("{line}\n");
|
||||
file.write_all(framed.as_bytes())
|
||||
.and_then(|_| file.flush())
|
||||
.and_then(|_| file.sync_data())
|
||||
.map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,17 @@ const EXTERNAL_AGENT_RUNNER_IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25);
|
||||
#[cfg(target_os = "linux")]
|
||||
const EXTERNAL_AGENT_RUNNER_LINUX_EPHEMERAL_PORT_RANGE_PATH: &str =
|
||||
"/proc/sys/net/ipv4/ip_local_port_range";
|
||||
#[cfg(target_os = "linux")]
|
||||
const EXTERNAL_AGENT_RUNNER_LINUX_RESERVED_PORTS_PATH: &str =
|
||||
"/proc/sys/net/ipv4/ip_local_reserved_ports";
|
||||
#[cfg(target_os = "linux")]
|
||||
const EXTERNAL_AGENT_RUNNER_LINUX_UNPRIVILEGED_PORT_START_PATH: &str =
|
||||
"/proc/sys/net/ipv4/ip_unprivileged_port_start";
|
||||
#[cfg(target_os = "linux")]
|
||||
const EXTERNAL_AGENT_RUNNER_FALLBACK_PORT_START: u16 = 61_000;
|
||||
|
||||
static EXTERNAL_AGENT_RUNNER_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
|
||||
static EXTERNAL_AGENT_RUNNER_CONFIGURE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -2471,6 +2482,134 @@ fn refresh_external_agent_runner_heartbeat(
|
||||
write_external_agent_runner_endpoint_atomic(&state.endpoint_path, &endpoint)
|
||||
}
|
||||
|
||||
fn bind_external_agent_runner_listener_with<T>(
|
||||
mut fallback_ports: impl FnMut() -> Vec<u16>,
|
||||
mut bind: impl FnMut(u16) -> io::Result<T>,
|
||||
) -> io::Result<T> {
|
||||
let primary_error = match bind(0) {
|
||||
Ok(listener) => return Ok(listener),
|
||||
Err(error) => error,
|
||||
};
|
||||
if primary_error.kind() != io::ErrorKind::AddrInUse {
|
||||
return Err(primary_error);
|
||||
}
|
||||
for port in fallback_ports() {
|
||||
match bind(port) {
|
||||
Ok(listener) => return Ok(listener),
|
||||
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(primary_error)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_external_agent_runner_linux_ephemeral_port_range(content: &str) -> Option<(u16, u16)> {
|
||||
let mut values = content.split_whitespace();
|
||||
let start = values.next()?.parse::<u16>().ok()?;
|
||||
let end = values.next()?.parse::<u16>().ok()?;
|
||||
if values.next().is_some() || start > end {
|
||||
return None;
|
||||
}
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_external_agent_runner_linux_single_port(content: &str) -> Option<u16> {
|
||||
let mut values = content.split_whitespace();
|
||||
let value = values.next()?.parse::<u16>().ok()?;
|
||||
values.next().is_none().then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_external_agent_runner_linux_reserved_ports(content: &str) -> Option<Vec<(u16, u16)>> {
|
||||
let content = content.trim();
|
||||
if content.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
for part in content.split(',') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut bounds = part.split('-');
|
||||
let start = bounds.next()?.parse::<u16>().ok()?;
|
||||
let end = match bounds.next() {
|
||||
Some(value) => value.parse::<u16>().ok()?,
|
||||
None => start,
|
||||
};
|
||||
if bounds.next().is_some() || start > end {
|
||||
return None;
|
||||
}
|
||||
ranges.push((start, end));
|
||||
}
|
||||
Some(ranges)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn external_agent_runner_linux_fallback_ports(
|
||||
boot_id: &str,
|
||||
(ephemeral_start, ephemeral_end): (u16, u16),
|
||||
unprivileged_port_start: u16,
|
||||
reserved_ports: &[(u16, u16)],
|
||||
) -> Vec<u16> {
|
||||
let start = EXTERNAL_AGENT_RUNNER_FALLBACK_PORT_START.max(unprivileged_port_start);
|
||||
let mut ports = (start..=u16::MAX)
|
||||
.filter(|port| {
|
||||
!(ephemeral_start..=ephemeral_end).contains(port)
|
||||
&& !reserved_ports.iter().any(|(reserved_start, reserved_end)| {
|
||||
(*reserved_start..=*reserved_end).contains(port)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !ports.is_empty() {
|
||||
let digest = Sha256::digest(boot_id.as_bytes());
|
||||
let seed = u64::from_be_bytes([
|
||||
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6], digest[7],
|
||||
]);
|
||||
let offset = (seed % ports.len() as u64) as usize;
|
||||
ports.rotate_left(offset);
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_external_agent_runner_linux_fallback_ports(boot_id: &str) -> Option<Vec<u16>> {
|
||||
let ephemeral_range = fs::read_to_string(EXTERNAL_AGENT_RUNNER_LINUX_EPHEMERAL_PORT_RANGE_PATH)
|
||||
.ok()
|
||||
.and_then(|content| parse_external_agent_runner_linux_ephemeral_port_range(&content))?;
|
||||
let unprivileged_port_start =
|
||||
fs::read_to_string(EXTERNAL_AGENT_RUNNER_LINUX_UNPRIVILEGED_PORT_START_PATH)
|
||||
.ok()
|
||||
.and_then(|content| parse_external_agent_runner_linux_single_port(&content))?;
|
||||
let reserved_ports = fs::read_to_string(EXTERNAL_AGENT_RUNNER_LINUX_RESERVED_PORTS_PATH)
|
||||
.ok()
|
||||
.and_then(|content| parse_external_agent_runner_linux_reserved_ports(&content))?;
|
||||
Some(external_agent_runner_linux_fallback_ports(
|
||||
boot_id,
|
||||
ephemeral_range,
|
||||
unprivileged_port_start,
|
||||
&reserved_ports,
|
||||
))
|
||||
}
|
||||
|
||||
fn bind_external_agent_runner_listener(boot_id: &str) -> io::Result<TcpListener> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return bind_external_agent_runner_listener_with(
|
||||
|| read_external_agent_runner_linux_fallback_ports(boot_id).unwrap_or_default(),
|
||||
|port| TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = boot_id;
|
||||
TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) -> Result<(), String> {
|
||||
let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?;
|
||||
EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release);
|
||||
@@ -2484,7 +2623,7 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) ->
|
||||
&external_agent_runner_lock_path(&config_dir),
|
||||
&boot_id,
|
||||
)?;
|
||||
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||||
let listener = bind_external_agent_runner_listener(&boot_id)
|
||||
.map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
@@ -3489,6 +3628,123 @@ mod tests {
|
||||
assert!(!config_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_listener_retries_fallback_ports_only_after_address_in_use() {
|
||||
let mut attempts = Vec::new();
|
||||
let bound_port = bind_external_agent_runner_listener_with(
|
||||
|| vec![61_000, 61_001],
|
||||
|port| {
|
||||
attempts.push(port);
|
||||
if matches!(port, 0 | 61_000) {
|
||||
Err(io::Error::new(io::ErrorKind::AddrInUse, "occupied"))
|
||||
} else {
|
||||
Ok(port)
|
||||
}
|
||||
},
|
||||
)
|
||||
.expect("fallback listener");
|
||||
|
||||
assert_eq!(bound_port, 61_001);
|
||||
assert_eq!(attempts, vec![0, 61_000, 61_001]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_listener_preserves_non_address_in_use_failure() {
|
||||
let mut attempts = Vec::new();
|
||||
let error = bind_external_agent_runner_listener_with(
|
||||
|| vec![61_000],
|
||||
|port| {
|
||||
attempts.push(port);
|
||||
Err::<(), _>(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
|
||||
},
|
||||
)
|
||||
.expect_err("permission failure must not use fallback ports");
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(attempts, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_listener_does_not_load_fallback_ports_when_port_zero_succeeds() {
|
||||
let mut fallback_loaded = false;
|
||||
let bound_port = bind_external_agent_runner_listener_with(
|
||||
|| {
|
||||
fallback_loaded = true;
|
||||
vec![61_000]
|
||||
},
|
||||
Ok,
|
||||
)
|
||||
.expect("port zero listener");
|
||||
|
||||
assert_eq!(bound_port, 0);
|
||||
assert!(!fallback_loaded);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_runner_fallback_ports_stay_outside_ephemeral_range() {
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_ephemeral_port_range("32768\t60999\n"),
|
||||
Some((32_768, 60_999))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_ephemeral_port_range("60999 32768"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_ephemeral_port_range("32768 60999 extra"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_single_port("32768\n"),
|
||||
Some(32_768)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_single_port("32768 extra"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_reserved_ports("61001-61003, 65535\n"),
|
||||
Some(vec![(61_001, 61_003), (65_535, 65_535)])
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_agent_runner_linux_reserved_ports("61003-61001"),
|
||||
None
|
||||
);
|
||||
|
||||
let ports = external_agent_runner_linux_fallback_ports(
|
||||
"runner-listener-fallback-test-boot",
|
||||
(32_768, 60_999),
|
||||
32_768,
|
||||
&[(61_001, 61_003), (65_535, 65_535)],
|
||||
);
|
||||
assert_eq!(ports.len(), 4_532);
|
||||
assert!(ports.iter().all(|port| {
|
||||
*port >= EXTERNAL_AGENT_RUNNER_FALLBACK_PORT_START
|
||||
&& !(32_768..=60_999).contains(port)
|
||||
&& !(61_001..=61_003).contains(port)
|
||||
&& *port != 65_535
|
||||
}));
|
||||
assert_eq!(
|
||||
ports.iter().copied().collect::<BTreeSet<_>>().len(),
|
||||
ports.len()
|
||||
);
|
||||
let hardened_ports = external_agent_runner_linux_fallback_ports(
|
||||
"runner-listener-hardened-boot",
|
||||
(32_768, 60_999),
|
||||
62_000,
|
||||
&[],
|
||||
);
|
||||
assert!(hardened_ports.iter().all(|port| *port >= 62_000));
|
||||
assert!(external_agent_runner_linux_fallback_ports(
|
||||
"runner-listener-exhausted-boot",
|
||||
(32_768, 60_999),
|
||||
65_535,
|
||||
&[(65_535, 65_535)],
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_only_runner_configuration_does_not_chmod_appdata() {
|
||||
|
||||
@@ -254,6 +254,24 @@ fn read_agent_db_records_for_test(root: &Path) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn remove_jsonl_records_for_test(path: &Path, mut should_remove: impl FnMut(&Value) -> bool) {
|
||||
let original = fs::read_to_string(path).expect("read jsonl fixture");
|
||||
let retained = original
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter(|line| {
|
||||
let value = serde_json::from_str::<Value>(line).expect("parse jsonl fixture");
|
||||
!should_remove(&value)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = if retained.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}\n", retained.join("\n"))
|
||||
};
|
||||
fs::write(path, content).expect("rewrite jsonl fixture");
|
||||
}
|
||||
|
||||
async fn execute_agent_runtime_file_delete_for_test(
|
||||
root: &Path,
|
||||
run_id: &str,
|
||||
@@ -30068,6 +30086,62 @@ fn process_session_command_stdin_input_summary_contains_only_safe_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_session_final_response_redacts_private_poll_output() {
|
||||
let challenge = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let ready = format!("GENARRATIVE_PROCESS_READY challenge={challenge}");
|
||||
let echo = format!("GENARRATIVE_PROCESS_ECHO {challenge}");
|
||||
let stopped = "GENARRATIVE_PROCESS_STOPPED";
|
||||
let observation = AgentRuntimeToolObservation {
|
||||
tool: "command.poll".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: "读取私有进程输出".to_string(),
|
||||
detail: Some(
|
||||
serde_json::json!({
|
||||
"processId": "proc-0123456789abcdef0123456789abcdef",
|
||||
"output": format!("{ready}\n{echo}\n{stopped}\n")
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let response = format!("已完成:{challenge};{ready};{echo};{stopped}");
|
||||
let redacted = redact_agent_runtime_private_process_output_from_response(
|
||||
&response,
|
||||
std::slice::from_ref(&observation),
|
||||
);
|
||||
assert!(!redacted.contains(challenge));
|
||||
assert!(!redacted.contains(&ready));
|
||||
assert!(!redacted.contains(&echo));
|
||||
assert!(!redacted.contains(stopped));
|
||||
assert_eq!(redacted, "持久进程交互已完成,私有进程输出已省略。");
|
||||
let short_private_observation = AgentRuntimeToolObservation {
|
||||
tool: "command.poll".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: "读取短私有进程输出".to_string(),
|
||||
detail: Some(serde_json::json!({ "output": "PIN=1234\n" }).to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
redact_agent_runtime_private_process_output_from_response(
|
||||
"进程返回的 PIN 是 1234。",
|
||||
&[short_private_observation],
|
||||
),
|
||||
"持久进程交互已完成,私有进程输出已省略。"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_agent_runtime_private_process_output_from_response(
|
||||
"持久进程交互已完成。",
|
||||
&[AgentRuntimeToolObservation {
|
||||
tool: "command.poll".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: "空输出".to_string(),
|
||||
detail: Some(serde_json::json!({ "output": "" }).to_string()),
|
||||
}],
|
||||
),
|
||||
"持久进程交互已完成。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_session_start_only_detach_profile_does_not_pollute_command_exec_resolution() {
|
||||
let root = unique_project_path();
|
||||
@@ -30201,6 +30275,390 @@ async fn process_session_command_start_rejects_detach_before_launch_or_revision_
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_restart_resume_projects_stale_process_session_to_reconciliation() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "Runner 重启进程恢复项目")
|
||||
.expect("project init");
|
||||
let run_id = "code-process-runner-restart-reconciliation-run";
|
||||
let state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"恢复旧 Runner 的持久进程",
|
||||
run_id,
|
||||
"agent-background-task",
|
||||
"等待旧进程状态",
|
||||
vec!["核对进程会话".to_string()],
|
||||
)
|
||||
.expect("start runtime state");
|
||||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||||
|
||||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||||
let now = unix_timestamp();
|
||||
let sandbox = command_sandbox_platform_metadata();
|
||||
let record = ProcessSessionRecord {
|
||||
schema_version: "3".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
agent_id: "code-prototype".to_string(),
|
||||
task_id: state.task_id.clone(),
|
||||
conversation_session_id: state.session_id.clone(),
|
||||
run_id: run_id.to_string(),
|
||||
start_action_id: "action-0123456789abcdef01234567".to_string(),
|
||||
start_action_fingerprint: "a".repeat(64),
|
||||
process_id: process_id.to_string(),
|
||||
owner_boot_id: format!("stale-{}", process_session_boot_id()),
|
||||
command_id: "npm:process:fixture".to_string(),
|
||||
program: "npm".to_string(),
|
||||
cwd: ".".to_string(),
|
||||
sandbox_backend: sandbox.backend.to_string(),
|
||||
sandbox_mode: sandbox.mode.to_string(),
|
||||
network_access: sandbox.network.to_string(),
|
||||
sandbox_profile_version: sandbox.profile_version.to_string(),
|
||||
sandbox_establishment: "established".to_string(),
|
||||
target_exec: "established".to_string(),
|
||||
launch_failure_kind: None,
|
||||
sandbox_ready_at: Some(now),
|
||||
exec_established_at: Some(now),
|
||||
status: "running".to_string(),
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdin_open: true,
|
||||
output_bytes: 0,
|
||||
output_sha256: format!("{:x}", Sha256::digest([])),
|
||||
output_ref: Some(format!(
|
||||
".agent/runtime/process-sessions/{process_id}.output.json"
|
||||
)),
|
||||
source_fingerprint_before: "b".repeat(64),
|
||||
source_fingerprint_after: None,
|
||||
source_changed: None,
|
||||
needs_reconciliation: false,
|
||||
started_at: now,
|
||||
terminal_at: None,
|
||||
updated_at: now,
|
||||
};
|
||||
let record_directory = root.join(".agent/runtime/process-sessions");
|
||||
fs::create_dir_all(&record_directory).expect("create process record directory");
|
||||
fs::write(
|
||||
record_directory.join(format!("{process_id}.json")),
|
||||
serde_json::to_vec(&record).expect("serialize process record"),
|
||||
)
|
||||
.expect("write stale process record");
|
||||
|
||||
let resumed =
|
||||
resume_game_creator_agent_background_tasks_at(&root).expect("resume stale process session");
|
||||
assert!(resumed.iter().any(|runtime| {
|
||||
runtime.state.agent_id == "code-prototype"
|
||||
&& runtime.state.run_id == run_id
|
||||
&& runtime.state.session_id == state.session_id
|
||||
&& runtime.state.phase == "needs-reconciliation"
|
||||
}));
|
||||
let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||||
.expect("read reconciled runtime")
|
||||
.state;
|
||||
assert_eq!(runtime.status, "failed");
|
||||
assert_eq!(runtime.phase, "needs-reconciliation");
|
||||
assert_eq!(runtime.run_id, run_id);
|
||||
assert_eq!(runtime.session_id, state.session_id);
|
||||
let records = active_process_session_records_at(&root, Some("code-prototype"), Some(run_id))
|
||||
.expect("read reconciled process record");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].status, "needs-reconciliation");
|
||||
assert!(records[0].needs_reconciliation);
|
||||
|
||||
let task_path = game_creator_agent_runtime_task_path(&root, "code-prototype");
|
||||
remove_jsonl_records_for_test(&task_path, |task| {
|
||||
task["runId"] == run_id && task["phase"] == "needs-reconciliation"
|
||||
});
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&task_path)
|
||||
.expect("open task tail fixture")
|
||||
.write_all(b"{\"phase\":\"needs-recon")
|
||||
.expect("write truncated task tail");
|
||||
fs::remove_file(root.join(".agent/runtime/agents/code-prototype.json"))
|
||||
.expect("remove reconciliation runtime state");
|
||||
let event_path = game_creator_agent_runtime_event_path(&root, "code-prototype");
|
||||
remove_jsonl_records_for_test(&event_path, |event| {
|
||||
event["runId"] == run_id
|
||||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||||
});
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&event_path)
|
||||
.expect("open event tail fixture")
|
||||
.write_all(b"{\"eventType\":\"process_session.reconciled")
|
||||
.expect("write truncated event tail");
|
||||
let agent_db_path = root.join(".agent/agent.db");
|
||||
remove_jsonl_records_for_test(&agent_db_path, |audit| {
|
||||
audit["runId"] == run_id
|
||||
&& audit["recordType"]
|
||||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||||
});
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&agent_db_path)
|
||||
.expect("open Agent DB tail fixture")
|
||||
.write_all(b"{\"recordType\":\"agent.runtime.process_session")
|
||||
.expect("write truncated Agent DB tail");
|
||||
|
||||
resume_game_creator_agent_background_tasks_at(&root)
|
||||
.expect("partial reconciliation projections are repaired");
|
||||
let repaired = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||||
.expect("read repaired reconciliation runtime")
|
||||
.state;
|
||||
assert_eq!(repaired.status, "failed");
|
||||
assert_eq!(repaired.phase, "needs-reconciliation");
|
||||
assert_eq!(repaired.run_id, run_id);
|
||||
assert_eq!(repaired.session_id, state.session_id);
|
||||
let reconciliation_tasks = fs::read_to_string(game_creator_agent_runtime_task_path(
|
||||
&root,
|
||||
"code-prototype",
|
||||
))
|
||||
.expect("read reconciliation tasks")
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str::<Value>(line).expect("parse reconciliation task"))
|
||||
.filter(|task| task["runId"] == run_id && task["phase"] == "needs-reconciliation")
|
||||
.count();
|
||||
assert_eq!(reconciliation_tasks, 1);
|
||||
let reconciliation_events = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||||
&root,
|
||||
"code-prototype",
|
||||
))
|
||||
.expect("read reconciliation events")
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str::<Value>(line).expect("parse reconciliation event"))
|
||||
.filter(|event| {
|
||||
event["runId"] == run_id
|
||||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||||
})
|
||||
.count();
|
||||
assert_eq!(reconciliation_events, 1);
|
||||
let audits = read_agent_db_records_for_test(&root)
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
record["runId"] == run_id
|
||||
&& record["recordType"]
|
||||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(audits.len(), 1);
|
||||
assert_eq!(audits[0]["runId"], run_id);
|
||||
assert_eq!(audits[0]["processId"], process_id);
|
||||
|
||||
let mut conflicting_event_state = repaired.clone();
|
||||
conflicting_event_state.session_id = "agent-session-conflicting".to_string();
|
||||
let event_error = append_game_creator_agent_runtime_action_event(
|
||||
&root,
|
||||
&conflicting_event_state,
|
||||
"process_session.reconciled_after_runner_restart",
|
||||
"failed",
|
||||
"needs-reconciliation",
|
||||
"Runner 重启后发现旧 boot 的活跃进程会话,已停止自动恢复。",
|
||||
conflicting_event_state.error.as_deref(),
|
||||
&record.start_action_id,
|
||||
)
|
||||
.expect_err("conflicting reconciliation event must fail closed");
|
||||
assert!(event_error.contains("幂等身份冲突"));
|
||||
let audit_error = append_agent_db_process_reconciliation_if_missing_for_action(
|
||||
&root,
|
||||
"code-prototype",
|
||||
run_id,
|
||||
&record.start_action_id,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.process_session.reconciled_after_runner_restart",
|
||||
"agentId": "code-prototype",
|
||||
"taskId": state.task_id,
|
||||
"sessionId": state.session_id,
|
||||
"runId": run_id,
|
||||
"actionId": record.start_action_id,
|
||||
"actionFingerprint": record.start_action_fingerprint,
|
||||
"tool": "runtime.process_session",
|
||||
"executionMode": "auto",
|
||||
"status": "needs-reconciliation",
|
||||
"summary": "Runner 重启后发现旧 boot 的活跃进程会话,已停止自动恢复。",
|
||||
"safeDetail": null,
|
||||
"detailUnavailable": true,
|
||||
"processId": "proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"ownerBootId": record.owner_boot_id,
|
||||
"processStatus": "needs-reconciliation",
|
||||
"needsReconciliation": true,
|
||||
}),
|
||||
)
|
||||
.expect_err("conflicting reconciliation audit must fail closed");
|
||||
assert!(audit_error.contains("身份冲突"));
|
||||
|
||||
resume_game_creator_agent_background_tasks_at(&root)
|
||||
.expect("complete reconciliation resume remains idempotent");
|
||||
let final_reconciliation_tasks = fs::read_to_string(game_creator_agent_runtime_task_path(
|
||||
&root,
|
||||
"code-prototype",
|
||||
))
|
||||
.expect("read final reconciliation tasks")
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str::<Value>(line).expect("parse final reconciliation task"))
|
||||
.filter(|task| task["runId"] == run_id && task["phase"] == "needs-reconciliation")
|
||||
.count();
|
||||
assert_eq!(final_reconciliation_tasks, 1);
|
||||
let final_reconciliation_events = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||||
&root,
|
||||
"code-prototype",
|
||||
))
|
||||
.expect("read final reconciliation events")
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str::<Value>(line).expect("parse final reconciliation event"))
|
||||
.filter(|event| {
|
||||
event["runId"] == run_id
|
||||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||||
})
|
||||
.count();
|
||||
assert_eq!(final_reconciliation_events, 1);
|
||||
assert_eq!(
|
||||
read_agent_db_records_for_test(&root)
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
record["runId"] == run_id
|
||||
&& record["recordType"]
|
||||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||||
})
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_restart_resume_rejects_process_session_task_identity_mismatch() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "Runner 重启身份冲突项目")
|
||||
.expect("project init");
|
||||
let run_id = "code-process-runner-restart-identity-mismatch-run";
|
||||
let state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"拒绝错误归属的旧 Runner 进程",
|
||||
run_id,
|
||||
"agent-background-task",
|
||||
"等待旧进程状态",
|
||||
vec!["核对进程会话身份".to_string()],
|
||||
)
|
||||
.expect("start runtime state");
|
||||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||||
|
||||
let process_id = "proc-fedcba9876543210fedcba9876543210";
|
||||
let now = unix_timestamp();
|
||||
let sandbox = command_sandbox_platform_metadata();
|
||||
let record = ProcessSessionRecord {
|
||||
schema_version: "3".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
agent_id: "code-prototype".to_string(),
|
||||
task_id: "different-task".to_string(),
|
||||
conversation_session_id: state.session_id.clone(),
|
||||
run_id: run_id.to_string(),
|
||||
start_action_id: "action-fedcba9876543210fedcba98".to_string(),
|
||||
start_action_fingerprint: "c".repeat(64),
|
||||
process_id: process_id.to_string(),
|
||||
owner_boot_id: format!("stale-{}", process_session_boot_id()),
|
||||
command_id: "npm:process:fixture".to_string(),
|
||||
program: "npm".to_string(),
|
||||
cwd: ".".to_string(),
|
||||
sandbox_backend: sandbox.backend.to_string(),
|
||||
sandbox_mode: sandbox.mode.to_string(),
|
||||
network_access: sandbox.network.to_string(),
|
||||
sandbox_profile_version: sandbox.profile_version.to_string(),
|
||||
sandbox_establishment: "established".to_string(),
|
||||
target_exec: "established".to_string(),
|
||||
launch_failure_kind: None,
|
||||
sandbox_ready_at: Some(now),
|
||||
exec_established_at: Some(now),
|
||||
status: "running".to_string(),
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdin_open: true,
|
||||
output_bytes: 0,
|
||||
output_sha256: format!("{:x}", Sha256::digest([])),
|
||||
output_ref: Some(format!(
|
||||
".agent/runtime/process-sessions/{process_id}.output.json"
|
||||
)),
|
||||
source_fingerprint_before: "d".repeat(64),
|
||||
source_fingerprint_after: None,
|
||||
source_changed: None,
|
||||
needs_reconciliation: false,
|
||||
started_at: now,
|
||||
terminal_at: None,
|
||||
updated_at: now,
|
||||
};
|
||||
let record_directory = root.join(".agent/runtime/process-sessions");
|
||||
fs::create_dir_all(&record_directory).expect("create process record directory");
|
||||
fs::write(
|
||||
record_directory.join(format!("{process_id}.json")),
|
||||
serde_json::to_vec(&record).expect("serialize process record"),
|
||||
)
|
||||
.expect("write stale process record");
|
||||
|
||||
let error = resume_game_creator_agent_background_tasks_at(&root)
|
||||
.expect_err("identity mismatch must fail closed");
|
||||
assert!(error.contains("身份不一致"));
|
||||
let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||||
.expect("read unchanged runtime")
|
||||
.state;
|
||||
assert_eq!(runtime.run_id, run_id);
|
||||
assert_ne!(runtime.phase, "needs-reconciliation");
|
||||
let reconciliation_events = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||||
&root,
|
||||
"code-prototype",
|
||||
))
|
||||
.expect("read reconciliation events")
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str::<Value>(line).expect("parse reconciliation event"))
|
||||
.filter(|event| {
|
||||
event["runId"] == run_id
|
||||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||||
})
|
||||
.count();
|
||||
assert_eq!(reconciliation_events, 0);
|
||||
assert_eq!(
|
||||
read_agent_db_records_for_test(&root)
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
record["agentId"] == "code-prototype"
|
||||
&& record["runId"] == run_id
|
||||
&& record["recordType"]
|
||||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||||
})
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
|
||||
fs::remove_file(record_directory.join(format!("{process_id}.json")))
|
||||
.expect("remove task mismatch process record");
|
||||
let mut agent_mismatch = record;
|
||||
agent_mismatch.agent_id = "design-director".to_string();
|
||||
agent_mismatch.task_id = state.task_id.clone();
|
||||
fs::write(
|
||||
record_directory.join(format!("{process_id}.json")),
|
||||
serde_json::to_vec(&agent_mismatch).expect("serialize Agent mismatch process record"),
|
||||
)
|
||||
.expect("write Agent mismatch process record");
|
||||
let agent_error = resume_game_creator_agent_background_tasks_at(&root)
|
||||
.expect_err("Agent identity mismatch must fail closed");
|
||||
assert!(agent_error.contains("无法安全恢复"));
|
||||
let unchanged = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||||
.expect("read runtime after Agent mismatch")
|
||||
.state;
|
||||
assert_eq!(unchanged.run_id, run_id);
|
||||
assert_ne!(unchanged.phase, "needs-reconciliation");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_session_command_start_is_a_revision_mutation_but_not_verification() {
|
||||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||||
|
||||
@@ -4315,3 +4315,12 @@
|
||||
- 决策:`command.stdin` 写入和 flush 成功后,若 target 在 writer 释放后先形成可信 terminal,仍按成功返回并持久化 `stdinOpen=false`;只有写入部分失败或结果 record 无法落盘才进入 reconciliation。
|
||||
- 决策:active process session 事实由 live registry、durable active/reconciliation record 和 Linux pending reservation 并集构成。capacity、cancel、final 和 runner idle 都必须先合并 live registry;record 被删除或改名不能让 live session 失败开放,损坏 record 仍读取失败关闭。non-Linux live record 的 started/ready/exec 使用同一 launch 时间点,避免跨秒后违反 v3 时间顺序。
|
||||
- 边界:确定性 bridge、PTY、迁移、fast-exit、target-exec-failed 和零执行测试通过后,只能宣称本地 Runtime 链路完成;真实 Provider `process-session` 与 Runner kill 套件重新通过前,不新增 V1.11.1 Provider PASS 结论。
|
||||
|
||||
## 2026-07-14 Agent Runner 临时端口耗尽与旧进程恢复
|
||||
|
||||
- 决策:Runner 正常仍优先 `bind(127.0.0.1:0)`。Linux 仅在该调用返回 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,按 boot 随机化起点并扫描 61000-65535 中同时位于临时范围外、不低于实际非特权起点且未被 reserved ranges 占用的 loopback 端口;任一 sysctl 不可可信读取、候选耗尽或非占用类错误继续失败关闭。不得停止现有服务、绑定非 loopback 地址或移除 endpoint 私有 token。
|
||||
- 决策:`runtime.resume` 先全局分类 reconciliation record,未知 Agent 立即失败关闭,再在每个 Agent lane 取得任务锁后处理所属旧 boot record。record 迁入 reconciliation 后,所属 task / state / queue / event / Agent DB 必须逐投影、可修复地幂等同步为 `needs-reconciliation`:task 仅在尚未进入 reconciliation 时追加;state / queue 每次从 task ledger 重建;event 和 Agent DB 绑定原 `startActionId + actionFingerprint`,锁内修复截断 JSONL 尾记录,再按 Agent/task/session/run/process/owner boot 全字段检测后补齐。同键冲突必须报错,不能当成完成。任一中间写入成功后 Runner 再次崩溃,下一次 resume 仍要继续补齐其余投影,不能因 task phase 已更新而整体早退。
|
||||
- 决策:恢复写入前必须逐条核对 process record 与 owning task 的 `agentId / runId / taskId / conversationSessionId`;任一冲突都失败关闭且不能改写原 task。同一 Agent 同时存在多个不同 owning run 的 reconciliation record 时也失败关闭;同一 run 的多个 record 只可在全部身份一致后聚合。缺 owning task、记录损坏或身份冲突时禁止恢复 LLM、按 PID 重连或重放 start。
|
||||
- 验收门禁: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。
|
||||
|
||||
@@ -2941,3 +2941,28 @@
|
||||
- 处理:sandbox-ready 后 child 阻塞等待父侧显式 commit 或 abort,父侧失败时发送 abort 并回收树。v3 读取使用封闭状态矩阵和 `started <= ready <= exec <= terminal <= updated` 的逐项可选时间校验;旧 boot prepared/launching 及同 action start replay转成 target unknown。非 Linux durable callback 只放在 existing action miss 分支,started/ready/exec 使用同一时间点。live registry 必须与 durable record、pending reservation 合并参与 capacity/final/idle,不能因 record 缺失失败开放。
|
||||
- 验证:durable callback 延迟超过旧 3 秒时 target marker 在 callback 内必须仍不存在、commit 后才出现;构造 launch-unknown/start-audit/target-exec 的非法组合均拒绝读取,旧 boot launching 和 same-action replay必须变成可再次读取的 reconciliation,同 action Windows 测试只调用一次 callback;删除 live record 后 final/idle 仍被 registry 阻断。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。
|
||||
|
||||
## loopback port 0 也会被临时端口池耗尽阻断
|
||||
|
||||
- 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 `TcpListener::bind(127.0.0.1:0)` 直接返回 `Address already in use`,所有 Agent 写命令随后报“Runner 在就绪前退出”。
|
||||
- 原因:port 0 仍需要内核从 `ip_local_port_range` 分配监听端口;本机 api-server 与 SpacetimeDB 的约 2.8 万双向连接占满 32768-60999 后,即使目标端口不是旧 endpoint 端口,自动分配也会失败。只看 `ss -ltn` 会漏掉占用本地端口的 established client socket。
|
||||
- 处理:先保留 port 0 正常路径;Linux 只在 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。
|
||||
- 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner,确认 endpoint 端口位于临时范围外、heartbeat 可读,并完成真实 Provider 任务。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。
|
||||
|
||||
## 旧 process record 惰性迁移不能替代 resume 主动投影
|
||||
|
||||
- 现象:owning Runner 被 SIGKILL 后,项目 cwd 进程已经清零,新 boot 和同 run / session 也恢复成功,但 process record 仍显示旧 boot 的 running,task 长时间停在旧 planning,真实 Runner-kill 套件等不到 reconciliation。
|
||||
- 原因:process record 的旧 boot 迁移只在 poll、active scan 等读取路径发生;独立 Runner `runtime.resume` 原先直接恢复 running task,没有先触发 active process scan,也没有把迁移后的 record 同步投影到 task / state。
|
||||
- 处理:resume 先全局拒绝指向未知 Agent 的 reconciliation record,再在 Agent lane 锁内处理所属旧 active records;一旦 record 进入 reconciliation,立即把 owning run 的 task / state / queue / event / Agent DB 写成 `needs-reconciliation` 并停止恢复。这里不能用“task phase 已是 reconciliation”作为整体完成标记:task 追加、state / queue 重建、event 和 Agent DB 补齐必须分别幂等。JSONL 追加前先锁内修复截断尾行;event / Agent DB 用原 start action 身份去重,并对 Agent/task/session/run/process/owner boot 做冲突校验,不能只按 run/type 判断存在。
|
||||
- 安全边界:写投影前逐条核对 process record 与 task 的 Agent、run、task、conversation session 身份;同一 Agent 出现多个不同 owning run 时失败关闭。同 run 多 record 也只能在全部身份一致时聚合。缺 task、记录损坏或身份冲突时不得改写原 task、继续规划、按 PID 重连或自动重启服务。
|
||||
- 验证:确定性用例除重复 resume 外,还要在首次恢复后把 task、event 和 Agent DB 专用投影改成截断尾行并删除 state,再次 resume 必须修复半行、补齐四者且 reconciliation task 仍只有 1 条;Agent 错归属、task/session/process 同键冲突必须报错。真实套件在 readiness 后 SIGKILL Runner,分别从全量 task、event、Agent DB、runtime state 和 process record证明唯一 reconciliation与零 reconnect;可选文件或目录只容忍 `ENOENT`,其他读取错误不能吞掉。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs`、`process_session.rs`、`tests.rs`、`agent-runtime-real-e2e.mjs`。
|
||||
|
||||
## 私有 PTY 正文不能只靠 prompt 阻止最终回复复述
|
||||
|
||||
- 现象:Agent 正确完成唯一持久进程交互,但模型偶发在最终回复中复述一次性 challenge 或 readiness / echo / stopped 行,随后 conversation、event 和 Agent DB 公共投影一起泄漏私有进程正文。
|
||||
- 原因:`command.poll` 正文需要进入 owning Agent 私有 observation 才能继续交互;system/task prompt 只能约束模型行为,不能作为持久化安全边界。
|
||||
- 处理:在 finalization journal 写入前检查当前 run 的成功 `command.poll` observation;只要存在非空私有输出,就不再持久化模型原回复,而是写固定安全完成摘要,再计算 fingerprint、写 assistant 和公共终态投影。不能只替换长行或高熵 token,因为模型可能只复述 `1234` 等短子串;不要把原始行或 token 写入新的审计记录。
|
||||
- 验证:定向用例让最终回复包含 challenge、完整 ready/echo/stopped 行和 `PIN=1234` 的短值局部回显,要求统一变为固定摘要;没有私有 poll 正文的普通回复保持原样。真实 Provider 继续扫描 task/event/Agent DB/receipt/conversation/activity/output/runtime state/report,所有正文泄漏必须为 0。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs`、`tests.rs`、`agent-runtime-real-e2e.mjs`。
|
||||
|
||||
@@ -87,7 +87,7 @@ Runner 从显式 AppData 目录读取 `game-creator.config.json`,API Key 不
|
||||
|
||||
### 本地协议
|
||||
|
||||
- Runner 只监听随机 `127.0.0.1` 端口。
|
||||
- Runner 只监听随机 `127.0.0.1` 端口。正常启动先使用 `bind(127.0.0.1:0)`;Linux 仅在该调用因 `AddrInUse` 失败后,才读取并严格解析 `/proc/sys/net/ipv4/ip_local_port_range`、`ip_unprivileged_port_start` 与 `ip_local_reserved_ports`。候选限定在 61000-65535 高位段,排除当前临时范围、低于实际非特权起点和显式 reserved ranges,再按当前 boot 随机化起点并跳过已占用端口。任一 sysctl 缺失/非法、候选耗尽或出现非占用类错误必须失败关闭,不得停止现有服务、绑定非 loopback 地址或回退到无 token IPC。
|
||||
- AppData endpoint 文件权限收紧为当前用户,保存 `protocolVersion / pid / bootId / port / token / heartbeatAt`。
|
||||
- 请求使用 `u32` 长度前缀加 UTF-8 JSON,单帧最多 1 MiB。
|
||||
- 每个请求必须携带私有 token、`requestId` 和协议版本。
|
||||
@@ -591,12 +591,22 @@ process record 已升级为 v3:只在 `SANDBOX_READY` 后执行 revision / ver
|
||||
|
||||
process-session child 在 `SANDBOX_READY` 后阻塞等待父侧显式 `COMMIT_EXEC / ABORT_LAUNCH`,不设置独立短 commit timeout;durable mutation 超过 3 秒仍保持 target 零执行,父侧失败时显式 abort 并回收 wrapper 树。target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 `setpgid + tcsetpgrp` 并恢复信号掩码后才 exec,避免目标以后台组立即读取 PTY 而停在 SIGTTIN。`command.terminate` 经 Runtime bridge 和 trampoline 私有控制帧只向 target group 发 SIGTERM;即使 direct leader 先退出,trampoline 仍在 800ms 宽限内等待同组后代完成清理,wrapper / bwrap 继续承载 PTY 与控制链,超时后才强杀外层 containment group。正常 EOF 写入成功后即使可信 terminal 先于状态落盘,也按成功返回而不是误标 reconciliation;Windows legacy start 的 durable callback 只在同 action首次创建时执行,ready/exec/started 使用同一时间点避免跨秒倒序。
|
||||
|
||||
本地确定性测试已覆盖真实 PTY stdin/echo/EOF/terminate、direct leader 先退出后同组后代完成 400ms SIGTERM 清理、3.2 秒慢 durable callback、workspace sandbox后代、commit callback失败目标零执行、target exec failure、fast exit 0/7、wrong nonce/peer/乱序、v1/v2 active/terminal迁移、v3 非法组合、旧 boot launching及同 action replay、live record 缺失门禁、start Agent DB 审计失败、start cursor零消费和控制材料不进入 transcript。该实现完成 V1.11.1 本地 Runtime 链路,但真实 Provider `process-session` 与 Runner kill 套件仍须按新协议重跑;在它们通过前,不新增 Provider PASS、统计条数或整体“已验收”结论。
|
||||
本地确定性测试已覆盖真实 PTY stdin/echo/EOF/terminate、direct leader 先退出后同组后代完成 400ms SIGTERM 清理、3.2 秒慢 durable callback、workspace sandbox后代、commit callback失败目标零执行、target exec failure、fast exit 0/7、wrong nonce/peer/乱序、v1/v2 active/terminal迁移、v3 非法组合、旧 boot launching及同 action replay、live record 缺失门禁、start Agent DB 审计失败、start cursor零消费和控制材料不进入 transcript。真实 Provider 结果必须继续由下述独立 `process-session` 与 Runner kill 套件证明,不能用本地用例替代。
|
||||
|
||||
2026-07-14 最新真实 `gpt-5.5` `llm-runtime` 已按新增 metadata 门禁通过:123 条 task、208 条 event、220 条 Agent DB、15 次成功工具执行、2 次 `command.exec`(先失败后成功)、1 次 `project.verify`、3 个隔离实例、双视口浏览器验证、唯一 completed / assistant;Runner 强杀后 run / session 身份稳定恢复,重复、副作用重放、密钥和诱饵泄漏均为 0。保留现场独立核对 2 条 command.exec 和 1 条 project.verify 审计均为 `bubblewrap / workspace-write / disabled / workspace-v1` 后按 sentinel 清理。
|
||||
|
||||
同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start;收紧策略后的旧 E2E 又暴露 V1.10 fixture 与 V1.11 sandbox 契约冲突,fixture 在 readiness 前写 `.agent`、启动 namespace 内 loopback 并把 namespace PID/端口当宿主事实,而 V1.11 正确隐藏 `.agent` 且隔离 pid/network namespace,因此 `process-transcript-interaction-evidence-missing` 不能直接归因于模型抄错 challenge。修复方向是纯 PTY fixture:不写 `.agent`、不启动 TCP、不跨 namespace 读取 PID/端口,以唯一 process record/start/readiness、连续 cursor、stdin hash、精确 echo、stopped 和宿主项目 cwd 进程清零作为事实。最新一次重跑在零工具计划阶段连续收到 Provider 502,只记外部瞬态失败,不用于判断 Runtime。新的纯 PTY Provider 套件 PASS 前,不更新 V1.10 历史结论,也不把本次失败描述成已验收。
|
||||
|
||||
2026-07-14 后续真实 `gpt-5.5` 已在纯 PTY 与 process record v3 门禁下完成最终复验。`process-session` 为 PASS:41 条 task、75 条 event、63 条 Agent DB、8 条 receipt,唯一 start、3 次连续 poll、唯一 stdin / terminate、3 次 cursor 推进、唯一 terminal / completed / assistant;side-effect replay、重复 action / message / receipt,以及 task / event / Agent DB / receipt / conversation / activity / output / report 的私有进程正文、Provider Key 和诱饵泄漏均为 0。`process-session-runner-kill` 为 PASS:13 条 task、19 条 event、19 条 Agent DB,readiness 后真实 SIGKILL owning Runner,项目 cwd 进程归零,新 boot 恢复同一 run / session,只形成 1 条 reconciliation,reconnect / stdin / terminate / completed / assistant / replay / 泄漏均为 0;两套 disposable 项目都按 sentinel 自动清理。
|
||||
|
||||
真实复验同时补齐两项独立 Runner 恢复能力。Linux 在 `bind(127.0.0.1:0)` 因临时端口池耗尽返回 `AddrInUse` 时,懒读取临时范围、实际非特权起点和 reserved ranges,只从剩余 61000-65535 高位 loopback 候选选择端口;当前 32768-60999 被约 2.8 万连接占满的主机上真实选到范围外端口并完成两套测试。
|
||||
|
||||
新 boot 执行 `runtime.resume` 时,先全局拒绝指向未知 Agent 的 reconciliation record,再在每个 Agent lane 锁内主动处理所属旧 boot active process record;恢复前逐条核对 record 与 task 的 Agent、run、task 和 conversation session 身份,不同 owning run 或任一身份冲突都失败关闭。命中后按 task / state+queue / event / Agent DB 四个持久步骤逐项补齐 `needs-reconciliation`:task 只追加一次,state 和 queue 每次从 ledger 重建,event 与 Agent DB 绑定原 start action identity。task/event 通用 JSONL 追加和 Agent DB 专用入口都先锁内修复截断尾行;同键记录必须核对 Agent/task/session/run/process/owner boot,冲突报错而不是当成完成。任何步骤后再次崩溃都不能阻止下次 resume 补齐其余投影;重复完整 resume 不产生第二条 task、event 或 audit。整个路径禁止恢复 LLM、按 PID 重连或重放 start。
|
||||
|
||||
Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢复成功,而是分别要求全量 task、event、Agent DB、runtime state 和 process record 中专用 reconciliation 精确一次,并保持 reconnect 为 0。runtime state 是必需证据并加入公共正文泄漏扫描;可选 activity / output 或证据目录仅在 `ENOENT` 时视为空,权限、I/O 或 JSON 损坏必须直接让验收失败。
|
||||
|
||||
`command.poll` 私有正文虽然必须进入 owning Agent context 供后续交互,但模型 prompt 不是持久化隔离边界。后台 finalization 在创建 assistant journal 前检查当前 run 的成功 poll observation;只要存在非空输出,就把模型最终回复整体收束为固定安全完成摘要,再计算 response fingerprint 并写 conversation/event/Agent DB。该边界不按长度猜测 token,因此 challenge、ready/echo/stopped 行和短 PIN 的局部回显都不能扩大到公共持久面;没有私有 poll 正文的普通回复保持原样。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `npm run ai-game-creator-shell:typecheck`
|
||||
|
||||
@@ -34,7 +34,9 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod
|
||||
|
||||
2026-07-14 V1.11.1 第一切片:`command.exec / project.verify` 已共用受信任 trampoline launcher。bwrap 的 `child-pid` 只推进 child-created,`--block-fd` 放行后仍须收到 `SANDBOX_READY`;Runtime 完成 revision / verification durable callback 后才发送 `COMMIT_EXEC`,收到 `EXEC_ESTABLISHED` 后才计算业务 timeout。当前不把这套 stdin 私有控制通道用于 PTY;`command.start` 与 process record v3 仍是下一切片,相关链路完成前 V1.11.1 保持进行中。
|
||||
|
||||
2026-07-14 V1.11.1 第二切片:`command.start` 已通过 PTY 外 abstract Unix socket bridge 接入同一 ready/commit/exec 状态机,process record 升级 v3;真实 target 的 PTY stdin 由 trampoline 复制已验证的 fd 1 slave,不向 target 泄漏 fd 0控制通道。pending launch和 live registry共同参与 capacity/shutdown/idle/final门禁,旧 v1/v2 active record迁入 reconciliation,target exec failure和fast exit保留同一 processId。child 等待父侧显式 commit/abort,不以短 timeout 猜测持久化失败;target 在 pre-exec 内原子进入 PTY 前台进程组,graceful terminate 只给 target group 发信号,direct leader 先退出后仍给同组后代保留清理宽限。v3 状态组合和时间顺序失败关闭,旧 boot launching及同 action replay降级为 launch-unknown,Windows 同 action replay 不重复执行 durable mutation且使用单一 launch 时间点。确定性测试通过后,V1.11.1 仍等待真实 Provider `process-session` 和 Runner kill复验,不提前标记整体已验收。
|
||||
2026-07-14 V1.11.1 第二切片:`command.start` 已通过 PTY 外 abstract Unix socket bridge 接入同一 ready/commit/exec 状态机,process record 升级 v3;真实 target 的 PTY stdin 由 trampoline 复制已验证的 fd 1 slave,不向 target 泄漏 fd 0控制通道。pending launch和 live registry共同参与 capacity/shutdown/idle/final门禁,旧 v1/v2 active record迁入 reconciliation,target exec failure和fast exit保留同一 processId。child 等待父侧显式 commit/abort,不以短 timeout猜测持久化失败;target 在 pre-exec 内原子进入 PTY 前台进程组,graceful terminate只给 target group发信号,direct leader先退出后仍给同组后代保留清理宽限。v3 状态组合和时间顺序失败关闭,旧 boot launching及同 action replay降级为 launch-unknown,Windows 同 action replay不重复执行 durable mutation且使用单一 launch时间点。Linux Runner 的 port 0 因临时端口池耗尽失败时,只从系统临时范围外选择非特权 loopback端口。新 boot resume 先全局拒绝未知 Agent record,再在 Agent lane 锁内核对 process record 与 task 的 Agent/run/task/session 身份,并逐项补齐 task、state/queue、event 和 Agent DB reconciliation;task/event/Agent DB 截断尾行可修复,event/audit绑定原 start action 并对同键身份冲突失败关闭。任一步后再次崩溃都可继续修复,完整重复 resume 不重复投影。不同 owning run 或身份冲突时不恢复 LLM、按 PID 重连或重放 start。后台 finalization 只要看到当前 run 的非空私有 poll 正文,就在 assistant journal 前把模型回复整体收束为固定安全摘要。Runner-kill 验收分别从全量 task、event、Agent DB、runtime state 和 process record 取证,并只把可选证据文件/目录的 `ENOENT` 当作空面。
|
||||
|
||||
2026-07-14 V1.11.1 最终真实验收:发布 AppData 的真实 `gpt-5.5` `process-session` 形成 41 条 task、75 条 event、63 条 Agent DB 和 8 条 receipt,唯一 start、3 poll、唯一 stdin / terminate、3 次 cursor 推进及唯一 terminal / completed / assistant全部通过;Runner kill套件形成 13 条 task、19 条 event、19 条 Agent DB,真实 SIGKILL后项目 cwd进程清零、新 boot保持同 run / session并只形成 1 条 reconciliation。两套的 reconnect、重放、重复 action / message / receipt、公共进程正文、密钥和诱饵泄漏均为 0,disposable项目均自动清理;V1.11.1 持久进程链路据此完成验收。
|
||||
|
||||
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)`,不得记为通过。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user