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
@@ -329,17 +329,41 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
329
329
  records = []session.Record{}
330
330
  }
331
331
  type row struct {
332
- RunID string `json:"run_id"`
333
- SessionID string `json:"session_id,omitempty"`
334
- PID int `json:"pid"`
335
- Engine string `json:"engine"`
336
- Model string `json:"model,omitempty"`
337
- Source string `json:"source,omitempty"`
338
- Client string `json:"client,omitempty"`
339
- State string `json:"state"`
332
+ RunID string `json:"run_id"`
333
+ SessionID string `json:"session_id,omitempty"`
334
+ PID int `json:"pid"`
335
+ Engine string `json:"engine"`
336
+ Model string `json:"model,omitempty"`
337
+ Source string `json:"source,omitempty"`
338
+ Client string `json:"client,omitempty"`
339
+ State string `json:"state"`
340
+ // Status 这条会话的**统一状态**(session 级,见 internal/session/status.go)。
341
+ //
342
+ // 为什么与 state 并存(2026-10-21 用户:「会话状态搞一个字段 跟会话标题一样存起来
343
+ // 更新这个字段来统一」):state 是**这一轮** run 的状态,同一会话多轮会给出不同值;
344
+ // 客户端各挑一行读,就会一端「已完成」、另一端还在「排队」。status 一条会话只一个值
345
+ // (core 在写登记表时同步更新),两端读它就必然一致。
346
+ // 老记录没有状态条目 → 回落 state,行为与改造前相同(只增字段,不升契约版本)。
347
+ Status string `json:"status"`
340
348
  Alive bool `json:"alive"`
341
349
  Workspace string `json:"workspace,omitempty"`
342
350
  PromptHead string `json:"prompt_head,omitempty"`
351
+ // Mode 这条会话的运行模式:空 = 单引擎(默认);"team" = 团队模式。
352
+ //
353
+ // 为什么列表要带它(2026-10-21):团队模式下这条会话是**一组子任务**跑出来的,
354
+ // 列表上要有可分辨的痕迹,否则用户看到一条普通会话点进去发现是一堆委派卡。
355
+ // 只增字段,老客户端忽略即可。
356
+ Mode string `json:"mode,omitempty"`
357
+ // ParentRunID / ParentSessionID 父会话(团队主会话)指针;空 = 顶层会话。
358
+ //
359
+ // 团队拆出的子任务**是普通会话**(有自己的登记与日志),列表靠这两个字段
360
+ // 把它们缩进挂在主会话下面。两个都留:主会话可能在还没拿到 session_id 时
361
+ // 就拆出了子任务,读侧优先用 ParentSessionID,缺失再按 ParentRunID 认亲。
362
+ ParentRunID string `json:"parent_run_id,omitempty"`
363
+ ParentSessionID string `json:"parent_session_id,omitempty"`
364
+ // Agents 团队模式下各类子任务实际派给了谁(按类别归并,同类只留一条)。
365
+ // 与 Mode 同批出现:`{kind,engine,model,count}`,客户端可据此画「谁在干活」。
366
+ Agents []session.AgentRef `json:"agents,omitempty"`
343
367
  // Title 这条会话的**可展示标题**:优先标题表里的归纳结果(--summarize-title 写的),
344
368
  // 没有就回落 prompt_head。
345
369
  //
@@ -373,6 +397,13 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
373
397
  if title == "" {
374
398
  title = r.PromptHead
375
399
  }
400
+ /* 统一状态:一条会话一个值(各轮的行给出同一个),决定客户端画「进行中 / 已完成」。
401
+ 没有条目(改造前的老记录)才回落这一轮的 state。alive 也**按它算** ——
402
+ 否则一行里 status=idle、alive=true 自相矛盾,客户端又会分叉。 */
403
+ status := session.StatusForAny(r.SessionID, r.RunID)
404
+ if status == "" {
405
+ status = r.State
406
+ }
376
407
  rows = append(rows, row{
377
408
  RunID: r.RunID,
378
409
  SessionID: r.SessionID,
@@ -382,9 +413,14 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
382
413
  Source: r.Source,
383
414
  Client: r.Client,
384
415
  State: r.State,
385
- Alive: r.State == session.StateRunning,
416
+ Status: status,
417
+ Alive: status == session.StateRunning,
386
418
  Workspace: r.Workspace,
387
419
  PromptHead: r.PromptHead,
420
+ Mode: r.Mode,
421
+ ParentRunID: r.ParentRunID,
422
+ ParentSessionID: r.ParentSessionID,
423
+ Agents: r.Agents,
388
424
  Title: title,
389
425
  TitleSummarized: summarized,
390
426
  StartedAt: r.StartedAt.Format("2006-01-02 15:04:05"),
@@ -477,12 +513,12 @@ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
477
513
 
478
514
  if flagChanged(cmd, "after") {
479
515
  var evs []sessionEvent
480
- var maxSeq uint64
516
+ var lastSeq uint64
481
517
  var err error
482
518
  if opts.logLean {
483
- evs, maxSeq, err = ReadSessionLogAfterLean(dir, id, opts.logAfter)
519
+ evs, lastSeq, err = ReadSessionLogAfterLean(dir, id, opts.logAfter)
484
520
  } else {
485
- evs, maxSeq, err = ReadSessionLogAfter(dir, id, opts.logAfter)
521
+ evs, lastSeq, err = ReadSessionLogAfter(dir, id, opts.logAfter)
486
522
  }
487
523
  if err != nil {
488
524
  return fmt.Errorf("--session-log %s: %w", id, err)
@@ -500,10 +536,11 @@ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
500
536
  After uint64 `json:"after"`
501
537
  LastSeq uint64 `json:"lastSeq"`
502
538
  // 调用方游标比磁盘还新 ⇒ 增量不可信,去拉全量。
539
+ // 「还新」= 大于**文件末尾那条**的 seq(不是全文件最大:续跑会让 seq 回落)。
503
540
  // ⚠️ 判据是**严格大于**:after == lastSeq 是「已经追平」,那是正常的空增量。
504
541
  SnapshotRequired bool `json:"snapshotRequired"`
505
542
  }{Session: id, Events: evs, Count: len(evs), After: opts.logAfter,
506
- LastSeq: maxSeq, SnapshotRequired: opts.logAfter > maxSeq})
543
+ LastSeq: lastSeq, SnapshotRequired: opts.logAfter > lastSeq})
507
544
  }
