diff --git a/docs/features/F117-message-delivery-lifecycle.md b/docs/features/F117-message-delivery-lifecycle.md index 379fb19af1..222a37b3ec 100644 --- a/docs/features/F117-message-delivery-lifecycle.md +++ b/docs/features/F117-message-delivery-lifecycle.md @@ -1101,6 +1101,14 @@ stop hook 从这里注入即覆盖全部。和设计的出入与补充: 空闲超时,客户端的 `timeoutMs` 选项已无调用方,留待后续清理。 - **#774**:重试条件收窄为「未收到首帧」的启动超时(`isCliStartupTimeoutError`),保留 tmux 首事件看门狗在 resume 时的重试;成员开始输出之后的静默不再被吞掉重跑。 +- **alpha 自测补丁(09-27)**:在 alpha(develop_base `900d87835`,`CLI_TIMEOUT_MS=60000`)里让 codex-sol 执行 + `sleep 150`:它在距最后一次输出 120 秒时被停下,R 为 failed / `timeout`,诊断 `silenceDurationMs=120001`,之后同一只猫 + 照常应答。发现并修了一处:超时成员原本在串行时没有 done、在并行时 done 不带 errorCode,QueueProcessor 只能走抛异常的 + 兜底路径,于是对话底部多出一行「Error: 响应超时…」,还会推送「猫猫出错了」。现在超时成员以一个 errorCode 为 `timeout` 的 + done 收尾,和 provider 失败的 done 带 `PROVIDER_EXECUTION_FAILED` 一样,由 QueueProcessor 直接记为失败。 +- **CLI 接入实际按 2× 顺延**:同一次自测里,Codex CLI 空等时每 60 秒约有 400ms CPU,超过存活探针 50ms / 60s 的「忙」 + 门槛,所以 CLI 接入的成员实际要静默 2×`CLI_TIMEOUT_MS` 才会停。这是 F118 原有的门槛,旧的 cli-spawn 计时器也按它顺延; + 这次不改,若要让顺延只给真正在算的进程,另议门槛。 ### Phase K(路线 Phase 2b:Claude Agent SDK 接入,2026-09-25) diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 5494fa6065..51c9ce123e 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -89,7 +89,11 @@ import { } from '../invocation/invocation-capacity-snapshot.js'; import { type InvocationParams, invokeSingleCat } from '../invocation/invoke-single-cat.js'; import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js'; -import { type MemberTimeoutEvent, memberTimeoutErrorText } from '../invocation/member-output-timeout.js'; +import { + MEMBER_TIMEOUT_REASON, + type MemberTimeoutEvent, + memberTimeoutErrorText, +} from '../invocation/member-output-timeout.js'; import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js'; import { recordTurnOutputVerdict, requireTurnOutputAllowed } from '../invocation/response-draft-settlement.js'; import { resolveManagedSessionPolicySnapshot } from '../invocation/session-policy-snapshot.js'; @@ -2449,7 +2453,9 @@ export async function* routeParallel( // identity / liveness wrongly attached to parent (instead of own turn). const stampedDone = ownInvId && !msg.invocationId ? { ...msg, invocationId: ownInvId } : msg; // F117 KD-22: the response is committed; report the timeout before the done, so the Queue - // counts this member failed (not cancelled) and releases its slot. + // counts this member failed (not cancelled) and releases its slot. The done names the + // timeout, as a failed provider turn's done names its failure, so the Queue settles the + // entry failed from it rather than throwing and broadcasting a second error. if (memberTimeout) { yield { type: 'error' as const, @@ -2463,6 +2469,7 @@ export async function* routeParallel( yield projectLiveTurnExecution( { ...stampedDone, + ...(memberTimeout && stampedDone.errorCode === undefined ? { errorCode: MEMBER_TIMEOUT_REASON } : {}), ...(persistedDoneContent !== undefined ? { content: persistedDoneContent } : {}), ...(turnStoredMessageId ? { messageId: turnStoredMessageId } : {}), isFinal, diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 3f079fa1dd..d257100a15 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -2284,7 +2284,9 @@ export async function* routeSerial( // F117 KD-22: a member stopped by its output timeout failed, like a provider failure. The // events it produced after the stop were dropped above, so its failure text and diagnostics - // come from what its timer kept before stopping it. + // come from what its timer kept before stopping it, and it ends with a done that names the + // timeout, as a failed provider turn's done names its failure: the Queue settles the entry + // failed from that done rather than throwing and broadcasting a second error. if (stoppedByMemberTimeout(catSignal) && memberTimeout) { hadError = true; hadProviderError = true; @@ -2293,6 +2295,7 @@ export async function* routeSerial( ...(persistedMetadata ?? { provider: '', model: '' }), timeoutDiagnostics: memberTimeout.diagnostics, }; + doneMsg ??= { type: 'done', catId, errorCode: MEMBER_TIMEOUT_REASON, timestamp: Date.now() }; } // F167 Phase S: this is the single route-side visibility barrier. The diff --git a/packages/api/test/1398-phase4-member-timeout-stop.test.js b/packages/api/test/1398-phase4-member-timeout-stop.test.js index ab50cd25ef..b12410e831 100644 --- a/packages/api/test/1398-phase4-member-timeout-stop.test.js +++ b/packages/api/test/1398-phase4-member-timeout-stop.test.js @@ -146,6 +146,13 @@ function assertTimedOutMember(result, catId) { assert.ok(failure, `${catId}'s route reports the timeout`); assert.match(failure.error, /响应超时/); assert.ok(failure.metadata.timeoutDiagnostics.silenceDurationMs >= 1000, 'diagnostics kept from before the stop'); + // Like a provider failure, the member ends with a done that names why, after its failure: the + // Queue settles the entry failed from it instead of throwing and broadcasting a second error row. + const events = result.events.filter((event) => event.catId === catId); + const done = events.findLast((event) => event.type === 'done'); + assert.ok(done, `${catId}'s route ends it with a done`); + assert.equal(done.errorCode, MEMBER_TIMEOUT_REASON, 'the done names the timeout'); + assert.ok(events.indexOf(failure) < events.indexOf(done), 'the failure is reported before the done'); const terminal = result.terminalOf(catId); assert.equal(terminal.status, 'failed'); assert.equal(terminal.terminalReason, MEMBER_TIMEOUT_REASON);