给真机夹具加 restart 用例:回合进行中杀进程后部分历史与宿主事实
Project CI / Backend tests (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 / 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 / AI game creator shell Rust lane 1/2 (push) Has been cancelled

- direct-execution-production-fixture:新增 restart 场景(契约 → 部分助手文本 + 阻塞原生工具 → 工具真开始后 SIGTERM 杀进程),断言部分文本与工具卡片按顺序落盘、被杀工具卡片是最后一行且无回执行、账本不伪造成 completed/executorStopped
- 复验:completed,passes,mcp,mcp-write,native,deadline,restart 共 7/7 passed
- 变异验证:把"等 400ms 再杀"改成"等 20s"(让回合自然跑完)后同一条用例立即变红(AGC must be killed mid-turn)
- 记录事实:CLI/无前端宿主不写用户消息(GUI 由前端写),夹具断言据此只校验部分文本与工具卡片顺序
- 文档:DirectProject 里程碑补该轮核对与剩余 UI 侧边界;pitfalls 记 CLI 不写用户消息这一坑
This commit is contained in:
kdletters
2026-09-28 23:32:02 +08:00
parent 4415c7a5ff
commit c388803729
3 changed files with 145 additions and 35 deletions
@@ -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(