Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ COLLECTIVE_GITHUB_CLIENT_SECRET=
# Default Service opened by the Clowder "Enter Collective" launch surface.
NEXT_PUBLIC_COLLECTIVE_SERVICE_URL=http://localhost:5201

# CLI inactivity timeout in milliseconds.
# Default is 1800000 = 30 minutes; set to 0 to disable the CLI-layer timeout.
# CLI_TIMEOUT_MS=1800000
# CLI inactivity timeout in milliseconds: a member with no output for this long is stopped.
# Default is 0 = no timeout (only a manual stop ends a silent turn). Example: 3600000 = 60 minutes.
# CLI_TIMEOUT_MS=3600000

# ── Workspace Access 工作目录 ───────────────────────────────
# Comma-separated list of directories the MCP server is allowed to access.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@ The `.env` file supports key variables as a legacy fallback, but the UI approach
|---------------------------|------------------|-------------|
| `NEXT_PUBLIC_API_URL` | *(auto-derived)* | API URL used by the frontend. Normally computed automatically from `API_SERVER_PORT`; override only if you use a reverse proxy or custom domain |
| `NEXT_PUBLIC_BRAND_NAME` | `Clowder AI` | Brand name shown in the UI (界面显示的品牌名称) |
| `CLI_TIMEOUT_MS` | `1800000` | CLI inactivity timeout in milliseconds (30 minutes). Set to `0` to disable (CLI 不活跃超时,0 为禁用) |
| `CLI_TIMEOUT_MS` | `0` | Member no-output timeout in milliseconds: a member with no output (text, tool calls, thinking) for this long is stopped as if by Stop, and its reply fails as timed out; a member whose process is still using CPU gets at most twice as long. `0` (default) disables it, so only a manual stop ends a silent turn (成员无输出超时,默认 0 即不超时) |
2 changes: 1 addition & 1 deletion docs/configuration/environment.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@ API 密钥最好在启动后通过 UI 管理:
|---------------------------|------------------|-------------|
| `NEXT_PUBLIC_API_URL` | *(自动推导)* | 前端使用的 API URL。通常会根据 `API_SERVER_PORT` 自动计算;仅在使用反向代理或自定义域名时才需覆盖 |
| `NEXT_PUBLIC_BRAND_NAME` | `Clowder AI` | UI 中显示的品牌名称 |
| `CLI_TIMEOUT_MS` | `1800000` | CLI 不活跃超时,单位毫秒(30 分钟)。设为 `0` 可禁用 |
| `CLI_TIMEOUT_MS` | `0` | 成员无输出超时,单位毫秒:一个成员这么久没有任何输出(文字、工具调用、思考)就按「停止」停下,回复记为超时失败;进程仍在占用 CPU 时最多顺延到 2 倍。默认 `0`,即不超时,静默的一轮只能手动停止 |
25 changes: 25 additions & 0 deletions docs/features/F117-message-delivery-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,31 @@ Antigravity、PTY 五个 carrier;而且在默认 `CLI_TIMEOUT_MS=0` 下,这
都没有时不算正在处理。快照不完整时,没有任何证据的 running 记录也保守列出,保持 AC-E7 的契约。复审建议的「待对账」与 KD-10 / AC-E7 冲突,没有采用:无法核实时保守地显示为运行中,
由停止(AC-E7)或下一次完整快照时的 read-repair 收尾(见 J3「为什么不单列『待对账』」)。这一取舍待砚砚复审确认。

#### J4 实现(路线 Phase 4,2026-09-27,基于 develop_base `5bd5e008e`)

按上文 J4 落地,代码锚点对照过:外层 2× 定时器、cli-spawn 的无输出定时器、#774、Codex app-server 空闲中断、
tmux 空闲、AGY `--print-timeout` 都还在设计写的位置;生产派发只有 QueueProcessor → `routeExecution` 一个入口,
stop hook 从这里注入即覆盖全部。和设计的出入与补充:

- **定时器**:`MemberOutputTimeout`(`member-output-timeout.ts`)在 invoke-single-cat 启动,只有 route 传入
`onMemberTimeout` 时才布防(生产都经 QueueProcessor 的 `stopMember`)。顺延看进程是否仍在占 CPU
(`ProcessLivenessProbe.activity()`,按两次采样的 CPU 增长,与最近是否有 stdout 无关),由 cli-spawn 按 invocation
登记(`process-activity-registry.ts`)。没有登记进程的接入不顺延,其中包括 **SDK 接入**:它的引擎子进程由 SDK
自己拉起,拿不到进程号。以前 SDK 接入只受外层 2× 定时器约束(3001 上是 60 分钟),现在是 `CLI_TIMEOUT_MS`
(30 分钟)无输出就停;Claude Code 的 Bash 工具本身 10 分钟封顶,更长的命令本来就该交给托管命令。
- **触发 = Stop**:定时器触发时先把诊断交给 route(abort 之后的事件会被 route 丢弃),再由 QueueProcessor 的
`createMemberTimeoutStop` 同步比对 `getExecutionId === executionId`(父执行 ID)后 `cancel(…, 'timeout')`。
- **结局**:R 的终态规则收成一处 `resolveResponseTerminal`(原来 route-serial 两份、route-parallel 一份),`timeout`
→ failed / `timeout`,R 带 `timeoutDiagnostics` 和失败文字。R 提交后 route 补发一条带诊断的 error 事件,
QueueProcessor 因此把该成员记为失败:`resolveFinalStatus` 与 disposition 的 `isCanceled` 都不把 timeout 墓碑
算作用户取消。TurnExecution failed / `timeout`,路由信号 `timeout` → `provider_timeout`;旧记录里的
`invocation_timeout` 仍按 `provider_timeout` 读。
- **删除范围**:cli-spawn 与 tmux 的超时不再默认读 `CLI_TIMEOUT_MS`,只给显式传 `timeoutMs` 的调用(例如 opencode
自动审批探测);成员派发从不传。AGY 不再传 `--print-timeout`(其默认 0,等整轮结束)。Codex app-server 不再传
空闲超时,客户端的 `timeoutMs` 选项已无调用方,留待后续清理。
- **#774**:重试条件收窄为「未收到首帧」的启动超时(`isCliStartupTimeoutError`),保留 tmux 首事件看门狗在 resume
时的重试;成员开始输出之后的静默不再被吞掉重跑。


### Phase K(路线 Phase 2b:Claude Agent SDK 接入,2026-09-25)