508
545
 
509
546
  var evs []sessionEvent
@@ -609,22 +646,24 @@ func runDeleteSession(cmd *cobra.Command, opts *askOptions) error {
609
646
  }
610
647
  if opts.output == "text" {
611
648
  _, _ = fmt.Fprintf(cmd.OutOrStdout(),
612
- "magic-agent: 已删除会话 %s(登记记录 %d 条 / 历史 %d 份 / 标题 %d 条)\n",
613
- res.ID, res.RecordsRemoved, res.LogsRemoved, res.TitlesRemoved)
649
+ "magic-agent: 已删除会话 %s(登记记录 %d 条 / 历史 %d 份 / 标题 %d 条 / 状态 %d 条)\n",
650
+ res.ID, res.RecordsRemoved, res.LogsRemoved, res.TitlesRemoved, res.StatusesRemoved)
614
651
  return nil
615
652
  }
616
653
  out := struct {
617
- Type string `json:"type"`
618
- ID string `json:"id"`
619
- RecordsRemoved int `json:"records_removed"`
620
- LogsRemoved int `json:"logs_removed"`
621
- TitlesRemoved int `json:"titles_removed"`
654
+ Type string `json:"type"`
655
+ ID string `json:"id"`
656
+ RecordsRemoved int `json:"records_removed"`
657
+ LogsRemoved int `json:"logs_removed"`
658
+ TitlesRemoved int `json:"titles_removed"`
659
+ StatusesRemoved int `json:"statuses_removed"`
622
660
  }{
623
- Type: "delete",
624
- ID: res.ID,
625
- RecordsRemoved: res.RecordsRemoved,
626
- LogsRemoved: res.LogsRemoved,
627
- TitlesRemoved: res.TitlesRemoved,
661
+ Type: "delete",
662
+ ID: res.ID,
663
+ RecordsRemoved: res.RecordsRemoved,
664
+ LogsRemoved: res.LogsRemoved,
665
+ TitlesRemoved: res.TitlesRemoved,
666
+ StatusesRemoved: res.StatusesRemoved,
628
667
  }
629
668
  return writeJSONLine(cmd.OutOrStdout(), out)
630
669
  }
@@ -50,11 +50,17 @@ var SessionLogDirOverride string
50
50
 
51
51
  // sessionLogDir 返回本会话日志的目录路径:
52
52
  // - 显式 override(SessionLogDirOverride)优先;
53
+ // - 其次 $MAGIC_AGENT_SESSIONS(与 session.Dir() 同一口径:登记表换了目录,
54
+ // 日志也必须跟着换 —— 否则 --sessions 在 A 目录、--session-log 读 B 目录,
55
+ // 日志明明写着却「读不到历史」,这类分家最难查);
53
56
  // - 默认是 ~/.magic-agent/sessions(与 session 登记表同一目录)。
