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.
Files changed (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2443 -178
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. 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
- // 返回 (事件, 文件里的最大 seq, error)。after = 0 等价于全量。
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
- // ⚠️ 返回的 maxSeq 覆盖**整个文件**(含被 after 滤掉的部分):调用方要用它判断
140
+ // ⚠️ 返回的 lastSeq 是**最后一条物理行**的 seq,不是全文件最大 seq(2026-10-21 修正)。
141
141
  //
142
- // 「客户端的游标是不是超前了」(after > maxSeq ⇒ 客户端状态比磁盘还新,
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
- // ⚠️ 续读走进程内尾部游标(见 tailMark):上一次读完整份文件时记下「maxSeq 之后
149
+ // 改取末尾 seq 后,这种陈旧游标满足 after > lastSeq,调用方据此判 snapshotRequired
150
+ // 重拉全量,游标随即落到新轮的真实位置 —— 自愈,不必改客户端。
146
151
  //
147
- // 的字节偏移」,之后 after == maxSeq 的请求直接 seek 到那里只读尾部。
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, maxSeq, ok := readFromTailMark(f, path, after, seqOf, parse, slim); ok {
190
- return evs, maxSeq, nil
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
- evs, maxSeq, err := readNDJSON(f, after, seqOf, parse, slim)
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, maxSeq, err
207
+ return evs, tailSeq, err
197
208
  }
198
209
  if seqOf != nil {
199
- // 全量扫过一遍 ⇒ maxSeq 与「最后一条完整行」的偏移是权威的,记下来给下次续读。
210
+ // 全量扫过一遍 ⇒ tailSeq 与「最后一条完整行」的偏移是权威的,记下来给下次续读。
200
211
  if st, serr := f.Stat(); serr == nil {
201
- storeTailMark(path, maxSeq, completeLogEnd(f, st.Size()))
212
+ storeTailMark(path, tailSeq, completeLogEnd(f, st.Size()))
202
213
  }
203
214
  }
204
- return evs, maxSeq, nil
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 是已读到的最大 seq,offset 是**那一行之后**的文件偏移(下一次从这里续读)。
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, maxSeq, consumed, err := readNDJSONFrom(f, m.offset, after, seqOf, parse, slim)
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, maxSeq, consumed)
422
- return evs, maxSeq, true
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
- // maxSeq 以 after 为起点:本函数看不到文件前半段,但调用方的前提是 mark.seq == after
510
- // ⇒ 前半段的最大 seq 就是 after。这样「没有新事件」时 maxSeq 仍等于 after,
511
- // `after > maxSeq` 不会把「已追平」误判成「游标超前」。
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
- maxSeq := after
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, maxSeq, pos, nil // 末尾半行还没落完:不消费
533
+ return out, tailSeq, pos, nil // 末尾半行还没落完:不消费
522
534
  }
523
- return out, maxSeq, pos, err
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: 会话历史行解析失败:%v(跳过该行)\n", err)
550
+ fmt.Fprintf(os.Stderr, "magic-agent: 会话历史行解析失败(跳过该行):%v\n", err)
539
551
  continue
540
552
  }
541
553
  s := seqOf(v)
542
- if s > maxSeq {
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
- // 返回的 maxSeq 也就恒为 0 —— 这两个参数是配套的,调用方别只给一半。
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 maxSeq uint64
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
- // maxSeq 先更新、后过滤:它要反映整个文件,而不只是这一页。
685
- if s > maxSeq {
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, maxSeq, sc.Err()
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. 返回的 maxSeq 覆盖**整个文件**而不只是这一页 —— 它是「客户端游标是否超前」的
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
- // TestReadSessionLogAfterMaxSeqCoversWholeFile 是这一组里最要紧的一条:
73
- // maxSeq 必须反映**整个文件**,不能只统计返回的那一页。
72
+ // TestReadSessionLogAfterAheadReportsTail 钉住「游标超前」的判据:即使这一页是空的,
73
+ // lastSeq 也必须是文件末尾那条的 seq。
74
74
  //
75
- // 为什么:调用方用 `after > maxSeq` 判「客户端游标超前了(日志被重置 / 游标来自
76
- // 别的会话)」,超时时得以全量重建。若 maxSeq 只统计过滤结果,after=99 这类超前
77
- // 情形会得到 maxSeq=0,判据恒为真 —— 客户端会被永久判成「超前」,每次进会话都
75
+ // 为什么:调用方用 `after > lastSeq` 判「客户端游标超前了(日志被重建 / 游标来自
76
+ // 别的会话 / 本文件 seq 曾回落)」,超前时得以全量重建。若 lastSeq 只统计过滤结果,
77
+ // after=99 这类情形会得到 0,判据恒为真 —— 客户端会被永久判成「超前」,每次进会话都
78
78
  // 重拉全量,增量能力形同虚设。
79
- func TestReadSessionLogAfterMaxSeqCoversWholeFile(t *testing.T) {
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):这一页是空的,但 maxSeq 仍须是 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("超前时 maxSeq 仍须覆盖整个文件(期望 7,实得 %d)", maxSeq)
91
+ t.Fatalf("超前时 lastSeq 仍须是文件末尾(期望 7,实得 %d)", maxSeq)
92
92
  }
93
93
  if !(99 > maxSeq) {
94
- t.Fatalf("判据 `after > maxSeq` 应为 true(客户端超前),maxSeq=%d", maxSeq)
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
+ }