完善 Agent Provider 持久重试真实验收

将瞬态故障注入迁移到 Project Supervisor 首次工具规划并验证持久退避恢复
为故障代理补充仅含序号和时间的元数据请求审计与对应测试
强化真实 E2E 对并行委派、重启恢复、重试时序、分账和终局清理的验收
修正瞬态重试目标的 maxRetries 配置门禁并同步 Runtime 文档与项目记忆
This commit is contained in:
AIGameCreator App
2026-07-19 23:24:51 +08:00
parent 21abd45f86
commit 160fe3a8b4
8 changed files with 792 additions and 141 deletions
File diff suppressed because it is too large Load Diff
@@ -265,6 +265,7 @@ export async function startLlmTransientFaultProxy(
const upstreamSockets = new Set();
const heldForwardingWaiters = new Set();
const counterWaiters = new Set();
const requestLog = [];
let requestCount = 0;
let faultInjectedCount = 0;
@@ -284,6 +285,8 @@ export async function startLlmTransientFaultProxy(
forwardingReleased,
stopped,
});
const requestLogSnapshot = () =>
Object.freeze(requestLog.map((entry) => Object.freeze({ ...entry })));
const notifyCounterWaiters = (kind) => {
for (const waiter of [...counterWaiters]) {
@@ -356,7 +359,8 @@ export async function startLlmTransientFaultProxy(
failClosed(request, response, 502);
};
const forwardRequest = (request, response) => {
const forwardRequest = (request, response, requestMetadata) => {
requestMetadata.forwardingStartedAtMs = Date.now();
const transport = options.upstream.protocol === 'https:' ? https : http;
let upstreamRequest;
try {
@@ -439,6 +443,14 @@ export async function startLlmTransientFaultProxy(
const handleRequest = async (request, response, expectsContinue) => {
requestCount += 1;
const requestMetadata = {
sequence: requestCount,
acceptedAtMs: Date.now(),
faultInjectedAtMs: null,
heldAtMs: null,
forwardingStartedAtMs: null,
};
requestLog.push(requestMetadata);
request.on('error', () => {});
response.on('error', () => {});
@@ -460,6 +472,7 @@ export async function startLlmTransientFaultProxy(
!forwardingReleased
) {
heldRequestCount += 1;
requestMetadata.heldAtMs = Date.now();
notifyCounterWaiters('held');
const shouldForward = await waitForForwardingRelease(request, response);
if (!shouldForward) {
@@ -470,6 +483,7 @@ export async function startLlmTransientFaultProxy(
if (faultInjectedCount < options.faultCount) {
faultInjectedCount += 1;
requestMetadata.faultInjectedAtMs = Date.now();
notifyCounterWaiters('fault');
resetSocket(request.socket);
return;
@@ -480,7 +494,7 @@ export async function startLlmTransientFaultProxy(
return;
}
if (expectsContinue) response.writeContinue();
forwardRequest(request, response);
forwardRequest(request, response, requestMetadata);
};
const dispatchRequest = (request, response, expectsContinue = false) => {
@@ -568,6 +582,7 @@ export async function startLlmTransientFaultProxy(
return stats();
},
getStats: stats,
getRequestLog: requestLogSnapshot,
waitForFault(timeoutMs = 5_000) {
return waitForCounter('fault', timeoutMs);
},
@@ -27,6 +27,13 @@ interface ProxyHandle {
port: number;
stats: ProxyStats;
getStats(): ProxyStats;
getRequestLog(): ReadonlyArray<{
sequence: number;
acceptedAtMs: number;
faultInjectedAtMs: number | null;
heldAtMs: number | null;
forwardingStartedAtMs: number | null;
}>;
waitForFault(timeoutMs?: number): Promise<ProxyStats>;
waitForHeldRequest(timeoutMs?: number): Promise<ProxyStats>;
releaseForwarding(): ProxyStats;
@@ -341,6 +348,30 @@ describe('LLM transient fault proxy', () => {
heldRequestCount: 1,
forwardedRequestCount: 0,
});
const heldLog = proxy.getRequestLog();
expect(heldLog).toEqual([
{
sequence: 1,
acceptedAtMs: expect.any(Number),
faultInjectedAtMs: expect.any(Number),
heldAtMs: null,
forwardingStartedAtMs: null,
},
{
sequence: 2,
acceptedAtMs: expect.any(Number),
faultInjectedAtMs: null,
heldAtMs: expect.any(Number),
forwardingStartedAtMs: null,
},
]);
expect(heldLog[1].acceptedAtMs).toBeGreaterThanOrEqual(
heldLog[0].acceptedAtMs,
);
expect(heldLog[1].heldAtMs).toBeGreaterThanOrEqual(heldLog[1].acceptedAtMs);
expect(JSON.stringify(heldLog)).not.toMatch(
/held-secret|request-body|responses|authorization/iu,
);
expect(captured).toHaveLength(0);
expect(proxy.releaseForwarding()).toMatchObject({
forwardingReleased: true,
@@ -363,6 +394,9 @@ describe('LLM transient fault proxy', () => {
}),
]);
expect(proxy.stats.forwardedRequestCount).toBe(1);
expect(
proxy.getRequestLog()[1].forwardingStartedAtMs,
).toBeGreaterThanOrEqual(heldLog[1].heldAtMs);
});
it('holds before injecting a remaining configured fault', async () => {
@@ -4901,3 +4901,4 @@
- 调度:等待态释放执行 lane,允许不同 Agent 并行;同 Agent 当前 running 等待任务继续阻挡后续 pending task,保持 FIFO。活跃 sidecar 阻断 finalization 与 `shutdown_if_idle`cancel、steer、终态和耗尽负责清理。
- 稳定身份:Provider 请求指纹不得包含工具策略 `updatedAt` 等非语义刷新时间。Goal pause 保留 sidecarresume 恢复原等待态;跨秒恢复必须仍命中相同 request fingerprint 和 `-transient-N` slot。final-reply、手动压缩与 tool-plan `repair-N` 继续使用进程内重试,待具备可无歧义重建的持久请求上下文后再单独升级。
- 证据边界:`provider_retry_` 19/19、`provider_transient_retry_` 6/6Tauri/Rust 串行全量 968 passed/4 ignored`cargo check`、rustfmt、编码与 diff 检查通过。本轮确定性验证与 V1.38 真实 Provider E2E 分开记账;任何旧失败轮、旧瞬态重试 PASS 或最小探针都不能拼接成 V1.39 真实 PASS。
- 真实验收:最终代码的独立 `gpt-5.5 / openai_chat / high` suite 以 Project Supervisor 首次 tool-plan 为受控故障目标,在子 Agent 请求产生前进入 `30s` 持久退避并执行一次 pidfd Runner 强杀。新 boot 恢复后 sidecar identity/字节/attempt/slot/retryAt 不变,强杀前、重启后、到期前请求数均为 1,metadata-only 代理证明第二个请求网络接收时间不早于 retryAt;随后同一父 run 由 static collaboration policy 强制同批两个指定专业 Agent,完整完成真重叠、2+1 delivery、唯一 repair、宿主验证和唯一最终回复。最终 `37 started / 37 terminal / 36 completed / 1 injected failed / 1 retry`incidental failure/retry 均为 0;重复、残留 sidecar、正文、Key、项目/正式配置路径泄漏均为 0,隔离现场完整清理,单轮耗时 `891.1s`。验收器按 request identity 分开统计受控注入与额外真实瞬态失败,额外失败仍须逐条通过原 lifecycle/retry/后继终态门禁且 failure/retry 计数相等;此前各失败样本不得与该 PASS 拼接。
@@ -99,10 +99,14 @@ npm run ai-game-creator-shell:typecheck
```bash
npm run test -- apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts
npm run ai-game-creator-shell:typecheck
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_retry_ -- --nocapture --test-threads=1
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_transient_retry_ -- --nocapture --test-threads=1
npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir <AppData>
```
真实 PASS 必须恰好包含 1 个 failed lifecycle、1 条 retry audit 和 1 个 `-transient-1` 后继 identityforwarding gate 放行前 action、receipt、目标 Agent 子委派、claim、assistant、pending、project revision 与 upstream forwarding 全为 0。放行后仍须完成双专业 Agent 重叠、唯一 repair、Runner 强杀恢复、唯一 Supervisor assistant 和零重复/残留/泄漏。suite 只能读正式 AppData,在其同级目录写入 sentinel 管理的 `0600` 私有副本和 overlay;启动 CLI/Runner 时须把 loopback 合并进大小写两套 no-proxy 环境,防止系统 HTTP 代理绕过本地故障门禁;source-dir guard 必须证明本 suite 前缀未进入源目录,源配置和 endpoint 身份保持不变,报告不得保存 Provider URL、headers、正文、凭据或绝对配置路径。若后续 repair/恢复/终局失败,partial report 仍应保留已经取得的 retry checkpoint
V1.39 真实门禁把一次故障注入到 Project Supervisor 的首次 tool-plan,使退避期强杀发生在子 Agent Provider 请求产生之前;恢复后仍须在同一父 Session/run 完成双专业 Agent 真重叠与唯一 repair。suite 使用 `30s` 退避,必须先观察 sidecar 已落盘且 task/state 均为 `running / waiting-for-provider-retry`,再强杀 Runner;新 boot 接管后 sidecar identity、字节、attempt、slot 与 `retryAt` 不变,重启后和到期前代理请求数都只能为 1。代理的 metadata-only 请求日志只允许保存序号与毫秒时间,第二个请求的 `acceptedAtMs` 必须不早于 `retryAtMs`,且不得包含 URL、method、headers 或正文。forwarding gate 放行前 action、receipt、子委派、claim、assistant、pending、project revision 与 upstream forwarding 全为 0;受控 request identity 必须恰好包含 1 个 failed lifecycle、1 条 retry audit 和 1 个 `-transient-1` 后继 identity。其它真实瞬态失败按 incidental failure/retry 分开计数,每条仍须通过既有 lifecycle、retry audit、唯一后继和终局门禁且两类计数相等;不能把它们混入受控注入链,也不能跳过唯一 Supervisor assistant 和零重复/残留/泄漏要求。首批双专业 Agent 使用正式 collaboration policy 固定为同批两个指定 static delegate,不能只靠提示碰运气;该 suite 已在 Provider 退避边界完成唯一一次 Runner 强杀,后续 repair 只验证持久 pending/确认链,不重复制造第二个 kill 边界
suite 只能读正式 AppData,在其同级目录写入 sentinel 管理的 `0600` 私有副本和 overlay;启动 CLI/Runner 时须把 loopback 合并进大小写两套 no-proxy 环境,防止系统 HTTP 代理绕过本地故障门禁;source-dir guard 必须证明本 suite 前缀未进入源目录,源配置和 endpoint 身份保持不变,报告不得保存 Provider URL、headers、正文、凭据或绝对配置路径。sidecar 先于 task/state 投影是合法提交窗口,验收器应等待完整等待态后再强杀;若后续协作或终局失败,partial report 仍应保留已取得的 retry checkpoint,但失败轮不得与后续成功轮拼接。
### AI 游戏创作自主 Swarm 终端复验
@@ -3363,5 +3363,6 @@
- 现象:Provider 首次规划请求发生 timeout/connectivity/transport 后,Runner 在 backoff 期间退出会丢失 retry,或重启后清零 attempt、提前补发;另一种偶发现象是 Goal pause/resume 看似保留 sidecar,但只要等待跨过一秒,恢复请求就被判为 context drift,旧 `-transient-N` attempt 被删除并改成新 loop 请求。
- 原因:退避 attempt 和到期时间只存在于进程内;或虽然已有 sidecar,请求 prompt 却直接序列化 Runtime 工具策略快照,把每次刷新都会变化的 `updatedAt` 带进 request fingerprint。同一权限内容因此仅因时间变化产生不同请求身份。
- 处理:先闭合物理请求 lifecycle,再原子持久 retry sidecar/.previous,最后投影 `waiting-for-provider-retry` 并释放 lane;Runner 启动扫描并按绝对到期时间恢复。请求指纹只绑定实际 Provider 请求的稳定语义,UI/审计时间戳、剩余等待毫秒和等待态文案不得进入 prompt。Goal pause 保留 sidecarresume 必须校验同一 Goal/steer/request/config 身份后恢复原 attempt。
- 验证:测试必须故意让 pause/resume 跨秒,捕获失败请求与恢复请求的 HTTP body 并比较 SHA-256,同时断言 lifecycle 使用原 `-transient-N` slot而不是新 loop;另覆盖 `.previous` 扫描、Runner idle blocker、同 Agent FIFO、跨 Agent 并行、cancel/steer/耗尽清理和重启未到期零请求。
- 验证:测试必须故意让 pause/resume 跨秒,捕获失败请求与恢复请求的 HTTP body 并比较 SHA-256,同时断言 lifecycle 使用原 `-transient-N` slot而不是新 loop;另覆盖 `.previous` 扫描、Runner idle blocker、同 Agent FIFO、跨 Agent 并行、cancel/steer/耗尽清理和重启未到期零请求。真实网络门禁不能只在 `retryAt` 前留一个静默窗口后等待请求,代理还要用不含 URL/header/body 的毫秒 metadata 证明第二个请求 `acceptedAtMs >= retryAtMs`
- 真实验收陷阱:sidecar 按设计早于 task/state 等待投影落盘,验收器看到 sidecar 后必须继续等完整 `running / waiting-for-provider-retry`,不能把合法提交窗口误判为 torn projection。共享 Runner 强杀会同时中断其它 Agent 的 in-flight Provider 请求;要隔离验证单个持久 retry,应在子请求产生前对父 Agent 首次规划注入故障,恢复后再完成同一 run 的并行协作。首批同批双委派若只依赖自然语言提示会受模型波动影响,真实 suite 应使用正式 collaboration policy/preflight 固定两个指定 static Agent,并保留无正文的 batch 数量诊断。长链路还可能发生额外真实瞬态失败,不能用“全局 failed/retry 必须等于 1”把已正确恢复的网络抖动误判为注入失败;应按 request identity 锁定唯一受控链,额外 failure/retry 独立计数并继续执行全部 lifecycle、后继终态和零残留门禁。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs``agent.rs``runner.rs``tests.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
@@ -1369,9 +1369,11 @@ V1.39 把 V1.28 的显式瞬态重试从单纯进程内退避扩展为可跨 Run
确定性回归必须覆盖:未到期零请求、到期唯一请求、耗尽清理、cancel、steer、Goal pause/resume 跨秒保持正文与 attempt、自动 context-compaction 先恢复再进入 tool-plan、sidecar 先于 task/state 的 torn projection、Runner 重启扫描、同 Agent FIFO、跨 Agent 并行、finalization/idle blocker、`.previous` 恢复/去重/排序和危险路径身份。原有 final-reply/repair 进程内重试回归必须继续通过。
V1.39 当前只完成确定性实现与回归,不把 V1.38 的失败轮、旧瞬态重试真实样本或最小 HTTP 探针拼接成新的真实 Provider PASS。需要真实验收时必须在最终代码上以独立 disposable 项目和隔离 AppData 完整运行,并证明 Runner 在退避期强杀后仍只发送同一 `-transient-N` attempt、其它 Agent 真并行、同 Agent FIFO、唯一最终回复、零重复/残留/正文/密钥/路径泄漏
V1.39 的真实 suite 把受控故障放在 Project Supervisor 首次 tool-plan,此时尚无子 Agent in-flight Provider 请求;否则强杀共享 Runner 会让无可信终态的旁路请求按既有 orphan 规则进入失败关闭,不能用它证明单一 retry sidecar 的恢复。隔离 collaboration policy 要求首批同批产生 `design-director + quality-review` 两个 static delegate,单委派由正式 preflight/协议修复拒绝,不能只依赖提示。故障代理使用 `30s` 退避与 metadata-only 时间日志:只记录请求序号及 `acceptedAtMs / faultInjectedAtMs / heldAtMs / forwardingStartedAtMs`,不记录 URL、method、headers、正文或凭据;退避期强杀后,sidecar 完整身份、字节、attempt、slot 和 `retryAt` 必须保持不变,重启后与到期前请求数均为 1,第二个请求的网络接收时间不得早于 `retryAt`
2026-07-19 最终代码的确定性门禁已通过:`provider_retry_` 19/19、`provider_transient_retry_` 6/6Tauri/Rust 串行全量为 968 passed、4 个环境依赖用例按设计 ignored,`cargo check`、rustfmt、编码与 diff 检查通过。以上结果不替代尚未执行的 V1.39 独立真实 Provider E2E。
2026-07-19 最终代码的确定性门禁已通过:`provider_retry_` 19/19、`provider_transient_retry_` 6/6Tauri/Rust 串行全量为 968 passed、4 个环境依赖用例按设计 ignored,`cargo check`、rustfmt、编码与 diff 检查通过。
同日最终代码的独立 `openai_chat / gpt-5.5 / high` 真实 Provider suite **PASS**,最终样本单轮耗时 `891.1s`。37 个 request identity 全部形成唯一终态:`37 started / 37 terminal / 36 completed / 1 injected failed / 1 retry`incidental Provider failure/retry 均为 0;目标父 Agent 的首次请求在正文转发前断线,retry sidecar 与 task/state 完整进入等待态后执行一次 pidfd Runner 强杀,新 boot 从旧 boot 恢复同一父 Session/run/loop/attempt。强杀前、重启后、到期前代理请求数均为 1,early request 为 0,第二个请求 `acceptedAtMs >= retryAtMs`,后继仅有一个稳定 `-transient-1` identity。放行后完成同批两个指定 static delegate、专业 Provider 真重叠、2 条初始 delivery + 1 条继承原合同的 repair、2 次 Observed claim、宿主验证、5 个父计划步骤、唯一 Supervisor assistant 与 3 条内部专业 assistant;重复 lifecycle/action/receipt/delivery/message 均为 0pending/confirmation/finalization/provider-action-batch/provider-retry sidecar 全为 0Provider 正文、私有正文、API Key、项目路径和正式配置路径泄漏均为 0,代理、Runner、隔离项目与 AppData 全部清理。验收器按 request identity 区分受控注入与真实网络抖动:额外瞬态失败只有逐条通过既有 lifecycle/retry/后继终态门禁并以相等 incidental failure/retry 计数公开时才允许继续,不能混入注入链。此前首批合同不完整、在专业 Agent in-flight 时强杀、sidecar-first 投影窗口误判、单委派和额外已恢复 Provider 抖动样本均只保留为独立失败证据,不与本次 PASS 拼接;V1.38 的真实 E2E 结论仍独立记账。
## 验收命令
@@ -605,4 +605,4 @@ game-project/
- snapshot v1 固定且完整包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`snapshot fingerprint 绑定除 `snapshotFingerprint / boundAt` 外的全部稳定字段。binding v1 固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,必须与 snapshot 逐字段一致。安全 ID 可原样作 key;不安全 Agent/run ID 必须使用有界安全前缀加原始 ID 稳定 SHA-256,锁 key 对完整父 Agent/run 身份计算稳定指纹,禁止 lossy 规范化碰撞。
- 恢复优先级为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policy。snapshot 存在但 binding 缺失时可从 snapshot 补写;binding 存在但 snapshot 丢失时只允许可信 v2 contract 按首次身份恢复,没有可信 v2 contract 时禁止按 live policy 重绑。contractless/v1 collaboration batch 必须先失败关闭,不能伪装 fresh run。`legacy-current-project-policy` 仅允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由 durable 身份和状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 runterminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。
- 已有 durable/未观察 claim 与 legacy claimed delivery 继续按原 action/group 身份恢复,不要求先创建新绑定;新 claim 必须先成功解析 effective snapshot 并核对 binding,再进入 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。snapshot 绑定后 global policy 的 `matched / drifted / unreadable` 只进入有界 status/诊断,不能改变后续执行;新 policy 只由后续新父 run 采用。2026-07-19 self-test、52/52 collaboration 定向回归和 949 passed/4 ignored Rust 全量已完成,终态快照保留也有独立回归;真实 mixed-swarm 功能样本已闭合但受正式 endpoint 外部重启污染,私有配置源的后续独立运行又连续耗尽 transient Provider retry,不能拼接证据,当前仍**不得声称 V1.38 真实 E2E 已 PASS**。详细报告以 Runtime 技术方案 V1.38 节为准。
- 2026-07-19 起,同一 Runtime 文档的“V1.39 首次规划 Provider 瞬态重试持久等待态”作为后台首次规划重试的恢复事实源。tool-plan `repair-0` 及其自动 context-compaction 在瞬态失败后先写 per-Agent/run retry sidecar,再投影 `waiting-for-provider-retry` 并释放 lane;Runner 重启按到期时间恢复同一 Session/run/loop/attempt,同 Agent 后续任务保持 FIFO,其它 Agent 可并行。final-reply、手动压缩和 tool-plan `repair-N` 暂不扩展为持久重试;工具策略刷新时间不得进入请求指纹。当前只记录确定性回归,不改变 V1.38 真实 E2E 尚未 PASS 的结论。
- 2026-07-19 起,同一 Runtime 文档的“V1.39 首次规划 Provider 瞬态重试持久等待态”作为后台首次规划重试的恢复事实源。tool-plan `repair-0` 及其自动 context-compaction 在瞬态失败后先写 per-Agent/run retry sidecar,再投影 `waiting-for-provider-retry` 并释放 lane;Runner 重启按到期时间恢复同一 Session/run/loop/attempt,同 Agent 后续任务保持 FIFO,其它 Agent 可并行。final-reply、手动压缩和 tool-plan `repair-N` 暂不扩展为持久重试;工具策略刷新时间不得进入请求指纹。最终代码已完成独立真实 `gpt-5.5 / openai_chat / high` PASS:父 Agent 首次规划在 `30s` 退避期执行一次 pidfd Runner 强杀,重启后 sidecar 身份/attempt/retryAt 稳定,到期前零早发且第二个请求网络接收时间不早于 retryAt;随后同一父 run 完成双专业 Agent 真重叠、2+1 delivery、唯一 repair、唯一最终回复和零重复/残留/正文/Key/路径泄漏,`37/37` lifecycle 闭合为 `36 completed + 1 injected failed + 1 retry`incidental failure/retry 均为 0,隔离现场完整清理。额外真实瞬态失败按 request identity 单独计数且仍须完整恢复,不能混入受控注入链。该证据不改变 V1.38 真实 E2E 尚未 PASS 的独立结论。