54
57
  func sessionLogDir() (string, error) {
55
58
  if SessionLogDirOverride != "" {
56
59
  return SessionLogDirOverride, nil
57
60
  }
61
+ if p := strings.TrimSpace(os.Getenv(session.EnvDir)); p != "" {
62
+ return p, nil
63
+ }
58
64
  home, err := os.UserHomeDir()
59
65
  if err != nil {
60
66
  return "", fmt.Errorf("找不到家目录:%w", err)
@@ -86,9 +92,13 @@ type sessionEvent struct {
86
92
  // agent_call / input_request / permission / tool_call),只有 tool_use 事件有,
87
93
  // 与 SSE 流同源(见 agent/toolkind.go 与 events.go 的 toolKindOfEvent)。
88
94
  // 有了它,历史回放和实时渲染才会画出同一种卡 —— 否则回放那侧只能按名字猜。
89
- ToolKind string `json:"tool_kind,omitempty"`
90
- SessionID string `json:"session_id,omitempty"`
91
- Ask *agent.AskRequest `json:"ask,omitempty"`
95
+ ToolKind string `json:"tool_kind,omitempty"`
96
+ SessionID string `json:"session_id,omitempty"`
97
+ // SubSessionID 子会话 id(2026-10-21,团队模式):子任务自己的引擎会话 id,
98
+ // 绑定在主会话下面存档(列表里不单列)。落盘它是为了让**历史回放**也能在委派卡
99
+ // 上标出「这段活跑在哪个会话」—— 只展示,不参与日志文件名/锚点的判定。
100
+ SubSessionID string `json:"sub_session_id,omitempty"`
101
+ Ask *agent.AskRequest `json:"ask,omitempty"`
92
102
  /* 状态收敛四元组(2026-10-02)。落盘它们是为了让**历史回放与实时渲染
93
103
  画出同一种东西**:回放侧不必自己猜「这条消息到哪算完」,
94
104
  按 (item_id, item_revision) 覆盖即可。缺了它们,历史里每条 text
@@ -96,6 +106,10 @@ type sessionEvent struct {
96
106
  ItemID string `json:"item_id,omitempty"`
97
107
  ItemRevision uint64 `json:"item_revision,omitempty"`
98
108
  Status string `json:"status,omitempty"`
109
+ // Parent 父委派 id(2026-10-21,团队模式):落盘它是为了让**历史回放**也把子任务
110
+ // 的事件挂回委派卡 —— 实时流与回放必须同构,否则「当时看到的」和「事后回看的」
111
+ // 会长成两张脸(这是本仓库反复踩过的那类问题)。
112
+ Parent string `json:"parent,omitempty"`
99
113
  /* Snapshot 是「该 item 到此刻的累积整段」——**只读不写**(2026-10-20)。
100
114
  写盘时恒为空(见 appendLine):每条增量都带一份 ⇒ 二次方膨胀
101
115
  (实测一条会话 1.8GB,95.6% 是它)。
@@ -131,8 +145,15 @@ type sessionWriter struct {
131
145
  file *os.File
132
146
  w *bufio.Writer
133
147
  filePath string
134
- fileName string // 当前文件名(不含 .jsonl);空 = 还没开文件
135
- overflow atomic.Uint64 // 被 ring-buffer 丢弃的事件计数(诊断用)
148
+ fileName string // 当前文件名(不含 .jsonl);空 = 还没开文件
149
+ // lastSize 上次 flush 之后**本进程期望的**文件字节数。
150
+ // 下一个事件写盘前拿 Stat 比一比:文件比这个数还大 ⇒ 有别的 writer 往同一份日志
151
+ // 追加过(本进程的增量都在 bufio 里,落盘后由 flush 同步这里),seq 必须重新接号。
152
+ // 见 resyncSeq(2026-10-21 用户报障:两端状态不一致 / 一端永远「生成中」)。
153
+ lastSize int64
154
+ // warnedConcurrent 并发追加的提示只打一次(每行都打会把 stderr 刷爆)。
155
+ warnedConcurrent bool
156
+ overflow atomic.Uint64 // 被 ring-buffer 丢弃的事件计数(诊断用)
136
157
  // poke 让主 goroutine 催一句「现在按最新名字把文件开出来 / 改名」,
137
158
  // 不必等下一个事件 —— 否则「先 push 了 user 事件、随后才拿到 run_id」
138
159
  // 或「id 刚到手就进入长时间静默」这两种情况下,文件会迟迟不出现。
@@ -313,6 +334,12 @@ func (sw *sessionWriter) flush() {
313
334
  if sw.w != nil {
314
335
  _ = sw.w.Flush()
315
336
  }
337
+ // 记下我们期望的文件大小:下次 Stat 比它大 = 别人追加过(见 resyncSeq)。
338
+ if sw.file != nil {
339
+ if st, err := sw.file.Stat(); err == nil {
340
+ sw.lastSize = st.Size()
341
+ }
342
+ }
316
343
  }
317
344
 
318
345
  // write 是单条事件的落盘:先保证文件是最新名字的那个,再追加一行。
@@ -386,6 +413,7 @@ func (sw *sessionWriter) renameTo(name string) {
386
413
  _ = sw.file.Close()
387
414
  }
388
415
  sw.file, sw.w, sw.filePath, sw.fileName = nil, nil, "", ""
416
+ sw.lastSize = 0
389
417
  if _, err := os.Stat(newPath); err == nil {
390
418
  appendFileInto(newPath, oldPath)
391
419
  } else if err := os.Rename(oldPath, newPath); err != nil {
@@ -440,9 +468,14 @@ func (sw *sessionWriter) openFile(name string) error {
440
468
  sw.file = f
441
469
  sw.fileName = name
442
470
  sw.w = bufio.NewWriterSize(f, 32*1024) // 32KB 缓冲;另有 1s 的定时 flush 兜底(见 flush)
443
- if n := readMaxSeq(sw.filePath); n > 0 {
471
+ // 只升不降:换名字重开文件(run_id → session_id)时,本进程已经发出去的 seq
472
+ // 必须继续往上走;把计数器拨回文件里的旧值会让追加的事件与已写的重号。
473
+ if n := readMaxSeq(sw.filePath); n > sw.seq.Load() {
444
474
  sw.seq.Store(n)
445
475
  }
476
+ if st, err := f.Stat(); err == nil {
477
+ sw.lastSize = st.Size()
478
+ }
446
479
  return nil
447
480
  }
448
481
 
@@ -519,6 +552,7 @@ func lastSeqIn(b []byte) uint64 {
519
552
  //
520
553
  // ⚠️ 字段本身保留(读旧档要认,见 sessionEventFromJSON):只是不再**写**。
521
554
  func (sw *sessionWriter) appendLine(ev sessionEvent) {
555
+ sw.resyncSeq()
522
556
  ev.Seq = sw.seq.Add(1)
523
557
  if ev.At == "" {
524
558
  ev.At = time.Now().UTC().Format(time.RFC3339Nano)
@@ -542,6 +576,41 @@ func (sw *sessionWriter) appendLine(ev sessionEvent) {
542
576
  // 代价:断电最坏丢一小批;流路径完全不受影响。disk 也减少 90%+ 的 fsync。
543
577
  }
544
578
 
579
+ // resyncSeq 在**别的 writer** 往同一份日志追加过时,把 seq 重新接到文件末尾。
580
+ //
581
+ // 背景(2026-10-21,用户:「两边的回话状态不一致,一边结束了,一边还在显示运行中」):
582
+ // 同一会话被两个进程同时 `--resume` 时,两边各有一个 sessionWriter,都在 openFile
583
+ // 时读一次 max seq 然后并行 Add —— 文件里出现成对重号的 seq(实测一条 9506 条事件的
584
+ // 日志里 4589 个重号)。读侧按 `--after seq` 增量续读,另一条 lane 之后写的事件全都
585
+ // `seq <= after` 被滤掉:一端读到 turn_end 显示「已完成」,另一端永远等不到终态。
586
+ //
587
+ // 判据:文件比我们上次 flush 后期望的 size 还大 ⇒ 有人插队了。本进程自己的增量都
588
+ // 在 bufio 里,落盘后由 flush 同步 lastSize;bufio 满溢自动写盘时读到的 max seq
589
+ // 也不会超过 sw.seq,所以不会误报。
590
+ //
591
+ // 代价是每条事件一次 Stat(微秒级)。换来的是「重号」降级成「跳号」—— 跳号读侧
592
+ // 天然容忍(`seq > after` 过滤),重号则静默丢事件。
593
+ func (sw *sessionWriter) resyncSeq() {
594
+ if sw.file == nil {
595
+ return
596
+ }
597
+ st, err := sw.file.Stat()
598
+ if err != nil {
599
+ return
600
+ }
601
+ if st.Size() <= sw.lastSize {
602
+ return
603
+ }
604
+ if n := readMaxSeq(sw.filePath); n > sw.seq.Load() {
605
+ sw.seq.Store(n)
606
+ if !sw.warnedConcurrent {
607
+ sw.warnedConcurrent = true
608
+ fmt.Fprintf(os.Stderr, "magic-agent: 另一进程正在追加同一会话日志(%s),已跳号续写(两端进度可能不一致)\n", sw.filePath)
609
+ }
610
+ }
611
+ sw.lastSize = st.Size()
612
+ }
613
+
545
614
  // sessionEventFromJSON 把 jsonl 的一行解析成 sessionEvent。
546
615
  // 与 session.ReadSessionLog 配对使用:CLI 知道自己的 schema,调用方把"怎么解析"
547
616
  // 当成回调传过去,避免 session 包反向依赖 cli。
@@ -585,7 +654,7 @@ func ReadSessionLog(dir, sessionID string) ([]sessionEvent, error) {
585
654
  return session.ReadSessionLog(dir, sessionID, sessionEventFromJSON)
586
655
  }
587
656
 
588
- // ReadSessionLogAfter 增量读:只取 seq > after 的事件,并回磁盘上的最大 seq。
657
+ // ReadSessionLogAfter 增量读:只取 seq > after 的事件,并回**文件末尾那条**的 seq。
589
658
  //
590
659
  // 存在的理由与全量读不同:断线重连 / 长会话续看时不必重传整段历史(差一个数量级)。
591
660
  // dir 传空 = 用默认历史目录(与 ReadSessionLog 同一口径)。
@@ -428,6 +428,64 @@ func TestSessionWriterSessionIDIsTerminalName(t *testing.T) {
428
428
  }
429
429
  }
430
430
 
431
+ // TestSessionWriterConcurrentWritersDoNotReuseSeq 两个 writer 并行往同一份日志追加时,
432
+ // seq 必须**严格递增**(重号降级成跳号)。
433
+ //
434
+ // 背景(2026-10-21,用户:「两边的回话状态不一致,一边结束了,一边还在显示运行中」):
435
+ // 同一个 session 被两个进程同时 --resume,两边各自 openFile 读一次 max seq 后并行 Add,
436
+ // 文件里出现成对重号。增量读(--after seq)会把后一条 lane 的事件全部滤掉 ——
437
+ // 一端读到 turn_end 显示「已完成」,另一端永远等不到终态。见 resyncSeq。
438
+ func TestSessionWriterConcurrentWritersDoNotReuseSeq(t *testing.T) {
439
+ dir := t.TempDir()
440
+ path := filepath.Join(dir, "sess-race.jsonl")
441
+
442
+ a := newSessionWriter(dir, 1)
443
+ b := newSessionWriter(dir, 2)
444
+ a.setSessionID("sess-race")
445
+ b.setSessionID("sess-race")
446
+
447
+ // 等两边都把文件开出来(此时都读到空文件、seq 从 0 起)。
448
+ deadline := time.Now().Add(5 * time.Second)
449
+ for {
450
+ if _, err := os.Stat(path); err == nil {
451
+ break
452
+ }
453
+ if time.Now().After(deadline) {
454
+ t.Fatal("日志文件没被开出来")
455
+ }
456
+ time.Sleep(10 * time.Millisecond)
457
+ }
458
+ time.Sleep(100 * time.Millisecond) // 让后开的那条也把 openFile 走完
459
+
460
+ // A 先写两条并落盘(flush 后文件变大,B 的 lastSize 过期)。
461
+ a.push(sessionEvent{Kind: "text", Text: "a1"})
462
+ a.push(sessionEvent{Kind: "text", Text: "a2"})
463
+ a.close()
464
+ waitFileLines(t, path, 2)
465
+
466
+ // B 再写一条:必须重新接号,不能从 1 重来。
467
+ b.push(sessionEvent{Kind: "text", Text: "b1"})
468
+ b.close()
469
+
470
+ lines := readLines(t, path)
471
+ if len(lines) != 3 {
472
+ t.Fatalf("应有 3 行,实得 %d:%v", len(lines), lines)
473
+ }
474
+ var seqs []uint64
475
+ for i, ln := range lines {
476
+ var ev sessionEvent
477
+ if err := json.Unmarshal([]byte(ln), &ev); err != nil {
478
+ t.Fatalf("第 %d 行解析失败:%v", i, err)
479
+ }
480
+ seqs = append(seqs, ev.Seq)
481
+ }
482
+ for i := 1; i < len(seqs); i++ {
483
+ if seqs[i] <= seqs[i-1] {
484
+ t.Fatalf("seq 必须严格递增(重号会让增量读静默丢事件),实得 %v", seqs)
485
+ }
486
+ }
487
+ }
488
+
431
489
  // drain 等 writer 的 in 通道全部写完。close 之后再等它把所有 pending 全 flush。
432
490
  func drain(t *testing.T, sw *sessionWriter) {
433
491
  t.Helper()