magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -8,11 +8,13 @@ import (
8
8
  "image"
9
9
  "image/png"
10
10
  "io"
11
+ "log"
11
12
  "mime/multipart"
12
13
  "net/http"
13
14
  "net/http/httptest"
14
15
  "net/url"
15
16
  "os"
17
+ "os/exec"
16
18
  "path/filepath"
17
19
  "strings"
18
20
  "testing"
@@ -66,6 +68,23 @@ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
66
68
 
67
69
  const goodContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
68
70
 
71
+ // fakeCoreWithSessions 与 fakeCore 相同,只是 `--sessions` 返回调用方给的那段 JSON。
72
+ // 用于断言「插件是否原样透传 core 给的字段」——字段集合必须由用例决定,
73
+ // 写死在 fakeCore 里就测不出透传。
74
+ func fakeCoreWithSessions(t *testing.T, sessionsJSON string) string {
75
+ t.Helper()
76
+ dir := t.TempDir()
77
+ path := filepath.Join(dir, "magic-agent")
78
+ script := "#!/bin/sh\ncase \"$*\" in\n" +
79
+ " *--contract*)\n echo '" + goodContract + "'\n exit 0\n ;;\n" +
80
+ " *--sessions*)\n cat <<'EOF'\n" + sessionsJSON + "\nEOF\n exit 0\n ;;\n" +
81
+ "esac\nexit 0\n"
82
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ return path
86
+ }
87
+
69
88
  // hostedContract 多了托管历史标识的能力位(2026-10-23 只增)。
70
89
  const hostedContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control","feature.session.hosted"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
71
90
 
@@ -415,7 +434,8 @@ func TestAskRelaysAndControl(t *testing.T) {
415
434
 
416
435
  // TestSessionsProxy 列表页要的是 core 的**持久会话登记表**(`--sessions`),
417
436
  // 且只回「本客户端 + 同族 magic-* 来源」那些(原口径见用户 2026-10-12
418
- // 「不是 magic-agent 客户端调用的任务不显示」;2026-10-15 放宽到名单,见 extraSessionClients)。
437
+ // 「不是 magic-agent 客户端调用的任务不显示」;2026-10-15 放宽到名单,
438
+ // 2026-10-26 改成按同族前缀判,见 sessionClientVisible)。
419
439
  //
420
440
  // 登记表目录是全机共享的,假 core 故意返回四种来源:本客户端的、没有 client 的
421
441
  // (= 直接跑 CLI / 旧记录)、别的应用的、掌天瓶(magic-test)的 —— 匿名那条与别家应用仍被滤掉,
@@ -452,6 +472,84 @@ func TestSessionsProxy(t *testing.T) {
452
472
  }
453
473
  }
454
474
 
475
+ // sessionClientVisible 同族判定(2026-10-26,用户:「多个客户端要能拉到同样的会话历史」)。
476
+ //
477
+ // 这条用例钉的是**维护点消失**:此前放行名单是写死的 {magic-test, magic-agent},
478
+ // 每接一个新客户端都要回来改常量;忘改的表现是那个客户端静默少一堆会话。
479
+ // 现在按 `magic-` 前缀判,新增客户端不必再动代码。
480
+ func TestSessionClientVisible(t *testing.T) {
481
+ const self = "com.magic.test"
482
+ cases := []struct {
483
+ client string
484
+ want bool
485
+ why string
486
+ }{
487
+ // ① 自己
488
+ {self, true, "本插件自己的 AppID"},
489
+ {"", false, "匿名 = 手工跑 / CI / 旧记录,不混进本应用列表"},
490
+ {" ", false, "只有空白等同匿名"},
491
+ // ② 同族(前缀 magic-)
492
+ {"magic-test", true, "掌天瓶桌面壳(不经插件,直接 exec core)"},
493
+ {"magic-agent", true, "core 自身 / 直连 CLI 的显式标识"},
494
+ {"magic-stock", true, "将来新增的 magic-* 客户端:**不该再回来改代码**"},
495
+ {"magic-foo-bar", true, "同族任意名字"},
496
+ // ③ 异族:前缀相近但不是这一族
497
+ {"magicclient", false, "没有连字符 → 不是本族命名约定"},
498
+ {"magical-tool", false, "magical- 不是 magic- 前缀"},
499
+ {"other-app", false, "不相干应用"},
500
+ {"com.example.app", false, "不相干应用(bundleId 形态)"},
501
+ }
502
+ for _, c := range cases {
503
+ if got := sessionClientVisible(c.client, self); got != c.want {
504
+ t.Errorf("sessionClientVisible(%q) = %v,want %v(%s)", c.client, got, c.want, c.why)
505
+ }
506
+ }
507
+ }
508
+
509
+ // TestSessionsProxyKeepsUnknownFields:`/desk/sessions` 必须**原样透传** core 给的字段。
510
+ // handleSessions 刻意解成 []map[string]any 而不是结构体,就是为了 core 往后给会话行加
511
+ // 字段时插件不用跟着改。2026-10-26 多客户端仲裁新增的 `workspace_locked` /
512
+ // `lock_holder` 正是靠这条路径到达手机端与 PC 端的 —— 谁把这行改成结构体、又忘了补字段,
513
+ // 界面上就是「在手机上看着能发,按下去卡十分钟」,且没有任何报错可查。
514
+ // 所以这里用一条**不在任何结构体里**的字段来钉住透传语义(比钉具体字段名更耐久)。
515
+ func TestSessionsProxyKeepsUnknownFields(t *testing.T) {
516
+ core := fakeCoreWithSessions(t, `[{"run_id":"run-1","session_id":"sess-1","engine":"claude",
517
+ "state":"running","source":"pc","client":"magic-client",
518
+ "workspace_locked":true,
519
+ "lock_holder":{"pid":4242,"client":"magic-test","source":"pc"}}]`)
520
+ svc := newTestService(t, core)
521
+ srv := httptest.NewServer(svc.Handler())
522
+ defer srv.Close()
523
+
524
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/sessions", nil)
525
+ req.Header.Set("X-Magic-Token", "tok")
526
+ resp, err := http.DefaultClient.Do(req)
527
+ if err != nil {
528
+ t.Fatal(err)
529
+ }
530
+ defer resp.Body.Close()
531
+
532
+ // 用 map 解,不假设字段集合:断言的是「这些字段确实出来了」。
533
+ var rows []map[string]any
534
+ if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
535
+ t.Fatal(err)
536
+ }
537
+ if len(rows) != 1 {
538
+ t.Fatalf("应剩 1 条,实际 %d: %+v", len(rows), rows)
539
+ }
540
+ if v, ok := rows[0]["workspace_locked"]; !ok || v != true {
541
+ t.Fatalf("workspace_locked 被吞了(多客户端排队提示会失效):%+v", rows[0])
542
+ }
543
+ holder, ok := rows[0]["lock_holder"].(map[string]any)
544
+ if !ok {
545
+ t.Fatalf("lock_holder 被吞了(客户端答不出「在等谁」):%+v", rows[0])
546
+ }
547
+ /* JSON 数字解成 float64 —— 断言值本身即可,别在这里比类型。 */
548
+ if pid, _ := holder["pid"].(float64); int(pid) != 4242 {
549
+ t.Errorf("lock_holder.pid = %v,want 4242", holder["pid"])
550
+ }
551
+ }
552
+
455
553
  func TestAskPassesSaveHistory(t *testing.T) {
456
554
  svc := newTestService(t, fakeCore(t, goodContract))
457
555
  srv := httptest.NewServer(svc.Handler())
@@ -518,6 +616,13 @@ func TestAskInjectsClientEnv(t *testing.T) {
518
616
  func TestAskInjectsProfileAppID(t *testing.T) {
519
617
  prof := DefaultProfile()
520
618
  prof.App.BundleID = "magic-test"
619
+ /* ⚠️ 必须把数据目录钉到临时目录(2026-10-26 实测)。
620
+ 这条用例走的是**真实** New() → 真的建作业队列并落盘。DefaultProfile() 的
621
+ Storage.Dir 是空的,于是 DataDir() 回落到 ~/.magic-client/<AppID>;而这里
622
+ 把 BundleID 改成了 magic-test —— 结果就是每次跑测试都往用户的
623
+ ~/.magic-client/magic-test/jobs 里写一条 prompt=hi、engine=claude 的作业。
624
+ 那条作业的 workspace 还是 internal/client,看起来像真任务,排查时极具误导性。 */
625
+ prof.Storage.Dir = t.TempDir()
521
626
  svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
522
627
  if err != nil {
523
628
  t.Fatal(err)
@@ -718,6 +823,57 @@ func TestModulesStatic(t *testing.T) {
718
823
  }
719
824
  }
720
825
 
826
+ // TestRootServesMobileUIToPhone 手机打开 "/" 必须落到移动端界面,且令牌随查询串带过去;
827
+ // 桌面 UA、以及「移动端界面不存在」两种情况仍给 PC 页 —— 后者保证不会把手机送进 404。
828
+ func TestRootServesMobileUIToPhone(t *testing.T) {
829
+ const iphoneUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
830
+ const desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
831
+
832
+ uiDir, mobileDir := t.TempDir(), t.TempDir()
833
+ for dir, body := range map[string]string{uiDir: "PC", mobileDir: "MOBILE"} {
834
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(body), 0o644); err != nil {
835
+ t.Fatal(err)
836
+ }
837
+ }
838
+ // 直接构造 Service、只测 handleStatic:这条逻辑不碰 core,测试也就不需要假 core 子进程
839
+ // (那是个 sh 脚本,Windows 上跑不起来,见 fakeCore)。
840
+ newSvc := func(mobileRoot string) *Service {
841
+ return &Service{opts: Options{Profile: DefaultProfile(), UIRoot: uiDir, MobileRoot: mobileRoot}}
842
+ }
843
+ get := func(t *testing.T, svc *Service, path, ua string) *httptest.ResponseRecorder {
844
+ t.Helper()
845
+ req := httptest.NewRequest(http.MethodGet, path, nil)
846
+ req.Header.Set("User-Agent", ua)
847
+ rec := httptest.NewRecorder()
848
+ svc.handleStatic(rec, req)
849
+ return rec
850
+ }
851
+
852
+ t.Run("手机访问根路径跳移动端", func(t *testing.T) {
853
+ rec := get(t, newSvc(mobileDir), "/?token=tok", iphoneUA)
854
+ if rec.Code != http.StatusFound {
855
+ t.Fatalf("应 302,实际 %d", rec.Code)
856
+ }
857
+ if loc := rec.Header().Get("Location"); loc != "/m/?token=tok" {
858
+ t.Errorf("跳转目标不对:%s", loc)
859
+ }
860
+ })
861
+
862
+ t.Run("桌面访问根路径仍是PC页", func(t *testing.T) {
863
+ rec := get(t, newSvc(mobileDir), "/?token=tok", desktopUA)
864
+ if rec.Code != http.StatusOK || rec.Body.String() != "PC" {
865
+ t.Errorf("桌面应拿到 PC 页,实际 %d / %q", rec.Code, rec.Body.String())
866
+ }
867
+ })
868
+
869
+ t.Run("没有移动端界面时不跳", func(t *testing.T) {
870
+ rec := get(t, newSvc(t.TempDir()), "/?token=tok", iphoneUA) // 空目录 ⇒ hasMobileUI=false
871
+ if rec.Code != http.StatusOK || rec.Body.String() != "PC" {
872
+ t.Errorf("应退回 PC 页,实际 %d / %q", rec.Code, rec.Body.String())
873
+ }
874
+ })
875
+ }
876
+
721
877
  func TestUploadReadBackAndTraversalGuard(t *testing.T) {
722
878
  svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: DefaultProfile(), Token: "tok"})
723
879
  if err != nil {
@@ -1220,3 +1376,63 @@ func multipartWrite(buf *bytes.Buffer, field, name string, content []byte) strin
1220
1376
  _ = w.Close()
1221
1377
  return w.FormDataContentType()
1222
1378
  }
1379
+
1380
+ /*
1381
+ WatchHost(2026-10-26):重启出来的新实例是 detach 的,桌面壳的 r.stop() 杀不到它。
1382
+ 没有这道闸,壳退出后会留下孤儿占着端口与单实例锁,下次开壳「插件启动即退出」。
1383
+
1384
+ 两个方向都要钉住:
1385
+
1386
+ ① 托管进程没了 → 必须调用 quit(服务主动收摊);
1387
+ ② 没有托管进程(HostPID=0,`magic-client start` 起的常驻实例)→ 永远不调用 quit
1388
+ (常驻实例本来就该活过启动它的终端)。
1389
+ */
1390
+ func TestWatchHostQuitsWhenHostGone(t *testing.T) {
1391
+ old := hostPollInterval
1392
+ hostPollInterval = 5 * time.Millisecond
1393
+ defer func() { hostPollInterval = old }()
1394
+
1395
+ // 造一个「已经不存在的 pid」:起一个马上退出的子进程,等它被回收。
1396
+ cmd := exec.Command(os.Args[0], "-test.run=TestWatchHostHelperProcess")
1397
+ cmd.Env = append(os.Environ(), "WATCH_HOST_HELPER=1")
1398
+ if err := cmd.Start(); err != nil {
1399
+ t.Fatal(err)
1400
+ }
1401
+ deadPID := cmd.Process.Pid
1402
+ _ = cmd.Wait()
1403
+
1404
+ quit := make(chan struct{}, 1)
1405
+ s := &Service{log: log.New(io.Discard, "", 0), opts: Options{HostPID: deadPID}}
1406
+ s.WatchHost(func() { quit <- struct{}{} })
1407
+
1408
+ select {
1409
+ case <-quit:
1410
+ case <-time.After(3 * time.Second):
1411
+ t.Fatal("托管进程已退出,但服务没有收摊(会留下孤儿占端口)")
1412
+ }
1413
+ }
1414
+
1415
+ // 常驻实例(HostPID=0)不该被这道闸影响。
1416
+ func TestWatchHostIgnoresUnmanaged(t *testing.T) {
1417
+ old := hostPollInterval
1418
+ hostPollInterval = 5 * time.Millisecond
1419
+ defer func() { hostPollInterval = old }()
1420
+
1421
+ quit := make(chan struct{}, 1)
1422
+ s := &Service{log: log.New(io.Discard, "", 0)} // HostPID = 0
1423
+ s.WatchHost(func() { quit <- struct{}{} })
1424
+
1425
+ select {
1426
+ case <-quit:
1427
+ t.Fatal("没有托管进程的常驻实例不该自己退出")
1428
+ case <-time.After(100 * time.Millisecond):
1429
+ }
1430
+ }
1431
+
1432
+ // TestWatchHostHelperProcess 只作为「一个会被立刻回收的子进程」存在。
1433
+ func TestWatchHostHelperProcess(t *testing.T) {
1434
+ if os.Getenv("WATCH_HOST_HELPER") == "1" {
1435
+ return
1436
+ }
1437
+ t.Skip("仅作为子进程桩使用")
1438
+ }
@@ -0,0 +1,148 @@
1
+ package client
2
+
3
+ // sessions_cache.go - 会话清单(/desk/sessions)的短 TTL 缓存 + 单飞。
4
+ //
5
+ // 2026-10-26 用户报障「会话列表现在显示很慢」。实测(同一台机器、同一份登记表):
6
+ //
7
+ // core 直跑 --sessions 18~22ms
8
+ // 经本插件 /desk/sessions 200~900ms(负载 9~11 时接近 1s)
9
+ // 10 并发同一接口 每个 1.6s,墙钟 1.7s(没有串行锁,纯进程争抢)
10
+ // /dsk/health(不 spawn core) 1~3ms
11
+ // /desk/titles(读缓存文件) <1ms
12
+ //
13
+ // 也就是说:慢的全部来自「每次请求都 spawn 一个 core 进程」这一步,而不是数据量
14
+ // (空目录 6ms vs 407 条记录 20ms,同一量级)。而列表页恰恰是**轮询**驱动的:
15
+ //
16
+ // · 移动端主列表每 5s 拉一次 /desk/sessions(startListPoll)
17
+ // · 它每次 loadSessions 还会并发问 /desk/titles、/health、/dirs、/jobs 共 5 个接口
18
+ // · 队列放行前的探活(PROBE_MS=700ms)、串行门(SLOT_POLL_MS=3s)、完成通知(5s)
19
+ // 各自还有一份 /desk/sessions 轮询
20
+ //
21
+ // 于是「打开一次列表」= 每 5 秒起一个新进程,机器忙时排队叠加,用户看到的就是一顿一顿。
22
+ //
23
+ // 两条修法,都在这一层做(不动 core,也不要求客户端改调用方式):
24
+ //
25
+ // ① 短 TTL 缓存:TTL 内直接回上一次的结果。列表页对「晚 1 秒」无感 —— 它本来就在
26
+ // 5s 轮询,而会话状态变化(开跑 / 收尾 / 排队)走的是事件流,不依赖这份清单的实时性。
27
+ // ② 单飞(single-flight):TTL 过期瞬间若来了 N 个并发请求,只让**一个**去 spawn
28
+ // core,其余等同一次结果。这正是上面「10 并发 1.6s」那组数字的解药。
29
+ //
30
+ // ⚠️ 缓存什么、不缓存什么:
31
+ // - 缓存的是 **core 的原始输出**(`--sessions` 的 JSON 字节),不是过滤后的结果。
32
+ // client 过滤依赖 selfID(每个实例一个),缓存原始输出才能保证不同实例各按自己的
33
+ // AppID 过一遍 —— 缓存过滤结果的话,第二个实例会拿到别人的列表。
34
+ // - **不缓存失败**:spawn 失败必须每次都真去试,否则一次瞬时故障会把列表钉死 1 秒。
35
+ // - 写操作(删除 / 停 / 改标题 / 派发)后由调用方调 invalidateSessionsCache() 主动失效,
36
+ // 避免「刚删掉的会话还在列表里停留一个 TTL」这种看起来像 bug 的现象。
37
+
38
+ import (
39
+ "context"
40
+ "os"
41
+ "strings"
42
+ "sync"
43
+ "time"
44
+ )
45
+
46
+ /* sessionsCacheTTL 缓存的存活时长。
47
+ *
48
+ * 取值依据:轮询间隔最短的是队列探活 PROBE_MS=700ms(那一段用户正在等任务落地,要快),
49
+ * 其次是主列表 5s。1s 的意思是「同一秒内的所有轮询合并成一次 spawn」——
50
+ * 移动端一次 loadSessions 的 5 个并发请求、以及多个轮询器同刻撞上,全部命中同一份。
51
+ * 再长就会让「刚发起的任务」在列表上晚一拍出现,那是用户看得见的。
52
+ *
53
+ * 可被 MAGIC_CLIENT_SESSIONS_TTL 覆盖(测试用:设 0 关缓存,设很大的值验证命中)。 */
54
+ const sessionsCacheTTL = time.Second
55
+
56
+ // sessionsCache 会话清单的短 TTL 缓存(并发安全)。
57
+ //
58
+ // 零值可用:body 为空表示还没缓存过。
59
+ type sessionsCache struct {
60
+ mu sync.Mutex
61
+ body []byte // core `--sessions` 的原始输出
62
+ expiry time.Time // 这份数据的过期时刻
63
+
64
+ /* flight 单飞:非 nil 表示「已经有一个请求在 spawn core」,其余请求等它。
65
+ 用它而不用 sync.Once / 每请求一把锁,是因为要能**跨请求**共享同一次结果 ——
66
+ TTL 刚过的那一瞬间往往正好是几个轮询器同时醒来。 */
67
+ flight *sessionsFlight
68
+ }
69
+
70
+ // sessionsFlight 一次进行中的 core 调用,别的请求 await 它的结果。
71
+ type sessionsFlight struct {
72
+ done chan struct{}
73
+ body []byte
74
+ err error
75
+ }
76
+
77
+ // get 取清单:命中未过期的缓存直接返回;否则(单飞地)真去 spawn 一次 core。
78
+ //
79
+ // fetch 是真正取数的函数(生产里是 spawn core + 读 stdout)。它只会被**一个**并发
80
+ // 请求执行,其余请求等这一个的结果 —— 这是本文件存在的核心价值。
81
+ func (c *sessionsCache) get(ctx context.Context, ttl time.Duration, fetch func(context.Context) ([]byte, error)) ([]byte, error) {
82
+ c.mu.Lock()
83
+ if ttl > 0 && c.body != nil && time.Now().Before(c.expiry) {
84
+ body := c.body
85
+ c.mu.Unlock()
86
+ return body, nil
87
+ }
88
+ if f := c.flight; f != nil {
89
+ // 已经有人在取了:等它,别再起一个 core。
90
+ c.mu.Unlock()
91
+ select {
92
+ case <-f.done:
93
+ return f.body, f.err
94
+ case <-ctx.Done():
95
+ // 等的人自己超时了:如实报错,但**不要**去动别人的 flight。
96
+ return nil, ctx.Err()
97
+ }
98
+ }
99
+ f := &sessionsFlight{done: make(chan struct{})}
100
+ c.flight = f
101
+ c.mu.Unlock()
102
+
103
+ body, err := fetch(ctx)
104
+
105
+ c.mu.Lock()
106
+ f.body, f.err = body, err
107
+ c.flight = nil
108
+ /* 只缓存成功结果:失败必须下次真去试(否则一次瞬时故障把列表钉死一个 TTL)。
109
+ 另外缓存的是原始输出 —— 见文件头「缓存什么」那段。 */
110
+ if err == nil && ttl > 0 {
111
+ c.body = body
112
+ c.expiry = time.Now().Add(ttl)
113
+ }
114
+ c.mu.Unlock()
115
+ close(f.done) // 唤醒所有等的人(必须在释放锁之后:他们醒来第一件事可能就是抢锁)
116
+ return body, err
117
+ }
118
+
119
+ // invalidate 主动失效缓存(写操作之后调)。
120
+ //
121
+ // 为什么需要:删会话 / 停会话 / 新派发之后,列表应当**立刻**反映。不失效的话用户会看到
122
+ // 「我刚删的那条还在」,然后过一秒自己消失 —— 那种体验比慢更让人怀疑程序。
123
+ func (c *sessionsCache) invalidate() {
124
+ c.mu.Lock()
125
+ c.body = nil
126
+ c.expiry = time.Time{}
127
+ c.mu.Unlock()
128
+ }
129
+
130
+ // invalidateSessionsCache 让下一次 /desk/sessions 重新 spawn core。
131
+ func (s *Service) invalidateSessionsCache() { s.sessCache.invalidate() }
132
+
133
+ // sessionsTTL 当前生效的缓存时长。
134
+ //
135
+ // 默认 sessionsCacheTTL(1s);MAGIC_CLIENT_SESSIONS_TTL 可覆盖(Go duration 写法,
136
+ // 如 "0" 关缓存 / "5s" 加长)。只给测试与排障用 —— 生产不该靠环境变量调性能。
137
+ // 值解析失败按默认走(宁可慢一点,也不要因为一个笔误把列表变成不刷新)。
138
+ func sessionsTTL() time.Duration {
139
+ v := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_SESSIONS_TTL"))
140
+ if v == "" {
141
+ return sessionsCacheTTL
142
+ }
143
+ d, err := time.ParseDuration(v)
144
+ if err != nil || d < 0 {
145
+ return sessionsCacheTTL
146
+ }
147
+ return d
148
+ }
@@ -0,0 +1,163 @@
1
+ package client
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "sync"
7
+ "sync/atomic"
8
+ "testing"
9
+ "time"
10
+ )
11
+
12
+ // TTL 内第二次取数必须命中缓存 —— 这是「会话列表慢」的主要修法:
13
+ // 列表页每 5s 轮询一次、一次 loadSessions 还并发问 5 个接口,全都要合并成一次 spawn。
14
+ func TestSessionsCacheHitsWithinTTL(t *testing.T) {
15
+ var calls int32
16
+ var c sessionsCache
17
+ fetch := func(context.Context) ([]byte, error) {
18
+ atomic.AddInt32(&calls, 1)
19
+ return []byte("[1]"), nil
20
+ }
21
+ for i := 0; i < 5; i++ {
22
+ body, err := c.get(context.Background(), time.Minute, fetch)
23
+ if err != nil || string(body) != "[1]" {
24
+ t.Fatalf("第 %d 次取数失败: %v %s", i, err, body)
25
+ }
26
+ }
27
+ if got := atomic.LoadInt32(&calls); got != 1 {
28
+ t.Fatalf("TTL 内只该 spawn 一次,实际 %d 次", got)
29
+ }
30
+ }
31
+
32
+ // TTL 过期后必须重新取数(不能把列表钉死在旧数据上)。
33
+ func TestSessionsCacheRefetchesAfterTTL(t *testing.T) {
34
+ var calls int32
35
+ var c sessionsCache
36
+ fetch := func(context.Context) ([]byte, error) {
37
+ n := atomic.AddInt32(&calls, 1)
38
+ return []byte{byte('0' + n)}, nil
39
+ }
40
+ if _, err := c.get(context.Background(), 0, fetch); err != nil { // ttl=0 = 不缓存
41
+ t.Fatal(err)
42
+ }
43
+ if _, err := c.get(context.Background(), 0, fetch); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if got := atomic.LoadInt32(&calls); got != 2 {
47
+ t.Fatalf("ttl=0 时每次都该真取,实际 %d 次", got)
48
+ }
49
+ }
50
+
51
+ // 失败**不缓存**:一次瞬时故障不该让列表在下一个 TTL 里继续报错。
52
+ func TestSessionsCacheDoesNotCacheErrors(t *testing.T) {
53
+ var calls int32
54
+ var c sessionsCache
55
+ boom := errors.New("spawn 失败")
56
+ fetch := func(context.Context) ([]byte, error) {
57
+ if atomic.AddInt32(&calls, 1) == 1 {
58
+ return nil, boom
59
+ }
60
+ return []byte("ok"), nil
61
+ }
62
+ if _, err := c.get(context.Background(), time.Minute, fetch); !errors.Is(err, boom) {
63
+ t.Fatalf("第一次应如实返回错误,实际 %v", err)
64
+ }
65
+ body, err := c.get(context.Background(), time.Minute, fetch)
66
+ if err != nil || string(body) != "ok" {
67
+ t.Fatalf("失败被缓存了:err=%v body=%s", err, body)
68
+ }
69
+ if got := atomic.LoadInt32(&calls); got != 2 {
70
+ t.Fatalf("失败后应再试一次,实际 %d 次", got)
71
+ }
72
+ }
73
+
74
+ // 单飞:并发请求只 spawn 一个 core。这就是「10 并发各 1.6s」那组数字的解药。
75
+ func TestSessionsCacheSingleFlight(t *testing.T) {
76
+ var calls int32
77
+ var c sessionsCache
78
+ release := make(chan struct{})
79
+ fetch := func(context.Context) ([]byte, error) {
80
+ atomic.AddInt32(&calls, 1)
81
+ <-release // 卡住,让所有并发请求都进来等同一趟
82
+ return []byte("shared"), nil
83
+ }
84
+ const n = 10
85
+ var wg sync.WaitGroup
86
+ bodies := make([]string, n)
87
+ errs := make([]error, n)
88
+ for i := 0; i < n; i++ {
89
+ wg.Add(1)
90
+ go func(i int) {
91
+ defer wg.Done()
92
+ body, err := c.get(context.Background(), time.Minute, fetch)
93
+ bodies[i], errs[i] = string(body), err
94
+ }(i)
95
+ }
96
+ // 等所有 goroutine 都进到 get 里,再放行 fetch。
97
+ time.Sleep(100 * time.Millisecond)
98
+ close(release)
99
+ wg.Wait()
100
+
101
+ if got := atomic.LoadInt32(&calls); got != 1 {
102
+ t.Fatalf("%d 个并发请求只该 spawn 一次,实际 %d 次", n, got)
103
+ }
104
+ for i := 0; i < n; i++ {
105
+ if errs[i] != nil || bodies[i] != "shared" {
106
+ t.Fatalf("第 %d 个请求拿到 %q / %v,应该都拿到同一份结果", i, bodies[i], errs[i])
107
+ }
108
+ }
109
+ }
110
+
111
+ // 失效后必须重新取数(删除会话 / 改标题之后靠它让列表立刻反映)。
112
+ func TestSessionsCacheInvalidate(t *testing.T) {
113
+ var calls int32
114
+ var c sessionsCache
115
+ fetch := func(context.Context) ([]byte, error) {
116
+ atomic.AddInt32(&calls, 1)
117
+ return []byte("x"), nil
118
+ }
119
+ if _, err := c.get(context.Background(), time.Minute, fetch); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ c.invalidate()
123
+ if _, err := c.get(context.Background(), time.Minute, fetch); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ if got := atomic.LoadInt32(&calls); got != 2 {
127
+ t.Fatalf("失效后应重新取数,实际 %d 次", got)
128
+ }
129
+ }
130
+
131
+ // 等待方超时不能连带毁掉别人的 flight(别人还要拿结果)。
132
+ func TestSessionsCacheWaitersTimeoutIndependently(t *testing.T) {
133
+ var c sessionsCache
134
+ release := make(chan struct{})
135
+ fetch := func(context.Context) ([]byte, error) {
136
+ <-release
137
+ return []byte("slow"), nil
138
+ }
139
+ go func() { _, _ = c.get(context.Background(), time.Minute, fetch) }()
140
+ time.Sleep(50 * time.Millisecond)
141
+
142
+ // 这个请求自己超时:应当拿到 ctx 错误,而不是 hang 住。
143
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
144
+ defer cancel()
145
+ if _, err := c.get(ctx, time.Minute, fetch); !errors.Is(err, context.DeadlineExceeded) {
146
+ t.Fatalf("等待方应返回超时,实际 %v", err)
147
+ }
148
+ // 把原件放行,确认它仍能正常完成(没被等待方的超时带崩)。
149
+ close(release)
150
+ deadline := time.Now().Add(time.Second)
151
+ for {
152
+ c.mu.Lock()
153
+ ready := c.body != nil
154
+ c.mu.Unlock()
155
+ if ready {
156
+ break
157
+ }
158
+ if time.Now().After(deadline) {
159
+ t.Fatal("原件没完成:等待方的超时可能误伤了它")
160
+ }
161
+ time.Sleep(10 * time.Millisecond)
162
+ }
163
+ }