Expand Down
2 changes: 1 addition & 1 deletion docs/features/F254-side-effect-freshness-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ F233 projector 可选读取此流做聚合报告(通过 `FreshnessAttentionEve

**(b) Per-Invocation Operational State**(hot path / 决策):

Redis-backed per-invocation counters(TTL = invocation timeout,如 30min,自动清理):
Redis-backed per-invocation counters(TTL 30min,自动清理;与 `CLI_TIMEOUT_MS` 无关,后者默认 0 即不超时):

```typescript
// key: `freshness:state:{invocationId}`
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/config/env-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ export const ENV_VARS: EnvDefinition[] = [
{
name: 'CLI_TIMEOUT_MS',
defaultValue: DEFAULT_CLI_TIMEOUT_LABEL,
description: 'CLI 自动终止超时(0 = 关闭,仅人工取消)',
description: '成员无输出超时:到时按停止处理,回复记为超时失败(占用 CPU 时最多 2 倍;0 = 关闭,仅人工停止)',
category: 'cli',
sensitive: false,
runtimeEditable: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import type { LifecycleActiveRun } from '@cat-cafe/shared';
import { createModuleLogger } from '../../../../../infrastructure/logger.js';
import type { AgentClientActiveRunDispatcher } from '../../types.js';
import { MEMBER_TIMEOUT_REASON } from './member-output-timeout.js';

const log = createModuleLogger('invocation-tracker');
export const DEFAULT_INVOCATION_SLOT_TTL_MS = 75 * 60_000;
Expand Down Expand Up @@ -687,6 +688,12 @@ export class InvocationTracker {
return inv.state;
}

/** F117 KD-22: a member stopped by its output timeout failed; nobody cancelled it. */
isTimedOut(threadId: string, catId: string): boolean {
const inv = this.active.get(this.slotKey(threadId, catId));
return inv?.state === 'canceled' && inv.cancelReason === MEMBER_TIMEOUT_REASON;
}

/** Exact canceled-slot fence for read repair; slot state alone is too broad. */
getCanceledSlotIdentity(threadId: string, catId: string): { executionId: string; userId: string } | undefined {
const inv = this.active.get(this.slotKey(threadId, catId));
Expand All @@ -700,7 +707,8 @@ export class InvocationTracker {
* - whole-invocation abort (batch gate aborted: cancelAll / force / thread-delete / preempt)
* → 'canceled_by_user' (user_cancel/cancel_all reason) or 'canceled' (other reasons)
* - else if EVERY target cat is a canceled tombstone → 'canceled_by_user' (cancelled cat-by-cat)
* - else → 'succeeded' (at least one cat ran to completion)
* - else → 'succeeded': not cancelled, so each cat's own outcome decides. A cat stopped by its
* output timeout (F117 KD-22) failed — it is not a cancellation, so it never counts here.
* `controller.signal.aborted` alone now means ONLY whole-invocation abort — a single-cat cancel
* no longer aborts the batch gate, so callers must use this aggregate rather than raw `.aborted`.
*/
Expand All @@ -713,7 +721,9 @@ export class InvocationTracker {
return batch.reason === 'user_cancel' || batch.reason === 'cancel_all' ? 'canceled_by_user' : 'canceled';
}
if (targetCats.length === 0) return 'succeeded';
const allCanceled = targetCats.every((c) => this.getSlotState(threadId, c) === 'canceled');
const allCanceled = targetCats.every(
(c) => this.getSlotState(threadId, c) === 'canceled' && !this.isTimedOut(threadId, c),
);
return allCanceled ? 'canceled_by_user' : 'succeeded';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import {
} from './InvocationTracker.js';
import { projectLifecycleAppendAction } from './lifecycle-append-projection.js';
import { emitLifecycleMessageUpdated } from './lifecycle-message-update.js';
import { createMemberTimeoutStop } from './member-output-timeout.js';
import { requireOwnerAuthProvenance } from './owner-auth-provenance.js';
import {
isTerminalDispositionEvent,
Expand Down Expand Up @@ -180,6 +181,10 @@ interface TrackerLike {
): boolean;
has(threadId: string, catId?: string): boolean;
cancelInvocation(threadId: string, catIds: string[], userId?: string, reason?: string): unknown;
/** F117 KD-22: stop one member the way Stop does (the member timeout passes reason `timeout`). */
cancel?(threadId: string, catId: string, requestUserId?: string, abortReason?: string): { cancelled: boolean };
/** F117 KD-22: the member was stopped by its output timeout — a failure, not a cancellation. */
isTimedOut?(threadId: string, catId: string): boolean;
getUserId?(threadId: string, catId: string): string | null;
getExecutionId?(threadId: string, catId: string): string | undefined;
/** F-parallel-cancel: expose a slot's own controller for per-cat cancel isolation. */
Expand Down Expand Up @@ -3425,7 +3430,10 @@ export class QueueProcessor {
let lifecycleClaimRestorePromise: Promise<boolean> | undefined;
const terminalDispositions = new PerCatTerminalDispositionCollector({
targetCatIds: targetCats,
isCanceled: (catId) => invocationTracker.getSlotState?.(threadId, catId) === 'canceled',
// A member stopped by its output timeout failed (F117 KD-22); its route reports the failure.
isCanceled: (catId) =>
invocationTracker.getSlotState?.(threadId, catId) === 'canceled' &&
!invocationTracker.isTimedOut?.(threadId, catId),
});
let responseText = '';
// F122B B6: completion hooks are registered before drain; keep their
Expand Down Expand Up @@ -4155,6 +4163,22 @@ export class QueueProcessor {
// abort (cancelAll / force / thread-delete), never on single-cat cancel — the sibling
// keeps streaming. (See InvocationTracker.startAll returning a fresh batchController.)
signalForCat: (catId: string) => invocationTracker.getController?.(threadId, catId)?.signal,
// F117 KD-22 (J4): a member's own invocation owns its output timeout; when it fires, the
// Queue stops only that member, and only while its slot still runs this execution.
...(invocationTracker.getExecutionId && invocationTracker.cancel
? {
stopMember: createMemberTimeoutStop({
invocationTracker: {
getExecutionId: (tid, catId) => invocationTracker.getExecutionId?.(tid, catId),
cancel: (tid, catId, requestUserId, abortReason) =>
invocationTracker.cancel?.(tid, catId, requestUserId, abortReason) ?? { cancelled: false },
},
threadId,
ownerUserId: userId,
log,
}),
}
: {}),
getQueuedFreshnessMessagesForCat: (tid: string, uid: string, catId: string, parentInvocationId?: string) =>
queue.getQueuedFreshnessMessagesForCat(tid, uid, catId, { excludeEntryId: entry.id, parentInvocationId }),
commitCompletedA2AWake: (input: Parameters<NonNullable<RouteOptions['commitCompletedA2AWake']>>[0]) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ export function isCliTimeoutError(message: string | undefined): boolean {
return /CLI (?:响应超时|idle-silent 超时)/i.test(message);
}

/**
* A CLI timeout before its first event (a startup watchdog such as tmux's): the member never came
* up. Silence after it started is the member output timeout's (F117 KD-22), which stops the member
* instead of surfacing here.
*/
export function isCliStartupTimeoutError(message: string | undefined): boolean {
return isCliTimeoutError(message) && /未收到首帧/.test(message ?? '');
}

/** F215: Detect malformed tool-call error emitted by ClaudeAgentService (form A / B).
* Used in invoke-single-cat to trigger seal+fresh-context+46接力 fallback chain. */
export function isMalformedToolCallError(message: string | undefined): boolean {
Expand Down
Loading