Skip to content

Commit 0965853

Browse files
author
王璨
committed
chore(openspec): sync & archive fix-session-not-found-on-load, fix-session-time-tracking,
harness-event-bus, logging-system Sync delta specs from 4 changes into main spec tree: - harness-event-bus: new event bus architecture — 3 new capabilities (harness-event-bus, ui-backend-slim, logging-system), 7 modified specs - fix-session-not-found-on-load: handleSession fallback + rebuildIndex empty-session fix (session-management) - logging-system: file-based Logger, phase progress logging (chiff-progress-display, eval-progress-feedback) - fix-session-time-tracking: no delta specs Archive: 2026-01-16-{fix-session-not-found-on-load, fix-session-time-tracking, harness-event-bus, logging-system}
1 parent 90d5b3a commit 0965853

36 files changed

Lines changed: 2061 additions & 84 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-16
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## Context
2+
3+
当前架构中,`SessionManager.listSessions()` 按设计过滤 `messageCount === 0` 的 session。`WebUiBackend.pushSessionList()` 在发送 session 列表给客户端时,会通过 `getCurrentMetadata()` 将当前活跃 session 补回列表,即使它的 messageCount 为 0。但 `handleSession``"load"` 分支直接使用 `listSessions()` 查询目标 session,没有相同的 fallback,导致用户无法点击加载一个刚创建的空 session。
4+
5+
此外,`SessionStore.rebuildIndex()` 在重建索引时跳过 `messages.length === 0` 的 session 文件,与 `updateIndex()` 行为不一致,会在索引损坏恢复时丢失合法存在的空 session。
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- 用户点击新建的空 session 时能正常切换(不会报 "Session not found")
11+
- `rebuildIndex``updateIndex` 行为一致,不丢弃合法存在的空 session
12+
13+
**Non-Goals:**
14+
- 不改变 `listSessions()` 的过滤语义(0-msg session 仍不出现于常规列表中)
15+
- 不改变 session 创建、持久化、删除的现有流程
16+
- 不改变前端侧任何行为
17+
18+
## Decisions
19+
20+
### Decision 1: 在 `handleSession` load 分支添加 fallback,而非修改 `listSessions()`
21+
22+
**选择**: 在 `WebUiBackend.handleSession``"load"` case 中,当 `listSessions()` 找不到匹配项时,fallback 检查 `getCurrentMetadata()`
23+
24+
**理由**:
25+
- `listSessions()` 过滤 0-msg 是 spec 明确定义的行为,广泛用于侧边栏列表渲染等场景,不应改动
26+
-`pushSessionList` 已有的补救逻辑保持一致,模式统一
27+
- 改动最小,仅影响一个 handler 分支
28+
29+
**备选方案**: 修改 `listSessions()` 不过滤 0-msg,改为在 UI 层过滤 → 影响面大,需要追踪所有 `listSessions()` 调用点并确认每个调用点的预期行为。
30+
31+
### Decision 2: 移除 `rebuildIndex` 中的 empty-messages 跳过逻辑
32+
33+
**选择**: `rebuildIndex` 不应跳过 `messages.length === 0` 的 session 文件,仅跳过解析失败的损坏文件。
34+
35+
**理由**:
36+
- `updateIndex`(通过 `save()``updateIndex()`)会正常写入 0-msg session,它们在磁盘上合法存在
37+
- 如果 index.json 损坏触发 `rebuildIndex`,不应丢失这些 session
38+
- 过滤逻辑应由 `SessionManager.listSessions()` 在应用层处理,而非索引层
39+
40+
## Risks / Trade-offs
41+
42+
- **[低风险] 索引包含更多条目**: 0-msg session 出现在索引中,但 `listSessions()` 仍会过滤它们,实际影响仅体现在 `rebuildIndex` 的场景中,该场景本身很少触发
43+
- **[无风险] Fallback 逻辑**: 仅当 `listSessions()` 匹配数为 0 时触发,且仅匹配当前活跃 session。若同时满足 "当前 session 不在列表中" 和 "请求 id 不匹配当前 session",则仍返回 "not found",行为正确
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
## Why
2+
3+
新建 session 后(messageCount=0),点击左侧 session 列表切换回该 session 时,服务端 `handleSession``load` 分支直接使用 `listSessions()` 查询 session,而该方法按设计过滤掉 `messageCount===0` 的 session。`pushSessionList` 已有将当前 session 补回列表的补救逻辑,但 `load` 分支缺少相同处理,导致返回 "Session not found" 错误。
4+
5+
## What Changes
6+
7+
-`WebUiBackend.handleSession``"load"` 分支中,当 `listSessions()` 找不到匹配 session 时,增加 fallback:检查请求的 id 是否匹配当前活跃 session(`getCurrentMetadata()`),若匹配则直接使用当前 session 的 metadata 继续加载流程。
8+
-`SessionStore.rebuildIndex` 中,移除对 `messages.length === 0` 的过滤,保持索引与实际磁盘文件一致(0-msg session 既然被 persistEmptySession 写入磁盘,就应在索引中可见)。
9+
10+
## Capabilities
11+
12+
### New Capabilities
13+
<!-- No new capabilities — this is a bug fix on existing behavior. -->
14+
15+
### Modified Capabilities
16+
- `session-management`: `handleSession` load 分支的行为现在与 `pushSessionList` 一致——当 `listSessions()` 因 messageCount=0 过滤掉当前 session 时,fallback 到当前 session metadata。`rebuildIndex` 不再跳过 messages 为空的 session 文件,保持索引完整性。
17+
18+
## Impact
19+
20+
- `src/ui/web/web-backend.ts``handleSession` "load" case,增加 fallback 逻辑
21+
- `src/session/store.ts``rebuildIndex`,移除 empty-messages 跳过逻辑
22+
- `src/session/manager.ts``listSessions()` 行为不变(spec 已明确其过滤语义)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
## ADDED Requirements
2+
3+
### Requirement: handleSession load fallback to current session
4+
5+
The `WebUiBackend.handleSession` `"load"` handler SHALL, when `listSessions()` returns zero matches for the requested session ID, check whether the request ID matches the current active session via `sessionManager.getCurrentMetadata()`. If the current session metadata exists and its `id` starts with the requested ID prefix, the handler SHALL proceed with loading the current session instead of returning a "Session not found" error.
6+
7+
This ensures consistency with `pushSessionList()`, which already includes the current session in the client-facing list even when its `messageCount` is 0.
8+
9+
#### Scenario: Load current empty session via sidebar click
10+
11+
- **WHEN** the client sends `{ type: "session", action: "load", id: "<currentSessionId>" }` and the current session has `messageCount === 0` (thus excluded from `listSessions()` output)
12+
- **THEN** the handler finds no match in `listSessions()` but detects that `getCurrentMetadata()?.id` starts with the requested ID
13+
- **AND** proceeds with the normal load flow (abort → save → loadSession → clear_conversation → ready)
14+
- **AND** does NOT return "Session not found" error
15+
16+
#### Scenario: Load non-existent session still returns error
17+
18+
- **WHEN** the client sends `{ type: "session", action: "load", id: "NONEXIST" }` and no session with that ID exists (neither in `listSessions()` nor as current session)
19+
- **THEN** the handler returns `{ type: "error", text: "Session not found: NONEXIST" }`
20+
21+
#### Scenario: Ambiguous prefix match still returns error
22+
23+
- **WHEN** the client sends `{ type: "session", action: "load", id: "00" }` and `listSessions()` matches more than one session with that prefix
24+
- **THEN** the handler returns `{ type: "error", text: "Ambiguous session ID prefix. ..." }` without checking the current session fallback
25+
26+
### Requirement: rebuildIndex preserves empty-message sessions
27+
28+
`SessionStore.rebuildIndex()` SHALL include session files with `messages.length === 0` in the rebuilt index, as long as the file contains valid `metadata` with a valid `id` field. Only files that fail to parse as valid JSON or lack valid `metadata.id` SHALL be skipped.
29+
30+
The existing fix-up for `messageCount === 0 && messages.length > 0` SHALL remain unchanged.
31+
32+
#### Scenario: Empty session preserved in rebuilt index
33+
34+
- **WHEN** `rebuildIndex()` scans a directory containing a valid session file with `messages: []` and valid metadata
35+
- **THEN** that session SHALL appear in the rebuilt index with its original metadata
36+
37+
#### Scenario: Corrupted session file still skipped
38+
39+
- **WHEN** `rebuildIndex()` scans a directory containing a file with invalid JSON or missing `metadata.id`
40+
- **THEN** that file SHALL be silently skipped
41+
42+
## MODIFIED Requirements
43+
44+
### Requirement: Current session preserved in list when empty
45+
46+
- **WHEN** the current active session has `messageCount === 0`
47+
- **THEN** that session is excluded from `listSessions()` output; the `pushSessionList` server method adds it back when sending to the client, using `currentSessionId` to identify it; the `handleSession` `"load"` handler also falls back to `getCurrentMetadata()` when `listSessions()` returns no matches
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
## 1. Fix `handleSession` load handler
2+
3+
- [x] 1.1 In `src/ui/web/web-backend.ts`, in `handleSession` `"load"` case, after line `if (matches.length === 0)` and before returning "Session not found" error, add fallback: check if `sessionManager.getCurrentMetadata()?.id.startsWith(cmd.id!)`. If so, set `match` to the current metadata and proceed with load flow.
4+
- [x] 1.2 Verify via unit or manual test: create a new session, click it in sidebar — should load successfully without "Session not found" error.
5+
6+
## 2. Fix `rebuildIndex` empty-message filtering
7+
8+
- [x] 2.1 In `src/session/store.ts` `rebuildIndex()`, remove the condition `Array.isArray(raw.messages) && raw.messages.length > 0` that guards pushing to `entries`. Push as long as `raw?.metadata && typeof raw.metadata.id === "string"`.
9+
- [x] 2.2 Verify via unit or manual test: delete `index.json`, restart — 0-message sessions should still appear in the index (though filtered from the sidebar by `listSessions()`).
10+
11+
## 3. Validation
12+
13+
- [x] 3.1 Run `npm test` to ensure existing tests pass.
14+
- [x] 3.2 Run `npm run typecheck` to ensure no type errors.
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
## 设计
2+
3+
### 现状问题
4+
5+
```
6+
当前流程 (有 bug):
7+
8+
createSession / loadSession
9+
10+
│ activeSince = Date.now() ← 从此刻开始计时
11+
12+
13+
[用户阅读历史, 思考, 打字...] ← 空闲时间也被计入!
14+
15+
16+
agent.prompt() → agent_start → ... processing ... → agent_end
17+
18+
19+
saveSession:
20+
accumulatedMs += Date.now() - activeSince ← 包含了空闲时间
21+
```
22+
23+
### 修复后流程(后端 — 已完成)
24+
25+
```
26+
修复后:
27+
28+
createSession / loadSession
29+
30+
│ activeSince = null ← 不自动启动
31+
32+
33+
[用户阅读历史, 思考, 打字...] ← 不计入
34+
35+
36+
agent_start
37+
38+
│ activeSince = Date.now() ← 仅此时启动
39+
40+
41+
[... agent 处理中 ...]
42+
43+
44+
agent_end
45+
46+
│ accumulatedMs += Date.now() - activeSince
47+
│ activeSince = null ← 暂停
48+
49+
50+
saveSession:
51+
if (activeSince !== null) { ... } ← null 时不累积
52+
totalActiveMs = accumulatedMs
53+
```
54+
55+
### SessionManager 新增 API
56+
57+
```typescript
58+
// 启动计时(agent 开始处理时调用)
59+
startActiveTimer(): void {
60+
if (this.activeSince === null) {
61+
this.activeSince = Date.now();
62+
}
63+
}
64+
65+
// 暂停计时并累积(agent 结束处理时调用)
66+
stopActiveTimer(): void {
67+
if (this.activeSince !== null) {
68+
this.accumulatedMs += Date.now() - this.activeSince;
69+
this.activeSince = null;
70+
if (this.current) {
71+
this.current.totalActiveMs = this.accumulatedMs;
72+
}
73+
}
74+
}
75+
```
76+
77+
### Harness 接入点
78+
79+
`src/core/harness.ts` 的 agent 事件订阅中:
80+
81+
```typescript
82+
// agent_start (约 line 1095):
83+
if (event.type === "agent_start") {
84+
this.sessionManager.startActiveTimer();
85+
this.ui.startAssistantMessage();
86+
}
87+
88+
// agent_end (约 line 1089):
89+
if (event.type === "agent_end") {
90+
this.sessionManager.stopActiveTimer();
91+
this.ui.setProcessing(false);
92+
this.sessionManager.trySaveSession(this.agent);
93+
}
94+
```
95+
96+
### SessionManager 现有改动
97+
98+
1. `createSession()`: 移除 `this.activeSince = Date.now()`(两处:line 97 附近和 line 115 附近)
99+
2. `loadSession()`: 移除 `this.activeSince = Date.now()`(line 239),保留 `this.accumulatedMs = ...`
100+
101+
---
102+
103+
### 前端修复:方案 B — 后端推送统一时钟源
104+
105+
#### 问题分析
106+
107+
```
108+
当前前端数据流:
109+
110+
backend: sessions 事件(仅 agent_end 后发一次)
111+
112+
113+
App.tsx: sessionActiveMs = sessions.find(...).totalActiveMs ← 冻结值
114+
│ │
115+
▼ ▼
116+
Sidebar ChatView
117+
formatDuration( sessionTime/1000 + elapsed
118+
s.totalActiveMs) ↑ ↑
119+
│ │
120+
冻结,不走表 rAF 每帧+1,走表
121+
but double-count!
122+
123+
结果:
124+
- Sidebar: frozen (processing 期间不走)
125+
- ChatView: ≈ 2x real time (double-count)
126+
- 差距 = processing 时长,线性扩大
127+
```
128+
129+
#### 修复架构
130+
131+
```
132+
修复后前端数据流:
133+
134+
backend: 1s interval ──► session_time { totalActiveMs } ──► agent_end 时停止
135+
136+
137+
App.tsx: 更新 sessions state 中当前 session 的 totalActiveMs
138+
│ │
139+
▼ ▼
140+
Sidebar ChatView
141+
formatDuration( formatTime(
142+
s.totalActiveMs) sessionTime / 1000)
143+
│ │
144+
└──────── 同一个值 ─────────────┘
145+
永远一致,实时走表
146+
```
147+
148+
#### Protocol 新增
149+
150+
```typescript
151+
// src/ui/shared/types.ts ServerEvent union 新增:
152+
| { type: "session_time"; totalActiveMs: number }
153+
```
154+
155+
#### WebUiBackend 改动
156+
157+
```typescript
158+
// WebUiBackend 新增字段
159+
private sessionTimeInterval: ReturnType<typeof setInterval> | null = null;
160+
161+
startAssistantMessage(): void {
162+
this.currentAssistant = { thinking: "", text: "", tools: [] };
163+
this.broadcast({ type: "assistant_start" });
164+
// ★ 启动 session_time 定时广播
165+
this.startSessionTimeBroadcast();
166+
}
167+
168+
finishAssistantMessage(): void {
169+
// ★ 先发最后一次,再停止
170+
this.broadcastSessionTime();
171+
this.stopSessionTimeBroadcast();
172+
this.currentAssistant = null;
173+
this.broadcast({ type: "assistant_end" });
174+
// ... 现有 sessions 广播 ...
175+
}
176+
177+
private startSessionTimeBroadcast(): void {
178+
if (this.sessionTimeInterval) return;
179+
this.sessionTimeInterval = setInterval(() => {
180+
const sm = this.harness.sessionManager;
181+
if (!sm) return;
182+
this.wsServer.broadcast({
183+
type: "session_time",
184+
totalActiveMs: sm.getTotalActiveMs(),
185+
});
186+
}, 1000);
187+
}
188+
189+
private stopSessionTimeBroadcast(): void {
190+
if (this.sessionTimeInterval) {
191+
clearInterval(this.sessionTimeInterval);
192+
this.sessionTimeInterval = null;
193+
}
194+
}
195+
196+
private broadcastSessionTime(): void {
197+
const sm = this.harness.sessionManager;
198+
if (!sm) return;
199+
this.wsServer.broadcast({
200+
type: "session_time",
201+
totalActiveMs: sm.getTotalActiveMs(),
202+
});
203+
}
204+
```
205+
206+
#### App.tsx 改动
207+
208+
```typescript
209+
case "session_time":
210+
setSessions((prev) => prev.map((s) =>
211+
s.id === currentSessionId
212+
? { ...s, totalActiveMs: event.totalActiveMs }
213+
: s
214+
));
215+
break;
216+
```
217+
218+
#### ChatView 改动
219+
220+
```typescript
221+
// 删除 elapsed state 和 rAF effect:
222+
// ❌ const [elapsed, setElapsed] = useState(0);
223+
// ❌ useEffect(() => { ... rAF tick ... }, [turnStartRef]);
224+
225+
// WaitingBubble: 去掉 + elapsed
226+
// 旧: ({formatTime(Math.floor(sessionTime / 1000) + elapsed)})
227+
// 新: ({formatTime(Math.floor(sessionTime / 1000))})
228+
229+
// ThinkingBlock: 同上
230+
// 旧: `Thinking... (${formatTime(Math.floor(sessionTime / 1000) + elapsed)})`
231+
// 新: `Thinking... (${formatTime(Math.floor(sessionTime / 1000))})`
232+
233+
// turnStartRef prop 可保留但不再在 ChatView 内使用
234+
// (仍由 App.tsx 管理,供 future use 或其他组件)
235+
```
236+
237+
### 边界情况处理
238+
239+
| 场景 | 行为 |
240+
|------|------|
241+
| 加载 session 后直接退出 | `activeSince = null`,不累积,totalActiveMs 不变 |
242+
| agent 处理中被 abort | `agent_end` 正常触发 → `stopActiveTimer()` 累积已处理时间;interval 在 `finishAssistantMessage` 中停止 |
243+
| 连续多个 turn | 每个 turn 的 `agent_start`/`agent_end` 独立计时;interval 随 turn 启停 |
244+
| `saveSession``activeSince = null` 时被调用 | 跳过 accumulation,仅保存当前 accumulatedMs |
245+
| `getTotalActiveMs()``activeSince = null` 时被调用 | 返回 accumulatedMs(不含 live 部分) |
246+
| 已有 session 的 totalActiveMs 包含历史误差 | 不回溯修正,新逻辑从加载后开始生效 |
247+
| WebSocket 断开期间 | interval 继续在后端运行但 broadcast 无接收者;重连后 `pushSessionList` 发送最新值 |
248+
| session_time 事件乱序到达 | 后端按序发送,前端幂等处理(总是更新当前 session) |

0 commit comments

Comments
 (0)