Merge branch 'master' into codex/fix-web-preflight-home
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Backend tests (pull_request) Successful in 4m4s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m29s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m10s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m1s
Project CI / Repository checks (pull_request) Successful in 2m42s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Backend tests (pull_request) Successful in 4m4s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m29s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m10s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m1s
Project CI / Repository checks (pull_request) Successful in 2m42s
This commit was merged in pull request #527.
This commit is contained in:
@@ -150,6 +150,63 @@ async function readLedger(host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `restart` 用例判据:回合进行中杀掉进程后
|
||||
* - 已落盘的用户消息、部分助手文本与工具卡片必须按原顺序保留;
|
||||
* - 宿主不得为这一轮伪造终态(不能出现最终助手消息,账本不能是 completed / executorStopped)。
|
||||
* 对应的里程碑条款是「回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,
|
||||
* 且界面不显示忙碌态」里**宿主侧可证的那一半**(界面渲染仍要真实客户端窗口)。
|
||||
*/
|
||||
async function assertRestartKeepsPartialHistory(project, ledger, exit) {
|
||||
assert(
|
||||
exit.code !== 0 || exit.signal,
|
||||
'restart: AGC must be killed mid-turn, got ' + JSON.stringify(exit),
|
||||
);
|
||||
const history = await fs
|
||||
.readFile(path.join(project, '.agent/conversations/project.jsonl'), 'utf8')
|
||||
.catch(() => '');
|
||||
const rows = history.split('\n').filter(Boolean);
|
||||
const raw = rows.join('\n');
|
||||
// 注意:CLI / 无前端宿主不写用户消息(GUI 是前端先调 append_direct_project_conversation_message),
|
||||
// 所以这里只断言"部分文本 + 工具卡片按原顺序落盘",用户消息的持久化属于 GUI 路径。
|
||||
const order = ['RESTART-PARTIAL-TEXT', 'exec_command'].map((needle) =>
|
||||
raw.indexOf(needle),
|
||||
);
|
||||
assert(
|
||||
order.every((index) => index >= 0) && order[0] < order[1],
|
||||
'restart: partial text and tool card must stay in order; got ' +
|
||||
JSON.stringify({ order, rows: rows.length }),
|
||||
);
|
||||
assert(
|
||||
!raw.includes('Fixture actions finished'),
|
||||
'restart: killed turn must not fabricate the final assistant message',
|
||||
);
|
||||
// 被杀的工具卡片必须是最后一条:既证明卡片已落盘,也证明它没有产生任何回执
|
||||
// (有回执就说明这一轮跑到了工具结束之后,不再是"进行中终止")。
|
||||
const lastRow = rows[rows.length - 1] ?? '';
|
||||
assert(
|
||||
lastRow.includes('restart-slow') && lastRow.includes('exec_command'),
|
||||
'restart: the killed tool card must be the last persisted row; got ' +
|
||||
lastRow.slice(0, 200),
|
||||
);
|
||||
assert(
|
||||
!rows.some(
|
||||
(row) => row.includes('restart-slow') && row.includes('"output"'),
|
||||
),
|
||||
'restart: the killed tool must not have produced an output row',
|
||||
);
|
||||
assert.notEqual(
|
||||
ledger.phase,
|
||||
'completed',
|
||||
'restart: killed turn must not claim completion',
|
||||
);
|
||||
assert.notEqual(
|
||||
ledger.executorStopped,
|
||||
true,
|
||||
'restart: killed turn must not report a fully stopped executor',
|
||||
);
|
||||
}
|
||||
|
||||
function ownFixtureTree(directory) {
|
||||
if (process.platform !== 'win32') return;
|
||||
assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep));
|
||||
@@ -260,6 +317,7 @@ async function runScenario(name) {
|
||||
'patch',
|
||||
'deadline',
|
||||
'native-session',
|
||||
'restart',
|
||||
].includes(name),
|
||||
'unknown fixture case',
|
||||
);
|
||||
@@ -469,18 +527,42 @@ async function runScenario(name) {
|
||||
),
|
||||
],
|
||||
]
|
||||
: [
|
||||
[register('never.txt')],
|
||||
[native('build', 'console.log(1+1);')],
|
||||
[native('test', 'console.log(2+2);')],
|
||||
[native('fail', 'process.exit(1);')],
|
||||
[
|
||||
native(
|
||||
'forbidden-after-budget',
|
||||
writes('forbidden.txt', 'must not run'),
|
||||
),
|
||||
],
|
||||
];
|
||||
: name === 'restart'
|
||||
? [
|
||||
[register('started.txt')],
|
||||
[
|
||||
{
|
||||
type: 'message',
|
||||
id: 'fixture-restart-partial',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'RESTART-PARTIAL-TEXT',
|
||||
},
|
||||
],
|
||||
},
|
||||
native(
|
||||
'restart-slow',
|
||||
writes('started.txt', 'started') +
|
||||
'setTimeout(()=>{' +
|
||||
writes('late.txt', 'must not run after kill') +
|
||||
'},15000);',
|
||||
),
|
||||
],
|
||||
]
|
||||
: [
|
||||
[register('never.txt')],
|
||||
[native('build', 'console.log(1+1);')],
|
||||
[native('test', 'console.log(2+2);')],
|
||||
[native('fail', 'process.exit(1);')],
|
||||
[
|
||||
native(
|
||||
'forbidden-after-budget',
|
||||
writes('forbidden.txt', 'must not run'),
|
||||
),
|
||||
],
|
||||
];
|
||||
const requests = [];
|
||||
const responses = [];
|
||||
const httpArrivals = [];
|
||||
@@ -696,12 +778,29 @@ async function runScenario(name) {
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024);
|
||||
});
|
||||
// restart:等工具真的开始跑(marker 落盘)再杀进程,保证"回合进行中终止"是真事,
|
||||
// 而不是在模型请求还没回来时就把它掐掉。
|
||||
const restartKillWatch =
|
||||
name === 'restart'
|
||||
? (async () => {
|
||||
const marker = path.join(project, 'started.txt');
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline && !(await exists(marker))) {
|
||||
await sleep(20);
|
||||
}
|
||||
assert(await exists(marker), 'restart: tool never started');
|
||||
// 留一点时间让这一轮的工具卡片落进项目历史,再终止进程。
|
||||
await sleep(400);
|
||||
child.kill();
|
||||
})()
|
||||
: null;
|
||||
const timeout = setTimeout(() => child.kill(), 90_000);
|
||||
const exit = await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
if (planWatch) clearInterval(planWatch);
|
||||
if (restartKillWatch) await restartKillWatch;
|
||||
await planObservationTask;
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
@@ -829,28 +928,32 @@ async function runScenario(name) {
|
||||
': Direct Responses requests must allow multiple tool calls without changing the selected model',
|
||||
);
|
||||
assert(ledger, name + ': missing host-authoritative ledger');
|
||||
assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json');
|
||||
assert.equal(
|
||||
report.markers.forbidden,
|
||||
false,
|
||||
name + ': effect ran after host terminal',
|
||||
);
|
||||
assert.equal(
|
||||
ledger.value.usedPasses,
|
||||
1,
|
||||
name + ': ordinary commands must share one pass',
|
||||
);
|
||||
assert.equal(
|
||||
ledger.value.phase,
|
||||
['completed', 'patch', 'native-session'].includes(name)
|
||||
? 'completed'
|
||||
: 'exhausted',
|
||||
);
|
||||
assert.equal(
|
||||
ledger.value.executorStopped,
|
||||
true,
|
||||
name + ': missing full executor exit proof',
|
||||
);
|
||||
if (name === 'restart') {
|
||||
await assertRestartKeepsPartialHistory(project, ledger.value, exit);
|
||||
} else {
|
||||
assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json');
|
||||
assert.equal(
|
||||
report.markers.forbidden,
|
||||
false,
|
||||
name + ': effect ran after host terminal',
|
||||
);
|
||||
assert.equal(
|
||||
ledger.value.usedPasses,
|
||||
1,
|
||||
name + ': ordinary commands must share one pass',
|
||||
);
|
||||
assert.equal(
|
||||
ledger.value.phase,
|
||||
['completed', 'patch', 'native-session'].includes(name)
|
||||
? 'completed'
|
||||
: 'exhausted',
|
||||
);
|
||||
assert.equal(
|
||||
ledger.value.executorStopped,
|
||||
true,
|
||||
name + ': missing full executor exit proof',
|
||||
);
|
||||
}
|
||||
if (name === 'completed') assert.equal(report.markers.ready, true);
|
||||
if (isMcp) {
|
||||
assert.equal(
|
||||
|
||||
@@ -747,7 +747,17 @@ impl ExecutionAdapter {
|
||||
/// 不得覆盖它。
|
||||
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
|
||||
let reason = failure.to_string();
|
||||
if !self.is_closed() && !self.host_stop_requested() {
|
||||
if self.is_closed() {
|
||||
// 宿主自己收尾:连接是我们先关的,紧随其后的 `TransportClosed` 只是收尾的副产物。
|
||||
// 只把原因留给报告,不改阶段——否则正常的宿主收尾会被改写成 `interrupted`
|
||||
// 并把回执文案换成「执行通道已断开」(见 pitfalls 2026-09-28)。
|
||||
let session = Arc::clone(&self.session);
|
||||
let note = reason.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || session.append_terminal_note(note)).await;
|
||||
return;
|
||||
}
|
||||
// 走到这里说明不是宿主自己关的连接;用户主动终止仍按原口径(记报告、不改失败事实)。
|
||||
if !self.host_stop_requested() {
|
||||
if let Ok(mut slot) = self.turn_failure.lock() {
|
||||
if slot.is_none() {
|
||||
*slot = Some(failure);
|
||||
|
||||
@@ -1293,6 +1293,25 @@ impl ExecutionSession {
|
||||
);
|
||||
self.commit(&mut data, next)
|
||||
}
|
||||
|
||||
/// 只把原因追加进终态说明,**不改阶段**。
|
||||
///
|
||||
/// 宿主自己收尾(正常终态、预算与交付收尾)时会先关掉 app-server,连接随之关闭;
|
||||
/// 这类「关闭原因」要留痕给排障看,但不能把已经/正在正常收口的回合改写成 `Interrupted`
|
||||
/// —— CLI、单回合宿主每轮都会命中这个窗口(见 pitfalls 2026-09-28)。
|
||||
pub(super) fn append_terminal_note(&self, reason: String) -> Result<(), String> {
|
||||
let mut data = self.lock()?;
|
||||
let mut next = data.ledger.clone();
|
||||
let prior = next.terminal_report.take().unwrap_or_default();
|
||||
next.terminal_report = Some(
|
||||
format!("{prior}\n{reason}")
|
||||
.trim()
|
||||
.chars()
|
||||
.take(16_000)
|
||||
.collect(),
|
||||
);
|
||||
self.commit(&mut data, next)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user