magic-agent 1.0.49 → 1.0.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2443 -178
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -70,11 +70,50 @@ func TestHandleMarkDoesNotTouchTerminalState(t *testing.T) {
|
|
|
70
70
|
func TestHandleMarkRejectsOtherStates(t *testing.T) {
|
|
71
71
|
t.Setenv(EnvDir, t.TempDir())
|
|
72
72
|
h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
73
|
-
h.Mark(StateFailed) //
|
|
73
|
+
h.Mark(StateFailed) // 不在允许的活状态里
|
|
74
74
|
h.Mark(StateDone)
|
|
75
75
|
h.Mark("")
|
|
76
76
|
if got := h.Record().State; got != StateRunning {
|
|
77
|
-
t.Errorf("Mark 不该把它改成 %q(只允许 running / waiting_approval)", got)
|
|
77
|
+
t.Errorf("Mark 不该把它改成 %q(只允许 running / waiting_approval / idle)", got)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* idle 是 keep-alive 会话**轮次之间**的状态(2026-10-21):本轮已结束、进程还在等 --append。
|
|
82
|
+
* 它必须能 running ↔ idle 来回切,且**落盘** —— 别的端(移动端跟进 / 串行门)读的就是磁盘。 */
|
|
83
|
+
func TestHandleMarkIdleRoundTrip(t *testing.T) {
|
|
84
|
+
t.Setenv(EnvDir, t.TempDir())
|
|
85
|
+
h, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
86
|
+
if err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
runID := h.RunID()
|
|
90
|
+
|
|
91
|
+
h.Mark(StateIdle) // 本轮结束
|
|
92
|
+
rec, found, _ := Find(runID)
|
|
93
|
+
if !found || rec.State != StateIdle {
|
|
94
|
+
t.Fatalf("落盘状态 = %q found=%v, want %q", rec.State, found, StateIdle)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
h.Mark(StateRunning) // 追加进来的下一轮开始
|
|
98
|
+
if got := h.Record().State; got != StateRunning {
|
|
99
|
+
t.Errorf("下一轮开始后状态 = %q want %q", got, StateRunning)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
h.Mark(StateWaitingApproval) // idle → 等审批同样允许(进程还在,停下来问用户)
|
|
103
|
+
if got := h.Record().State; got != StateWaitingApproval {
|
|
104
|
+
t.Errorf("idle → 等审批后状态 = %q", got)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// idle 只说明「这一轮没在跑」,进程仍在 —— 进程真的没了才归一成 gone。
|
|
109
|
+
func TestNormalizeDeadCoversIdle(t *testing.T) {
|
|
110
|
+
dead := &Record{State: StateIdle, PID: 999999}
|
|
111
|
+
if !normalizeDead(dead) || dead.State != StateGone {
|
|
112
|
+
t.Errorf("idle + 死进程应归一成 gone,得到 %q", dead.State)
|
|
113
|
+
}
|
|
114
|
+
live := &Record{State: StateIdle, PID: os.Getpid()}
|
|
115
|
+
if normalizeDead(live) || live.State != StateIdle {
|
|
116
|
+
t.Errorf("idle + 活进程不该被改,得到 %q", live.State)
|
|
78
117
|
}
|
|
79
118
|
}
|
|
80
119
|
|
|
@@ -84,7 +123,7 @@ func TestNormalizeLegacyState(t *testing.T) {
|
|
|
84
123
|
if got := normalizeState("stopped"); got != StateCancelled {
|
|
85
124
|
t.Errorf("normalizeState(stopped) = %q, want %q", got, StateCancelled)
|
|
86
125
|
}
|
|
87
|
-
for _, s := range []string{StateRunning, StateWaitingApproval, StateDone, StateFailed, StateCancelled, StateInterrupted, StateGone} {
|
|
126
|
+
for _, s := range []string{StateRunning, StateWaitingApproval, StateIdle, StateDone, StateFailed, StateCancelled, StateInterrupted, StateGone} {
|
|
88
127
|
if got := normalizeState(s); got != s {
|
|
89
128
|
t.Errorf("normalizeState(%q) 不该改它,得到 %q", s, got)
|
|
90
129
|
}
|
|
@@ -10,6 +10,7 @@ package session
|
|
|
10
10
|
import (
|
|
11
11
|
"encoding/json"
|
|
12
12
|
"os"
|
|
13
|
+
"os/exec"
|
|
13
14
|
"path/filepath"
|
|
14
15
|
"strings"
|
|
15
16
|
"testing"
|
|
@@ -193,6 +194,48 @@ func TestFinishKeepsStoppedState(t *testing.T) {
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
// TestBeginSupersedesAliveSibling 同一条会话再起一次时,**还活着**的上一次运行必须被停掉。
|
|
198
|
+
//
|
|
199
|
+
// 背景(2026-10-21,用户:「两边的回话状态不一致,一边结束了,一边还在显示运行中」):
|
|
200
|
+
// 两个进程同时 --resume 同一 session 时,两边各写各的 seq 到同一份日志(成对重号),
|
|
201
|
+
// 增量读的另一端会漏掉终态;登记表里旧 run 又一直 running,Find 永远答「在跑」。
|
|
202
|
+
func TestBeginSupersedesAliveSibling(t *testing.T) {
|
|
203
|
+
isolate(t)
|
|
204
|
+
|
|
205
|
+
// 用一个真实存活进程冒充「同会话的上一次运行」(supersedeSiblings 会按 pid 判活、下手)。
|
|
206
|
+
old := exec.Command("sleep", "30")
|
|
207
|
+
if err := old.Start(); err != nil {
|
|
208
|
+
t.Fatalf("起占位进程: %v", err)
|
|
209
|
+
}
|
|
210
|
+
waited := make(chan struct{})
|
|
211
|
+
go func() { _ = old.Wait(); close(waited) }()
|
|
212
|
+
t.Cleanup(func() { _ = old.Process.Kill() })
|
|
213
|
+
|
|
214
|
+
oldH, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-dup", PID: old.Process.Pid})
|
|
215
|
+
if err != nil {
|
|
216
|
+
t.Fatalf("Begin(old): %v", err)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 同会话再起一次(self pid 用测试进程自己):旧的那次必须被停掉。
|
|
220
|
+
if _, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-dup", PID: os.Getpid()}); err != nil {
|
|
221
|
+
t.Fatalf("Begin(new): %v", err)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
select {
|
|
225
|
+
case <-waited:
|
|
226
|
+
case <-time.After(3 * time.Second):
|
|
227
|
+
t.Fatal("同会话的上一次运行没有被停掉")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
rec, found, err := Find(oldH.RunID())
|
|
231
|
+
if err != nil || !found {
|
|
232
|
+
t.Fatalf("Find(old) = %+v, %v, %v", rec, found, err)
|
|
233
|
+
}
|
|
234
|
+
if rec.State != StateCancelled {
|
|
235
|
+
t.Fatalf("旧 run 应写成 %q(否则 Find 会一直答「在跑」),实得 %q", StateCancelled, rec.State)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
196
239
|
// 登记表**不按时间清理**:老记录必须留着(2026-10-18,用户:「永远保留啊 没有24小时限制」)。
|
|
197
240
|
// 只有读不出来的坏文件才清 —— 它永远读不出来,留着只是噪音。
|
|
198
241
|
func TestListKeepsOldRecordsDropsBroken(t *testing.T) {
|
|
@@ -128,7 +128,7 @@ func ReadSessionLog[T any](dir, sessionID string, parse func([]byte) (T, error))
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
// ReadSessionLogAfter 只读 seq > after 的事件(**断线续读**用)。
|
|
131
|
-
// 返回 (事件,
|
|
131
|
+
// 返回 (事件, 文件**末尾**那条事件的 seq, error)。after = 0 等价于全量。
|
|
132
132
|
//
|
|
133
133
|
// 语义对齐 agents-anywhere 的 `GET /sessions/{id}/events?after=seq:N`:
|
|
134
134
|
// 客户端报「我已经看到 seq=N」,服务端就从 N+1 开始给。
|
|
@@ -137,14 +137,21 @@ func ReadSessionLog[T any](dir, sessionID string, parse func([]byte) (T, error))
|
|
|
137
137
|
//
|
|
138
138
|
// 逻辑从「优化」变成「必需」—— 而续读的全部意义就是让客户端不必再关心去重。
|
|
139
139
|
//
|
|
140
|
-
// ⚠️ 返回的
|
|
140
|
+
// ⚠️ 返回的 lastSeq 是**最后一条物理行**的 seq,不是全文件最大 seq(2026-10-21 修正)。
|
|
141
141
|
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
142
|
+
// 旧版取 max,而同一会话每次续跑都是新进程、writer 又会在「拿到 session_id 后把
|
|
143
|
+
// <run_id>.jsonl 合并进 <session_id>.jsonl」时把 seq 重排回 1 —— 于是文件里 seq
|
|
144
|
+
// 不是单调递增的:实测一份 21753 行的日志有 17 处回落,全局 max=9506,而正在跑的
|
|
145
|
+
// 这一轮只到 2367。客户端游标停在 9506(旧轮末尾),`--after 9506` 把新轮 1..2367
|
|
146
|
+
// 全按 `s <= after` 滤掉 ⇒ 增量永远为空、画布永远「已追平」(用户实测报障:
|
|
147
|
+
// 「这不还是没进度吗」)。
|
|
144
148
|
//
|
|
145
|
-
//
|
|
149
|
+
// 改取末尾 seq 后,这种陈旧游标满足 after > lastSeq,调用方据此判 snapshotRequired
|
|
150
|
+
// 重拉全量,游标随即落到新轮的真实位置 —— 自愈,不必改客户端。
|
|
146
151
|
//
|
|
147
|
-
//
|
|
152
|
+
// ⚠️ 续读走进程内尾部游标(见 tailMark):上一次读完整份文件时记下「结尾 seq 之后
|
|
153
|
+
//
|
|
154
|
+
// 的字节偏移」,之后 after == lastSeq 的请求直接 seek 到那里只读尾部。
|
|
148
155
|
// 游标未命中(进程重启 / seq 不吻合 / 文件被截断)才退回全量重扫 —— 结果一致,
|
|
149
156
|
// 只是慢。输出形状与语义不变。
|
|
150
157
|
func ReadSessionLogAfter[T any](dir, sessionID string, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error)) ([]T, uint64, error) {
|
|
@@ -186,22 +193,26 @@ func readSessionLogAfter[T any](dir, sessionID string, after uint64, seqOf SeqOf
|
|
|
186
193
|
|
|
187
194
|
// 快路径:这个文件上次刚好读到 after 这条,直接从当时的偏移续读尾部。
|
|
188
195
|
if seqOf != nil && after > 0 {
|
|
189
|
-
if evs,
|
|
190
|
-
return evs,
|
|
196
|
+
if evs, tailSeq, ok := readFromTailMark(f, path, after, seqOf, parse, slim); ok {
|
|
197
|
+
return evs, tailSeq, nil
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
|
|
194
|
-
|
|
201
|
+
// 快路径可能已经 seek 过(命中游标后读失败):全量扫必须从文件头开始。
|
|
202
|
+
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
203
|
+
return nil, 0, err
|
|
204
|
+
}
|
|
205
|
+
evs, tailSeq, err := readNDJSON(f, after, seqOf, parse, slim)
|
|
195
206
|
if err != nil {
|
|
196
|
-
return evs,
|
|
207
|
+
return evs, tailSeq, err
|
|
197
208
|
}
|
|
198
209
|
if seqOf != nil {
|
|
199
|
-
// 全量扫过一遍 ⇒
|
|
210
|
+
// 全量扫过一遍 ⇒ tailSeq 与「最后一条完整行」的偏移是权威的,记下来给下次续读。
|
|
200
211
|
if st, serr := f.Stat(); serr == nil {
|
|
201
|
-
storeTailMark(path,
|
|
212
|
+
storeTailMark(path, tailSeq, completeLogEnd(f, st.Size()))
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
|
-
return evs,
|
|
215
|
+
return evs, tailSeq, nil
|
|
205
216
|
}
|
|
206
217
|
|
|
207
218
|
// ReadSessionLogBefore 反向读「从文件尾跳过 skip 条之后的一页」事件(**向上翻历史**用)。
|
|
@@ -370,7 +381,7 @@ func readSessionLogBefore[T any](dir, sessionID string, skip uint64, limit int,
|
|
|
370
381
|
}
|
|
371
382
|
|
|
372
383
|
// tailMark 记「某个日志文件已经完整消费到哪儿」:
|
|
373
|
-
// seq
|
|
384
|
+
// seq 是**最后一条完整行**的 seq,offset 是**那一行之后**的文件偏移(下一次从这里续读)。
|
|
374
385
|
//
|
|
375
386
|
// 为什么要它(2026-10-20):日志是纯追加的 NDJSON,但 seq 只能解析出来,无法 seek 定位。
|
|
376
387
|
// 于是每个 `/events?after=N` 都从文件头重扫全文 —— 移动端每 3s 对每个运行中会话轮询
|
|
@@ -414,12 +425,12 @@ func readFromTailMark[T any](f *os.File, path string, after uint64, seqOf SeqOf[
|
|
|
414
425
|
if _, err := f.Seek(m.offset, io.SeekStart); err != nil {
|
|
415
426
|
return nil, 0, false
|
|
416
427
|
}
|
|
417
|
-
evs,
|
|
428
|
+
evs, tailSeq, consumed, err := readNDJSONFrom(f, m.offset, after, seqOf, parse, slim)
|
|
418
429
|
if err != nil {
|
|
419
430
|
return nil, 0, false
|
|
420
431
|
}
|
|
421
|
-
storeTailMark(path,
|
|
422
|
-
return evs,
|
|
432
|
+
storeTailMark(path, tailSeq, consumed)
|
|
433
|
+
return evs, tailSeq, true
|
|
423
434
|
}
|
|
424
435
|
|
|
425
436
|
// loadTailMark 取 seq == after 的游标(offset <= 0 的占位不算)。
|
|
@@ -506,21 +517,22 @@ func completeLogEnd(f *os.File, size int64) int64 {
|
|
|
506
517
|
// readNDJSONFrom 从 pos 起续读到尾,语义与 readNDJSON 相同(同样的过滤 / 坏行跳过 / slim),
|
|
507
518
|
// 额外返回「已消费到的偏移」—— 只累加**带换行**的完整行,末尾半行留给下次。
|
|
508
519
|
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
520
|
+
// tailSeq 以 after 为起点:读不到新行时它就是 after,`after > tailSeq` 不会把
|
|
521
|
+
// 「已追平」误判成「游标超前」。读到新行后取**最后一条**的 seq(不是最大):日志里
|
|
522
|
+
// 可能有一轮 seq 从 1 重来,max 会永远停在旧轮的高位,游标就再也追不上(见
|
|
523
|
+
// ReadSessionLogAfter 的说明)。
|
|
512
524
|
func readNDJSONFrom[T any](f *os.File, start int64, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error), slim bool) ([]T, uint64, int64, error) {
|
|
513
525
|
var out []T
|
|
514
|
-
|
|
526
|
+
tailSeq := after
|
|
515
527
|
pos := start
|
|
516
528
|
br := bufio.NewReaderSize(f, 256*1024)
|
|
517
529
|
for {
|
|
518
530
|
line, err := br.ReadBytes('\n')
|
|
519
531
|
if err != nil {
|
|
520
532
|
if errors.Is(err, io.EOF) {
|
|
521
|
-
return out,
|
|
533
|
+
return out, tailSeq, pos, nil // 末尾半行还没落完:不消费
|
|
522
534
|
}
|
|
523
|
-
return out,
|
|
535
|
+
return out, tailSeq, pos, err
|
|
524
536
|
}
|
|
525
537
|
pos += int64(len(line))
|
|
526
538
|
line = line[:len(line)-1]
|
|
@@ -535,14 +547,17 @@ func readNDJSONFrom[T any](f *os.File, start int64, after uint64, seqOf SeqOf[T]
|
|
|
535
547
|
}
|
|
536
548
|
v, err := parse(line)
|
|
537
549
|
if err != nil {
|
|
538
|
-
fmt.Fprintf(os.Stderr, "magic-agent:
|
|
550
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 会话历史行解析失败(跳过该行):%v\n", err)
|
|
539
551
|
continue
|
|
540
552
|
}
|
|
541
553
|
s := seqOf(v)
|
|
542
|
-
|
|
543
|
-
maxSeq = s
|
|
544
|
-
}
|
|
554
|
+
tailSeq = s
|
|
545
555
|
if s <= after {
|
|
556
|
+
// 不比游标新 ⇒ 这是文件里更早的那一段(同一 seq 可能在续跑后重复出现)。
|
|
557
|
+
// 游标的真实位置在这行**之后**:之前攒下的候选全部作废,否则旧轮里
|
|
558
|
+
// seq 高于游标的事件会被当成增量反复重投(2026-10-21 实测:续跑回落
|
|
559
|
+
// 的日志会让画布每秒重复插入旧事件)。
|
|
560
|
+
out = out[:0]
|
|
546
561
|
continue
|
|
547
562
|
}
|
|
548
563
|
out = append(out, v)
|
|
@@ -656,11 +671,11 @@ func skipJSONValue(b []byte, i int) int {
|
|
|
656
671
|
// readNDJSON 流式读 NDJSON。空行 / 坏行跳过(warn 一次)。
|
|
657
672
|
//
|
|
658
673
|
// after / seqOf 都可选:seqOf 为 nil 时不按 seq 过滤(此时 after 无意义),
|
|
659
|
-
// 返回的
|
|
674
|
+
// 返回的 tailSeq 也就恒为 0 —— 这两个参数是配套的,调用方别只给一半。
|
|
660
675
|
// slim 为真时先做 stripSnapshotField(见 ReadSessionLogLean)。
|
|
661
676
|
func readNDJSON[T any](r io.Reader, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error), slim bool) ([]T, uint64, error) {
|
|
662
677
|
var out []T
|
|
663
|
-
var
|
|
678
|
+
var tailSeq uint64
|
|
664
679
|
sc := bufio.NewScanner(r)
|
|
665
680
|
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
666
681
|
for sc.Scan() {
|
|
@@ -681,14 +696,15 @@ func readNDJSON[T any](r io.Reader, after uint64, seqOf SeqOf[T], parse func([]b
|
|
|
681
696
|
continue
|
|
682
697
|
}
|
|
683
698
|
s := seqOf(v)
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
maxSeq = s
|
|
687
|
-
}
|
|
699
|
+
// 取最后一条(不是最大):seq 可能在续跑时从 1 重来,max 会永远停在旧轮高位。
|
|
700
|
+
tailSeq = s
|
|
688
701
|
if s <= after {
|
|
702
|
+
// 与 readNDJSONFrom 同一条规则:见到「不比游标新」的行,说明游标的
|
|
703
|
+
// 真实位置更靠后,之前攒下的候选不是增量,作废。
|
|
704
|
+
out = out[:0]
|
|
689
705
|
continue
|
|
690
706
|
}
|
|
691
707
|
out = append(out, v)
|
|
692
708
|
}
|
|
693
|
-
return out,
|
|
709
|
+
return out, tailSeq, sc.Err()
|
|
694
710
|
}
|
|
@@ -12,8 +12,8 @@ import (
|
|
|
12
12
|
//
|
|
13
13
|
// 1. after 是**排他**上界(不回 seq == after 那条)——写成 >= 会让客户端每次续读
|
|
14
14
|
// 都重收一条,去重逻辑就被迫成为必需;
|
|
15
|
-
// 2. 返回的
|
|
16
|
-
//
|
|
15
|
+
// 2. 返回的 lastSeq 是**文件末尾那条**的 seq(不是全文件最大)——续跑会让 seq 从 1
|
|
16
|
+
// 重来,取 max 会停在旧轮高位,游标永远追不上(2026-10-21 修);
|
|
17
17
|
// 3. 文件不存在时不是错误(新会话没有历史是正常状态)。
|
|
18
18
|
|
|
19
19
|
// seqEvent 测试用的事件类型:带 seq 与 text 两个字段。
|
|
@@ -69,17 +69,17 @@ func TestReadSessionLogAfterFiltersBySeq(t *testing.T) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
72
|
+
// TestReadSessionLogAfterAheadReportsTail 钉住「游标超前」的判据:即使这一页是空的,
|
|
73
|
+
// lastSeq 也必须是文件末尾那条的 seq。
|
|
74
74
|
//
|
|
75
|
-
// 为什么:调用方用 `after >
|
|
76
|
-
//
|
|
77
|
-
//
|
|
75
|
+
// 为什么:调用方用 `after > lastSeq` 判「客户端游标超前了(日志被重建 / 游标来自
|
|
76
|
+
// 别的会话 / 本文件 seq 曾回落)」,超前时得以全量重建。若 lastSeq 只统计过滤结果,
|
|
77
|
+
// after=99 这类情形会得到 0,判据恒为真 —— 客户端会被永久判成「超前」,每次进会话都
|
|
78
78
|
// 重拉全量,增量能力形同虚设。
|
|
79
|
-
func
|
|
79
|
+
func TestReadSessionLogAfterAheadReportsTail(t *testing.T) {
|
|
80
80
|
dir := writeLog(t, "s", `{"seq":1,"text":"a"}`, `{"seq":7,"text":"b"}`)
|
|
81
81
|
|
|
82
|
-
// after 超前(99 > 7):这一页是空的,但
|
|
82
|
+
// after 超前(99 > 7):这一页是空的,但 lastSeq 仍须是 7(文件末尾)。
|
|
83
83
|
evs, maxSeq, err := ReadSessionLogAfter(dir, "s", 99, func(e seqEvent) uint64 { return e.Seq }, parseSeqEvent)
|
|
84
84
|
if err != nil {
|
|
85
85
|
t.Fatal(err)
|
|
@@ -88,10 +88,10 @@ func TestReadSessionLogAfterMaxSeqCoversWholeFile(t *testing.T) {
|
|
|
88
88
|
t.Fatalf("after=99 期望空页,实得 %+v", evs)
|
|
89
89
|
}
|
|
90
90
|
if maxSeq != 7 {
|
|
91
|
-
t.Fatalf("超前时
|
|
91
|
+
t.Fatalf("超前时 lastSeq 仍须是文件末尾(期望 7,实得 %d)", maxSeq)
|
|
92
92
|
}
|
|
93
93
|
if !(99 > maxSeq) {
|
|
94
|
-
t.Fatalf("判据 `after >
|
|
94
|
+
t.Fatalf("判据 `after > lastSeq` 应为 true(客户端超前),lastSeq=%d", maxSeq)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
// 追平(after == maxSeq):空页,且判据为 false(这是正常的「没有新事件」)。
|
|
@@ -107,6 +107,64 @@ func TestReadSessionLogAfterMaxSeqCoversWholeFile(t *testing.T) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// TestReadSessionLogAfterSeqReset 钉住 2026-10-21 的现场:同一份日志里 seq 回落
|
|
111
|
+
// (旧轮 1..9506,新一轮又从 1 数起)。
|
|
112
|
+
//
|
|
113
|
+
// 回落来源:`--session <id>` 续接时 writer 先按 <run_id>.jsonl 从 1 写,拿到 id 后
|
|
114
|
+
// 把内容合并进 <session_id>.jsonl —— 文件末尾的 seq 比中段小。客户端游标停在旧轮
|
|
115
|
+
// 末位 9506 时,旧实现(按全文件 max 判游标)永远认为「已追平」,画布不再刷新
|
|
116
|
+
// (用户实测:「这不还是没进度吗」)。
|
|
117
|
+
func TestReadSessionLogAfterSeqReset(t *testing.T) {
|
|
118
|
+
dir := writeLog(t, "s",
|
|
119
|
+
`{"seq":9505,"text":"old-a"}`,
|
|
120
|
+
`{"seq":9506,"text":"old-b"}`,
|
|
121
|
+
`{"seq":1,"text":"new-a"}`,
|
|
122
|
+
`{"seq":2,"text":"new-b"}`)
|
|
123
|
+
seqOf := func(e seqEvent) uint64 { return e.Seq }
|
|
124
|
+
|
|
125
|
+
// ① 新轮进行中(游标 = 新轮 1):只回新轮 2,旧轮 9505/9506 不算增量
|
|
126
|
+
// (它们物理上在游标那条之前,投出去就是重复画旧事件)。
|
|
127
|
+
evs, last, err := ReadSessionLogAfter(dir, "s", 1, seqOf, parseSeqEvent)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
if len(evs) != 1 || evs[0].Text != "new-b" {
|
|
132
|
+
t.Fatalf("after=1 期望 [new-b],实得 %+v", evs)
|
|
133
|
+
}
|
|
134
|
+
if last != 2 {
|
|
135
|
+
t.Fatalf("lastSeq 期望 2,实得 %d", last)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ② 陈旧游标停在旧轮末位(用户实测就是这个状态):一页空 + lastSeq 落到新轮末位,
|
|
139
|
+
// 调用方据此判 snapshotRequired 重拉全量,游标自愈。
|
|
140
|
+
evs, last, err = ReadSessionLogAfter(dir, "s", 9506, seqOf, parseSeqEvent)
|
|
141
|
+
if err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
if len(evs) != 0 {
|
|
145
|
+
t.Fatalf("after=9506 期望空页,实得 %+v", evs)
|
|
146
|
+
}
|
|
147
|
+
if last != 2 {
|
|
148
|
+
t.Fatalf("lastSeq 期望新轮末位 2,实得 %d", last)
|
|
149
|
+
}
|
|
150
|
+
if !(9506 > last) {
|
|
151
|
+
t.Fatalf("陈旧游标须判成超前(snapshotRequired),lastSeq=%d", last)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ③ 全量重读后游标 = 末位 2:不能再把旧轮 9505/9506 当增量重投(否则每个轮询
|
|
155
|
+
// 周期都往画布插一条旧事件)。
|
|
156
|
+
evs, last, err = ReadSessionLogAfter(dir, "s", 2, seqOf, parseSeqEvent)
|
|
157
|
+
if err != nil {
|
|
158
|
+
t.Fatal(err)
|
|
159
|
+
}
|
|
160
|
+
if len(evs) != 0 {
|
|
161
|
+
t.Fatalf("after=2(已追平)期望空页,实得 %+v", evs)
|
|
162
|
+
}
|
|
163
|
+
if last != 2 {
|
|
164
|
+
t.Fatalf("lastSeq 期望 2,实得 %d", last)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
110
168
|
// TestReadSessionLogAfterMissingFile 没历史不是错(新会话 / 从未落盘)。
|
|
111
169
|
func TestReadSessionLogAfterMissingFile(t *testing.T) {
|
|
112
170
|
dir := t.TempDir()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// status.go - 会话**级**状态:一张按会话存的覆盖表,客户端只读。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要(2026-10-21 用户:「会话状态搞一个字段 跟会话标题一样存起来 更新这个字段来统一」):
|
|
4
|
+
// 登记表是 **run 级**的 —— 一条会话每轮一个文件,各自记自己的 state。客户端拿到的行取决于
|
|
5
|
+
// 「它挑中了哪一轮」:PC 按事件流已经显示「已完成」,移动端读到的却是上一轮的 running,
|
|
6
|
+
// 于是同一会话在两端状态不一致,移动端的串行门(同空间有 running 就排队)还会一直排着。
|
|
7
|
+
//
|
|
8
|
+
// 这里把状态收成**一个会话一份**,与标题同一套做法:core 在写登记表时顺手更新,
|
|
9
|
+
// 客户端只读一个字段,不再各自推算。存的是「这条会话最后一次已知状态」,不是「某一轮」。
|
|
10
|
+
//
|
|
11
|
+
// 存储:~/.magic-agent/session-status.json(与 session-titles.json 同目录),形状:
|
|
12
|
+
//
|
|
13
|
+
// { "<session_id>": {"state":"running","run_id":"run-…","run_started":"…","updated_at":"…"} }
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ 键优先用 session_id(引擎给了才用),没给时退回 run_id;拿到 session_id 后把 run 键
|
|
16
|
+
// 迁过去并删掉旧键 —— 留着两把键,按不同 id 读就会拿到两个状态(与标题同一个坑)。
|
|
17
|
+
package session
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"encoding/json"
|
|
21
|
+
"os"
|
|
22
|
+
"path/filepath"
|
|
23
|
+
"strings"
|
|
24
|
+
"sync"
|
|
25
|
+
"time"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
var (
|
|
29
|
+
statusMu sync.Mutex
|
|
30
|
+
statusCache map[string]StatusEntry
|
|
31
|
+
statusLoaded bool
|
|
32
|
+
// statusCachePath 缓存对应的表路径。生产里 EnvDir 不会中途变,但测试会反复换目录 ——
|
|
33
|
+
// 只认「同一张表」的缓存,换目录自动失效,用例之间不会互相串状态。
|
|
34
|
+
statusCachePath string
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// StatusEntry 一条会话的当前状态(表里的值)。
|
|
38
|
+
type StatusEntry struct {
|
|
39
|
+
State string `json:"state"`
|
|
40
|
+
// RunID / RunStarted 写下这条状态的是哪一轮、那一轮的开始时间。
|
|
41
|
+
//
|
|
42
|
+
// 为什么必须带 RunStarted:状态表是**会话级**的,而写入方是**各轮的进程** ——
|
|
43
|
+
// 旧一轮收尾(Finish / Stop / 进程已不在 → gone)可能晚于新一轮开跑,
|
|
44
|
+
// 没有排序守卫的话,迟到的「done」会把新一轮的「running」盖掉,
|
|
45
|
+
// 界面就变成「明明在跑却显示已完成」。
|
|
46
|
+
RunID string `json:"run_id,omitempty"`
|
|
47
|
+
RunStarted time.Time `json:"run_started"`
|
|
48
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// StatusStorePath 状态表路径(~/.magic-agent/session-status.json)。
|
|
52
|
+
// 与标题表同目录同风格(见 TitleStorePath):换实现不换位置。
|
|
53
|
+
func StatusStorePath() (string, error) {
|
|
54
|
+
if p := strings.TrimSpace(os.Getenv(EnvDir)); p != "" {
|
|
55
|
+
return filepath.Join(filepath.Dir(p), "session-status.json"), nil
|
|
56
|
+
}
|
|
57
|
+
home, err := os.UserHomeDir()
|
|
58
|
+
if err != nil || home == "" {
|
|
59
|
+
return "", os.ErrNotExist
|
|
60
|
+
}
|
|
61
|
+
return filepath.Join(home, ".magic-agent", "session-status.json"), nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// readStatusesLocked 从磁盘读整表(调用方须持 statusMu;文件不存在 = 空表)。
|
|
65
|
+
func readStatusesLocked() map[string]StatusEntry {
|
|
66
|
+
out := map[string]StatusEntry{}
|
|
67
|
+
p, err := StatusStorePath()
|
|
68
|
+
if err != nil {
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
if b, err := os.ReadFile(p); err == nil {
|
|
72
|
+
_ = json.Unmarshal(b, &out)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func copyStatuses(m map[string]StatusEntry) map[string]StatusEntry {
|
|
78
|
+
out := make(map[string]StatusEntry, len(m))
|
|
79
|
+
for k, v := range m {
|
|
80
|
+
out[k] = v
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func saveStatusesLocked(m map[string]StatusEntry) error {
|
|
86
|
+
p, err := StatusStorePath()
|
|
87
|
+
if err != nil {
|
|
88
|
+
return err
|
|
89
|
+
}
|
|
90
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
91
|
+
return err
|
|
92
|
+
}
|
|
93
|
+
b, err := json.MarshalIndent(m, "", " ")
|
|
94
|
+
if err != nil {
|
|
95
|
+
return err
|
|
96
|
+
}
|
|
97
|
+
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
98
|
+
return err
|
|
99
|
+
}
|
|
100
|
+
statusCache, statusLoaded, statusCachePath = m, true, p
|
|
101
|
+
return nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// loadStatuses 读状态表(进程内缓存一份:列表页会逐行问状态,逐行读盘没必要)。
|
|
105
|
+
// 写操作(setStatus / dropStatuses)后缓存随之更新。
|
|
106
|
+
func loadStatuses() (map[string]StatusEntry, error) {
|
|
107
|
+
statusMu.Lock()
|
|
108
|
+
defer statusMu.Unlock()
|
|
109
|
+
p, err := StatusStorePath()
|
|
110
|
+
if err != nil {
|
|
111
|
+
return map[string]StatusEntry{}, nil
|
|
112
|
+
}
|
|
113
|
+
if statusLoaded && statusCachePath == p {
|
|
114
|
+
return copyStatuses(statusCache), nil
|
|
115
|
+
}
|
|
116
|
+
m := readStatusesLocked()
|
|
117
|
+
statusCache, statusLoaded, statusCachePath = m, true, p
|
|
118
|
+
return copyStatuses(m), nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// StatusFor 取一条会话的状态(没有则空串)。
|
|
122
|
+
func StatusFor(id string) string {
|
|
123
|
+
id = strings.TrimSpace(id)
|
|
124
|
+
if id == "" {
|
|
125
|
+
return ""
|
|
126
|
+
}
|
|
127
|
+
m, _ := loadStatuses()
|
|
128
|
+
return m[id].State
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// StatusForAny 取一条会话的状态:**session_id 优先,其次 run_id**。
|
|
132
|
+
//
|
|
133
|
+
// 与 TitleForAny 同一个理由:引擎没吐 session_id 时键只能落在 run_id 上,
|
|
134
|
+
// 而 run_id 每轮都换 —— 两个键都认,才谈得上「一条会话一个状态」。
|
|
135
|
+
func StatusForAny(sessionID, runID string) string {
|
|
136
|
+
m, _ := loadStatuses()
|
|
137
|
+
if e, ok := m[strings.TrimSpace(sessionID)]; ok && e.State != "" {
|
|
138
|
+
return e.State
|
|
139
|
+
}
|
|
140
|
+
return m[strings.TrimSpace(runID)].State
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// setStatus 把一条登记记录的状态写进会话级状态表(writeRecord 的收口点)。
|
|
144
|
+
//
|
|
145
|
+
// 两件事保证「统一」而不是「谁最后写谁算」:
|
|
146
|
+
//
|
|
147
|
+
// ① 排序守卫:只在写入方这一轮**不早于**表里那一轮时才覆盖。旧一轮收尾的 done/gone
|
|
148
|
+
// 迟到时直接丢弃,不会把新一轮的 running 盖掉;
|
|
149
|
+
// ② 键归一:拿到 session_id 后写 session 键,并删掉本轮的 run 键(不留双键)。
|
|
150
|
+
func setStatus(rec Record) {
|
|
151
|
+
if rec.State == "" {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
sid := strings.TrimSpace(rec.SessionID)
|
|
155
|
+
rid := strings.TrimSpace(rec.RunID)
|
|
156
|
+
key := sid
|
|
157
|
+
if key == "" {
|
|
158
|
+
key = rid
|
|
159
|
+
}
|
|
160
|
+
if key == "" {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
statusMu.Lock()
|
|
164
|
+
defer statusMu.Unlock()
|
|
165
|
+
m := readStatusesLocked()
|
|
166
|
+
cur, has := m[key]
|
|
167
|
+
started := rec.StartedAt
|
|
168
|
+
if started.IsZero() {
|
|
169
|
+
// 老记录没有开始时间:沿用表里已有的排序依据,别让零值把自己判成「最新一轮」。
|
|
170
|
+
started = cur.RunStarted
|
|
171
|
+
}
|
|
172
|
+
if has && !rec.StartedAt.IsZero() && cur.RunStarted.After(rec.StartedAt) {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if has && cur.State == rec.State && cur.RunID == rid && cur.RunStarted.Equal(started) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
m[key] = StatusEntry{State: rec.State, RunID: rid, RunStarted: started, UpdatedAt: time.Now()}
|
|
179
|
+
if sid != "" && rid != "" && rid != sid {
|
|
180
|
+
// 本轮的 run 键迁到 session 键上:留着它,按 run_id 读旧状态、按 session_id 读新状态。
|
|
181
|
+
if old, ok := m[rid]; ok && !old.RunStarted.After(rec.StartedAt) {
|
|
182
|
+
delete(m, rid)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
_ = saveStatusesLocked(m)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// dropStatuses 清掉这些 id 上的状态(删除会话时用),返回清掉的条数。
|
|
189
|
+
func dropStatuses(names map[string]bool) int {
|
|
190
|
+
statusMu.Lock()
|
|
191
|
+
defer statusMu.Unlock()
|
|
192
|
+
m := readStatusesLocked()
|
|
193
|
+
n := 0
|
|
194
|
+
for one := range names {
|
|
195
|
+
if _, ok := m[one]; ok {
|
|
196
|
+
delete(m, one)
|
|
197
|
+
n++
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if n == 0 {
|
|
201
|
+
return 0
|
|
202
|
+
}
|
|
203
|
+
_ = saveStatusesLocked(m)
|
|
204
|
+
return n
|
|
205
|
+
}
|