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
@@ -0,0 +1,368 @@
1
+ package session
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "strconv"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ /* 多客户端仲裁的不变量(2026-10-26)。
15
+ *
16
+ * 用户定调:「是 magic-agent 本身要支持多客户端」。
17
+ * 也就是说,仲裁必须在 **core**,不能指望每个宿主自己排一遍 ——
18
+ * 能起轮次的地方有桌面壳、HTTP 插件、手机、手工 CLI、CI,
19
+ * 少一个宿主漏实现,同一批文件就已经被两个引擎改了。
20
+ *
21
+ * 这些用例钉的就是「core 自己扛得住」那几条,与宿主无关:
22
+ * ① 锁默认生效(不需要任何宿主注入环境变量);
23
+ * ② 能关(跑批 / 测试 / 团队模式要能显式退出);
24
+ * ③ 等待方能读到「谁占着」(而不是只知道「有人在跑」);
25
+ * ④ 跨客户端的重复接管**不静默杀**(同客户端才接管);
26
+ * ⑤ 兼容旧版锁文件(升级途中不能让两代进程各认一种格式而同时跑)。
27
+ */
28
+
29
+ // TestWorkspaceLockDefaultsOn 不设任何环境变量,锁也必须生效 ——
30
+ // 这是「magic-agent 本身支持多客户端」的最小含义:不依赖宿主注入。
31
+ func TestWorkspaceLockDefaultsOn(t *testing.T) {
32
+ t.Setenv(EnvDir, t.TempDir())
33
+ // 显式清掉,模拟「宿主什么都没配」的真实情况(t.Setenv 只在本用例内生效)
34
+ t.Setenv(EnvWorkspaceLock, "zzz-unset")
35
+ t.Setenv(EnvWorkspaceLockWait, "1s")
36
+ ws := t.TempDir()
37
+
38
+ h1, err := Begin(BeginOptions{Workspace: ws, Engine: "claude", Client: "client-a"})
39
+ if err != nil {
40
+ t.Fatalf("第一轮 Begin 失败:%v", err)
41
+ }
42
+ // 同 workspace 的第二轮必须等(不是立刻拿到)
43
+ done := make(chan error, 1)
44
+ go func() {
45
+ h2, err := Begin(BeginOptions{Workspace: ws, Engine: "claude", Client: "client-b"})
46
+ if err == nil {
47
+ h2.Finish("", StateDone)
48
+ }
49
+ done <- err
50
+ }()
51
+ select {
52
+ case err := <-done:
53
+ t.Fatalf("默认启用时,同 workspace 的第二轮不应立刻拿到锁(err=%v)", err)
54
+ case <-time.After(300 * time.Millisecond):
55
+ }
56
+ h1.Finish("", StateDone)
57
+ select {
58
+ case <-done:
59
+ case <-time.After(3 * time.Second):
60
+ t.Fatal("第一轮结束后,第二轮仍未拿到锁")
61
+ }
62
+ }
63
+
64
+ // TestWorkspaceLockCanBeDisabled 显式关掉时必须真的不排 ——
65
+ // 跑批 / 测试 / 团队模式依赖这个开关,不能被「默认启用」连坐。
66
+ func TestWorkspaceLockCanBeDisabled(t *testing.T) {
67
+ t.Setenv(EnvDir, t.TempDir())
68
+ t.Setenv(EnvWorkspaceLock, "0")
69
+ ws := t.TempDir()
70
+
71
+ h1, err := Begin(BeginOptions{Workspace: ws, Engine: "claude"})
72
+ if err != nil {
73
+ t.Fatalf("Begin(1): %v", err)
74
+ }
75
+ // 关掉之后第二轮立刻拿到(不做串行)
76
+ h2, err := Begin(BeginOptions{Workspace: ws, Engine: "claude"})
77
+ if err != nil {
78
+ t.Fatalf("关掉锁之后第二轮应立刻拿到:%v", err)
79
+ }
80
+ h2.Finish("", StateDone)
81
+ h1.Finish("", StateDone)
82
+ }
83
+
84
+ // TestWorkspaceLockHolderIsReadable 等待方要能读到「谁占着」。
85
+ // 只写 pid 的话,界面只能说「有人在跑」,用户不知道该去停哪一端。
86
+ func TestWorkspaceLockHolderIsReadable(t *testing.T) {
87
+ t.Setenv(EnvDir, t.TempDir())
88
+ t.Setenv(EnvWorkspaceLock, "1")
89
+ t.Setenv(EnvWorkspaceLockWait, "1s")
90
+ ws := t.TempDir()
91
+
92
+ h, err := Begin(BeginOptions{Workspace: ws, Engine: "claude", Client: "com.magic.test", Source: SourceMobile})
93
+ if err != nil {
94
+ t.Fatalf("Begin: %v", err)
95
+ }
96
+ defer h.Finish("", StateDone)
97
+
98
+ got, ok := LockHolderFor(ws)
99
+ if !ok {
100
+ t.Fatal("锁已持有,LockHolderFor 应报出来")
101
+ }
102
+ if got.PID != os.Getpid() {
103
+ t.Errorf("holder pid = %d, want %d", got.PID, os.Getpid())
104
+ }
105
+ if got.Client != "com.magic.test" {
106
+ t.Errorf("holder client = %q, want com.magic.test(界面据此说「手机上那条」)", got.Client)
107
+ }
108
+ if got.Source != SourceMobile {
109
+ t.Errorf("holder source = %q, want %q", got.Source, SourceMobile)
110
+ }
111
+ /* run_id 是取锁之后补写的:没有它,界面上那句「pid=123 占着」没法点进
112
+ --sessions 里对应那一行,用户也就查不出到底是谁。 */
113
+ if got.RunID != h.RunID() {
114
+ t.Errorf("holder run_id = %q, want %q(取锁后要补写)", got.RunID, h.RunID())
115
+ }
116
+ if got.Since.IsZero() {
117
+ t.Error("holder since 不该是零值(界面要显示「占了多久」)")
118
+ }
119
+
120
+ // 没锁的目录:报 false,不是零值 + true
121
+ if _, ok := LockHolderFor(t.TempDir()); ok {
122
+ t.Error("没有锁文件的目录不该报「有持有者」")
123
+ }
124
+ }
125
+
126
+ // TestLockKeyNormalizesPath 归一必须唯一,否则「同一个目录的两种写法」会被当成两个空间。
127
+ func TestLockKeyNormalizesPath(t *testing.T) {
128
+ base := t.TempDir()
129
+ a, fa := LockKey(base + "/")
130
+ b, fb := LockKey(base)
131
+ if a != b || fa != fb {
132
+ t.Errorf("尾斜杠不该改变锁键:%q/%q vs %q/%q", a, fa, b, fb)
133
+ }
134
+ c, fc := LockKey(base + "//./")
135
+ if c != a || fc != fa {
136
+ t.Errorf("冗余分隔符不该改变锁键:%q vs %q", c, a)
137
+ }
138
+ if a == "" || fa == "" {
139
+ t.Fatal("LockKey 不该返回空")
140
+ }
141
+ // 不同目录必须不同
142
+ other, _ := LockKey(t.TempDir())
143
+ if other == a {
144
+ t.Error("不同目录不该算出同一个锁键")
145
+ }
146
+ }
147
+
148
+ // TestSupersedeDoesNotCrossClients 跨客户端的重复接管**不许静默杀**。
149
+ //
150
+ // 这是这次改动里最要紧的一条:用户在手机上跑着一条,在 PC 上点开同一条继续问,
151
+ // 改前 core 会把手机上那条直接 SIGTERM —— 手机端只看到「突然已停止」。
152
+ func TestSupersedeDoesNotCrossClients(t *testing.T) {
153
+ isolate(t)
154
+ // 关锁:本用例要单独验证 supersede 的分支判断,不能让锁先把第二轮挡住
155
+ t.Setenv(EnvWorkspaceLock, "0")
156
+
157
+ old := exec.Command("sleep", "30")
158
+ if err := old.Start(); err != nil {
159
+ t.Fatalf("起占位进程: %v", err)
160
+ }
161
+ alive := make(chan struct{})
162
+ go func() { _ = old.Wait(); close(alive) }()
163
+ t.Cleanup(func() { _ = old.Process.Kill() })
164
+
165
+ // 「手机端」那条:client = com.magic.test
166
+ oldH, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-x", PID: old.Process.Pid, Client: "com.magic.test"})
167
+ if err != nil {
168
+ t.Fatalf("Begin(手机端): %v", err)
169
+ }
170
+
171
+ // 「PC 端」接管同一条会话:client 不同 → 不许杀
172
+ if _, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-x", PID: os.Getpid(), Client: "magic-test"}); err != nil {
173
+ t.Fatalf("Begin(PC 端): %v", err)
174
+ }
175
+
176
+ select {
177
+ case <-alive:
178
+ t.Fatal("跨客户端的会话被静默终止了 —— 这正是要修的那把暗刀")
179
+ case <-time.After(600 * time.Millisecond):
180
+ // 正常:对面还活着
181
+ }
182
+
183
+ rec, found, err := Find(oldH.RunID())
184
+ if err != nil || !found {
185
+ t.Fatalf("Find: %+v %v %v", rec, found, err)
186
+ }
187
+ if rec.State == StateCancelled {
188
+ t.Fatalf("跨客户端不该写成 cancelled(实际两端都该照常跑,由锁与状态表协调),实得 %q", rec.State)
189
+ }
190
+ }
191
+
192
+ // TestSupersedeSameClientStillTakesOver 同一客户端的重复接管**照旧**停旧 ——
193
+ // 改成多客户端之后不能把原来要解决的「两份日志抢写」问题放过。
194
+ func TestSupersedeSameClientStillTakesOver(t *testing.T) {
195
+ isolate(t)
196
+ t.Setenv(EnvWorkspaceLock, "0")
197
+
198
+ old := exec.Command("sleep", "30")
199
+ if err := old.Start(); err != nil {
200
+ t.Fatalf("起占位进程: %v", err)
201
+ }
202
+ alive := make(chan struct{})
203
+ go func() { _ = old.Wait(); close(alive) }()
204
+ t.Cleanup(func() { _ = old.Process.Kill() })
205
+
206
+ oldH, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-y", PID: old.Process.Pid, Client: "magic-test"})
207
+ if err != nil {
208
+ t.Fatalf("Begin(old): %v", err)
209
+ }
210
+ if _, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-y", PID: os.Getpid(), Client: "magic-test"}); err != nil {
211
+ t.Fatalf("Begin(new): %v", err)
212
+ }
213
+
214
+ select {
215
+ case <-alive:
216
+ case <-time.After(3 * time.Second):
217
+ t.Fatal("同一客户端的上一次运行没被停掉(两份日志会抢写)")
218
+ }
219
+ rec, _, _ := Find(oldH.RunID())
220
+ if rec.State != StateCancelled {
221
+ t.Fatalf("旧 run 应写成 %q,实得 %q", StateCancelled, rec.State)
222
+ }
223
+ }
224
+
225
+ // TestSupersedeEmptyClientBehavesLikeSameClient 两边都没 client(都是直接跑 CLI)
226
+ // 属于「同一来源」,照旧接管 —— 用户开两个终端跑同一会话同样是灾难。
227
+ func TestSupersedeEmptyClientBehavesLikeSameClient(t *testing.T) {
228
+ isolate(t)
229
+ t.Setenv(EnvWorkspaceLock, "0")
230
+
231
+ old := exec.Command("sleep", "30")
232
+ if err := old.Start(); err != nil {
233
+ t.Fatalf("起占位进程: %v", err)
234
+ }
235
+ alive := make(chan struct{})
236
+ go func() { _ = old.Wait(); close(alive) }()
237
+ t.Cleanup(func() { _ = old.Process.Kill() })
238
+
239
+ if _, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-z", PID: old.Process.Pid}); err != nil {
240
+ t.Fatalf("Begin(old): %v", err)
241
+ }
242
+ if _, err := Begin(BeginOptions{Engine: "claude", SessionID: "sess-z", PID: os.Getpid()}); err != nil {
243
+ t.Fatalf("Begin(new): %v", err)
244
+ }
245
+ select {
246
+ case <-alive:
247
+ case <-time.After(3 * time.Second):
248
+ t.Fatal("两边都没 client 时应视为同一来源,旧运行要被接管")
249
+ }
250
+ }
251
+
252
+ // TestLockFileReadsLegacyFormat 旧版锁文件(`pid\n时间戳`)必须照旧认。
253
+ //
254
+ // 为什么单列一条:升级途中,上一个版本留下的锁文件还在。若新代码只认 JSON,
255
+ // 它会读不出 pid → 判不出死进程 → 那个锁要等 6 小时 stale 才回收;
256
+ // 更糟的是可能被当成「坏文件」直接抢过来 —— 两个进程就同时跑了。
257
+ func TestLockFileReadsLegacyFormat(t *testing.T) {
258
+ dir := t.TempDir()
259
+ t.Setenv(EnvDir, dir)
260
+ ws := t.TempDir()
261
+
262
+ _, name := workspaceLockKey(ws)
263
+ lockDir := filepath.Join(dir, "locks")
264
+ if err := os.MkdirAll(lockDir, 0o700); err != nil {
265
+ t.Fatal(err)
266
+ }
267
+ // 一个几乎不可能存活的 pid,写成**旧格式**
268
+ deadPID := 999999
269
+ body := strconv.Itoa(deadPID) + "\n" + strconv.FormatInt(time.Now().UnixNano(), 10) + "\n"
270
+ if err := os.WriteFile(filepath.Join(lockDir, name+".lock"), []byte(body), 0o600); err != nil {
271
+ t.Fatal(err)
272
+ }
273
+
274
+ if h, ok := LockHolderFor(ws); !ok || h.PID != deadPID {
275
+ t.Fatalf("旧格式锁文件应能读出 pid=%d,实得 %+v ok=%v", deadPID, h, ok)
276
+ }
277
+
278
+ // 死 pid 的旧格式锁必须能被回收(否则一行崩溃留下的记录要堵 6 小时)
279
+ t.Setenv(EnvWorkspaceLock, "1")
280
+ t.Setenv(EnvWorkspaceLockWait, "3s")
281
+ done := make(chan error, 1)
282
+ go func() {
283
+ h, err := Begin(BeginOptions{Workspace: ws, Engine: "claude"})
284
+ if err == nil {
285
+ h.Finish("", StateDone)
286
+ }
287
+ done <- err
288
+ }()
289
+ select {
290
+ case err := <-done:
291
+ if err != nil {
292
+ t.Fatalf("陈旧旧格式锁应被回收:%v", err)
293
+ }
294
+ case <-time.After(4 * time.Second):
295
+ t.Fatal("旧格式的陈旧锁没被回收:Begin 一直等")
296
+ }
297
+ }
298
+
299
+ // TestLockFileWritesJSON 新写的锁文件是 JSON(能带身份),且 Release 只删自己那把。
300
+ func TestLockFileWritesJSON(t *testing.T) {
301
+ dir := t.TempDir()
302
+ t.Setenv(EnvDir, dir)
303
+ t.Setenv(EnvWorkspaceLock, "1")
304
+ ws := t.TempDir()
305
+
306
+ h, err := Begin(BeginOptions{Workspace: ws, Engine: "claude", Client: "c1"})
307
+ if err != nil {
308
+ t.Fatalf("Begin: %v", err)
309
+ }
310
+ p := lockFilePath(ws)
311
+ b, err := os.ReadFile(p)
312
+ if err != nil {
313
+ t.Fatalf("读锁文件: %v", err)
314
+ }
315
+ var probe map[string]any
316
+ if err := json.Unmarshal(b, &probe); err != nil {
317
+ t.Fatalf("新锁文件应是 JSON(能带持有者身份):%v(内容 %q)", err, string(b))
318
+ }
319
+ if probe["pid"] == nil {
320
+ t.Error("锁文件里必须有 pid")
321
+ }
322
+
323
+ // 伪造「不是自己」的锁:Release 不该删掉别人的
324
+ if err := os.WriteFile(p, encodeLock(LockHolder{PID: 424242, Client: "other"}), 0o600); err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ h.Finish("", StateDone)
328
+ if _, err := os.Stat(p); err != nil {
329
+ t.Error("Release 不该删掉 pid 不是自己的那把锁")
330
+ }
331
+ _ = os.Remove(p)
332
+ }
333
+
334
+ // TestLockTimeoutMentionsHolder 等超时的报错要带上「谁占着」。
335
+ // 原来的文案只有路径,用户看到「等超时了」却不知道该去停谁。
336
+ func TestLockTimeoutMentionsHolder(t *testing.T) {
337
+ t.Setenv(EnvDir, t.TempDir())
338
+ t.Setenv(EnvWorkspaceLock, "1")
339
+ t.Setenv(EnvWorkspaceLockWait, "300ms")
340
+ ws := t.TempDir()
341
+
342
+ blocker := exec.Command("sleep", "30")
343
+ if err := blocker.Start(); err != nil {
344
+ t.Fatalf("起占位进程: %v", err)
345
+ }
346
+ t.Cleanup(func() { _ = blocker.Process.Kill() })
347
+
348
+ // 手写一把「活着的别人」持有的锁
349
+ ld := lockDirPath()
350
+ if err := os.MkdirAll(ld, 0o700); err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ _, name := workspaceLockKey(ws)
354
+ body := encodeLock(LockHolder{PID: blocker.Process.Pid, Client: "com.magic.test", Source: SourceMobile, Since: time.Now()})
355
+ if err := os.WriteFile(filepath.Join(ld, name+".lock"), body, 0o600); err != nil {
356
+ t.Fatal(err)
357
+ }
358
+
359
+ _, err := Begin(BeginOptions{Workspace: ws, Engine: "claude"})
360
+ if err == nil {
361
+ t.Fatal("锁被活着的人占着,Begin 应超时报错")
362
+ }
363
+ for _, want := range []string{"超时", "pid=", "com.magic.test", "手机"} {
364
+ if !strings.Contains(err.Error(), want) {
365
+ t.Errorf("超时报错里应包含 %q(用户要能看出在等谁),实际:%v", want, err)
366
+ }
367
+ }
368
+ }
@@ -133,6 +133,10 @@ const (
133
133
  StateCancelled = "cancelled" // 被 --stop 取消
134
134
  StateInterrupted = "interrupted" // 当前轮被打断(会话仍可继续)
135
135
  StateGone = "gone" // 记录里是 running,但进程已不在(自然退出/被别处杀掉)
136
+ /* queued 已受理、还在作业队列里等(**不是**活着:没有进程,也没有会话日志)。
137
+ 由服务端作业队列预登记(见 RegisterQueued),真开跑时抹掉、交给 core 自己的
138
+ running 记录。列表页据此把「排在前面的那几条」画出来。 */
139
+ StateQueued = "queued"
136
140
  )
137
141
 
138
142
  // isLiveState 这个状态是否代表「会话/进程还活着」(running / waiting_approval / idle)。
@@ -144,6 +148,14 @@ func isLiveState(s string) bool {
144
148
  return s == StateRunning || s == StateWaitingApproval || s == StateIdle
145
149
  }
146
150
 
151
+ // IsLiveState 导出版(2026-10-26):**客户端也要问同一个问题**。
152
+ //
153
+ // 为什么必须导出而不是让调用方自己写 `s == "running" || ...`:多客户端之后,
154
+ // 判断「这条会话还活着吗」的地方从 core 内部扩散到了各宿主(列表上标不标「进行中」、
155
+ // 要不要显示「这个目录被占着」、能不能续接)。一旦新增一个活着状态(idle 就是这么加的),
156
+ // 漏改任何一处都会静默地少显示一条活会话。判定只留一份,谁要问就调它。
157
+ func IsLiveState(s string) bool { return isLiveState(s) }
158
+
147
159
  // normalizeState 把磁盘上的**旧状态值**归一成当前词表。
148
160
  //
149
161
  // 为什么必须有:`stopped` 在 2026-09-29 之前由 `--stop` 写入,一次性全部改写磁盘上的
@@ -481,14 +493,24 @@ func Begin(o BeginOptions) (*Handle, error) {
481
493
  if !o.NoPersist {
482
494
  dropUnreadable(dir)
483
495
  }
484
- /* 工作空间串行锁(选入式,见 workspace_lock.go):拿到锁之后才写 running 记录,
496
+ /* 工作空间串行锁(默认启用,见 workspace_lock.go):拿到锁之后才写 running 记录,
485
497
  别的进程看到的「在跑」就一定是已经可以开跑的。
486
498
  ⚠️ 团队子任务(ParentRunID 非空)不参与:它们与主任务同属一个工作空间,
487
- 再取一把锁就是自己锁自己。 */
499
+ 再取一把锁就是自己锁自己。
500
+ ⚠️ 锁里要写**持有者身份**(client / source / run_id / session_id)——
501
+ 等待方靠它回答「我在等谁」(手机上那条?还是另一个窗口?)。
502
+ 只写 pid 的话,用户看到的永远是「有人在跑」,不知道该去停谁。 */
488
503
  var wl *WorkspaceLock
489
504
  if strings.TrimSpace(o.ParentRunID) == "" {
490
505
  var lerr error
491
- if wl, lerr = acquireWorkspaceLock(o.Workspace); lerr != nil {
506
+ if wl, lerr = acquireWorkspaceLock(o.Workspace, LockHolder{
507
+ Client: normalizeClient(o.Client),
508
+ Source: normalizeSource(o.Source),
509
+ SessionID: strings.TrimSpace(o.SessionID),
510
+ Engine: o.Engine,
511
+ // RunID 此刻还没生成(下面才算),先留空:
512
+ // 拿到锁之后立刻补写一次(见下面的 noteLockRun)。
513
+ }); lerr != nil {
492
514
  return nil, lerr
493
515
  }
494
516
  }
@@ -519,13 +541,90 @@ func Begin(o BeginOptions) (*Handle, error) {
519
541
  // 句柄里留着它,SetAppendPath 之类也不用知道自己是不是在存档模式。
520
542
  h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist, lock: wl}
521
543
  h.write() // noPersist 时这里什么都不做
544
+ /* 锁文件的持有者身份补上 run_id(取锁时它还没生成):等待方要能按 run_id 对上
545
+ `--sessions` 里那一行,否则「占用者 pid=123」这种线索在界面上没法点进去。 */
546
+ if wl != nil {
547
+ wl.noteRun(rec.RunID)
548
+ }
522
549
  // 「新起即停旧」:同一条会话上还活着的上一次运行先停掉(见 supersedeSiblings)。
523
550
  if !o.NoPersist && rec.SessionID != "" {
524
- supersedeSiblings(rec.SessionID, rec.RunID, rec.PID)
551
+ supersedeSiblings(rec.SessionID, rec.RunID, rec.PID, rec.Client)
525
552
  }
526
553
  return h, nil
527
554
  }
528
555
 
556
+ // RegisterQueued 预登记一条「已受理、还没开跑」的会话(作业队列用)。
557
+ //
558
+ // 为什么(2026-10-24 用户:「没跑也先登记啊」):串行模式下作业在服务端排队,
559
+ // 此前只有真开跑(core 的 Begin)才写登记表 —— 排在后面的几条既不在 --sessions 里,
560
+ // 也不在任何客户端的列表上,看起来像「发完凭空消失」。现在受理即登记(state=queued),
561
+ // 谁排在队里、排第几,列表页从这一刻就能看见;真开跑 / 终结时由队列调 RemoveQueued
562
+ // 抹掉占位,交给 core 自己的那条 running 记录。
563
+ //
564
+ // 口径:
565
+ // - RunID 用客户端的幂等键(request_id;没有就用 job id):客户端据此把本地那条
566
+ // 「等待中」和这条登记行认成同一条,不会画两行。core 的 run_id 是 run-… 前缀,
567
+ // 两者不会撞。
568
+ // - PID 恒 0、state=queued:读侧(normalizeDead / externalBusy / Stop)都按
569
+ // 「非活着」处理,不占工作空间锁、不会被误判成在跑。
570
+ // - 覆盖写是幂等的:队列每推进一次状态就同步一次。
571
+ func RegisterQueued(rec Record) error {
572
+ dir := Dir()
573
+ rec.RunID = strings.TrimSpace(rec.RunID)
574
+ if dir == "" || rec.RunID == "" {
575
+ return fmt.Errorf("register queued: 缺少登记目录或 run_id")
576
+ }
577
+ /* RunID 会直接当文件名用(<run_id>.json),而排队占位的 id 来自客户端的
578
+ request_id —— 不能让 `../x`、带分隔符的 id 逃出登记目录。作业队列侧已经
579
+ 做过一次安全化(见 client 的 queuedSessionID),这里再挡一道。 */
580
+ if !validRecordName(rec.RunID) {
581
+ return fmt.Errorf("register queued: 非法 run_id %q", rec.RunID)
582
+ }
583
+ if err := os.MkdirAll(dir, 0o700); err != nil {
584
+ return err
585
+ }
586
+ rec.State = StateQueued
587
+ rec.PID = 0
588
+ rec.KillGroup = false
589
+ rec.PromptHead = head(rec.PromptHead, 80)
590
+ if rec.StartedAt.IsZero() {
591
+ rec.StartedAt = time.Now()
592
+ }
593
+ rec.UpdatedAt = time.Now()
594
+ data, err := json.MarshalIndent(rec, "", " ")
595
+ if err != nil {
596
+ return err
597
+ }
598
+ path := filepath.Join(dir, rec.RunID+".json")
599
+ tmp := path + ".tmp"
600
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
601
+ return err
602
+ }
603
+ if err := os.Rename(tmp, path); err != nil {
604
+ return err
605
+ }
606
+ setStatus(rec)
607
+ return nil
608
+ }
609
+
610
+ // RemoveQueued 抹掉排队占位(开跑 / 终结 / 取消时由作业队列调用)。
611
+ // 没有占位文件就什么都不做 —— 也**不要**去清状态表:那条键可能已经是真实会话的了。
612
+ func RemoveQueued(runID string) {
613
+ runID = strings.TrimSpace(runID)
614
+ dir := Dir()
615
+ if runID == "" || dir == "" || !validRecordName(runID) {
616
+ return
617
+ }
618
+ path := filepath.Join(dir, runID+".json")
619
+ if _, err := os.Stat(path); err != nil {
620
+ return
621
+ }
622
+ if err := os.Remove(path); err != nil {
623
+ return
624
+ }
625
+ dropStatuses(map[string]bool{runID: true})
626
+ }
627
+
529
628
  // supersedeSiblings 停掉同一会话上**还活着**的上一次运行。
530
629
  //
531
630
  // 为什么(2026-10-21,用户:「两边的回话状态不一致,一边结束了,一边还在显示运行中」):
@@ -536,9 +635,27 @@ func Begin(o BeginOptions) (*Handle, error) {
536
635
  // 登记表里那条旧 run 又一直 running,`Find` 于是永远答「在跑」。
537
636
  // 而同一会话本来也不该被两个引擎进程同时续接(引擎侧的会话状态也会互相覆盖)。
538
637
  //
638
+ // ── 2026-10-26 修:**跨客户端不许静默杀** ─────────────────────────────
639
+ // 原先这里对任何「同会话 + 还活着」的旧 run 一律 SIGTERM,不看是谁起的。多客户端
640
+ // 场景下这条就是一把暗刀:用户在手机上跑着一条对话,在 PC 上点开同一条会话接着问
641
+ // (本意是「继续」),core 在 PC 这轮 Begin 时把手机上那条**静默终止**了 ——
642
+ // 手机端看到的是「突然已停止」,既没有提示也不知道为什么。
643
+ //
644
+ // 现在的口径分两档:
645
+ //
646
+ // ① 同一个 client(或两边都为空 = 都是直接跑 CLI)→ **照旧接管**。
647
+ // 这是原本要解决的问题(同一份日志被两个进程同时续写),不能因为改成多客户端就放过。
648
+ // ② 跨 client → **不杀**,改由工作空间锁在 Begin 阶段就把并发挡在门外。
649
+ // 跨端的续接本来也不该发生:session 级状态表(status.go)是共享的,PC 端看到
650
+ // 「手机上正跑着」应当提示用户,而不是抢过来把对面杀了。
651
+ //
652
+ // 为什么要留 ① 而不干脆全删:直接跑 CLI 的两个进程(用户开了两个终端)client 都是空,
653
+ // 那种并发同样是灾难。②只说「不同客户端不该互相杀」,没说「并发就对了」——
654
+ // 并发由锁和 isLiveState 挡住,这里只负责「同一来源的重复接管」。
655
+ //
539
656
  // 口径与 Stop 一致:先 SIGTERM,宽限期不退再 SIGKILL,确认消失后写 cancelled。
540
657
  // 只在落盘模式下做:NoPersist 的调用彼此看不见,也无从判定谁是「旧」的。
541
- func supersedeSiblings(sessionID, runID string, selfPID int) {
658
+ func supersedeSiblings(sessionID, runID string, selfPID int, selfClient string) {
542
659
  sessionID = strings.TrimSpace(sessionID)
543
660
  if sessionID == "" {
544
661
  return
@@ -547,6 +664,7 @@ func supersedeSiblings(sessionID, runID string, selfPID int) {
547
664
  if err != nil {
548
665
  return
549
666
  }
667
+ self := normalizeClient(selfClient)
550
668
  for _, r := range all {
551
669
  if r.RunID == runID || r.SessionID != sessionID {
552
670
  continue
@@ -557,6 +675,16 @@ func supersedeSiblings(sessionID, runID string, selfPID int) {
557
675
  if r.PID <= 0 || r.PID == selfPID || !agent.PIDAlive(r.PID) {
558
676
  continue
559
677
  }
678
+ /* 跨客户端:不动手,只说明。见上面「跨客户端不许静默杀」。
679
+ 用 stderr 是为了让**当前这轮**的调用方(壳 / 插件)能把这句话转给界面 ——
680
+ 用户需要知道「你正在接的这条会话,另一边还跑着」。 */
681
+ if other := normalizeClient(r.Client); other != self {
682
+ fmt.Fprintf(os.Stderr,
683
+ "magic-agent: 同一会话的另一端仍在运行(pid %d / %s / 客户端 %s),本端不接管;"+
684
+ "如需继续请先停掉那一端(magic-agent --stop %s)\n",
685
+ r.PID, r.RunID, clientLabel(other), sessionID)
686
+ continue
687
+ }
560
688
  _ = agent.TerminatePID(r.PID, r.KillGroup)
561
689
  if !waitGone(r.PID, 2*time.Second) {
562
690
  _ = agent.KillPID(r.PID, r.KillGroup)
@@ -572,6 +700,15 @@ func supersedeSiblings(sessionID, runID string, selfPID int) {
572
700
  }
573
701
  }
574
702
 
703
+ // clientLabel 客户端标识的人话(空 = 直接跑 CLI,不是「未知」)。
704
+ func clientLabel(c string) string {
705
+ c = strings.TrimSpace(c)
706
+ if c == "" {
707
+ return "(直接命令行)"
708
+ }
709
+ return c
710
+ }
711
+
575
712
  // head 截取开头 n 个字符(按 rune,避免把中文切坏)。
576
713
  func head(s string, n int) string {
577
714
  s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
@@ -582,6 +719,22 @@ func head(s string, n int) string {
582
719
  return string(r[:n]) + "…"
583
720
  }
584
721
 
722
+ // validRecordName 登记文件名是否安全:只允许字母 / 数字 / - _ .,且不能是 . / ..。
723
+ // 只给排队占位用 —— 它的 id 可能来自客户端(request_id),不能直接拼进路径。
724
+ func validRecordName(id string) bool {
725
+ if id == "" || id == "." || id == ".." || len(id) > 96 {
726
+ return false
727
+ }
728
+ for _, r := range id {
729
+ switch {
730
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
731
+ default:
732
+ return false
733
+ }
734
+ }
735
+ return true
736
+ }
737
+
585
738
  // cwdOf 取进程当前目录;拿不到就留空(需求归纳按 workspace 归属时会退回用
586
739
  // Workspace 字段,两者都没有就只是不参与按目录的自动分组,不影响会话本身)。
587
740
  func cwdOf() string {
@@ -593,12 +746,17 @@ func cwdOf() string {
593
746
  }
594
747
 
595
748
  // List 返回全部记录(新的在前);顺带清理读不出来的坏文件。
749
+ //
750
+ // ⚠️ 一次遍历读一遍就够了(2026-10-26 修):此前先调 dropUnreadable(dir)(把每个 json
751
+ // 读一遍)、主循环再 readRecord 一遍 —— 同一份目录被读了两遍、反序列化两遍。实测 407
752
+ // 条记录时是 814 次读盘 + 814 次 JSON 解析,而 go 侧整个 --sessions 只值 20ms,
753
+ // 客户端列表接口却要 200~900ms,这一份白读是其中一块。现在坏文件在**同一次遍历里**
754
+ // 顺手删掉,语义与原来的「先清后列」完全一致(坏文件本来就不出现在结果里)。
596
755
  func List() ([]Record, error) {
597
756
  dir := Dir()
598
757
  if dir == "" {
599
758
  return nil, fmt.Errorf("cannot resolve sessions dir")
600
759
  }
601
- dropUnreadable(dir)
602
760
  entries, err := os.ReadDir(dir)
603
761
  if err != nil {
604
762
  if os.IsNotExist(err) {
@@ -611,12 +769,18 @@ func List() ([]Record, error) {
611
769
  if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
612
770
  continue
613
771
  }
614
- if rec, err := readRecord(filepath.Join(dir, e.Name())); err == nil {
615
- if normalizeDead(&rec) {
616
- writeRecord(rec)
617
- }
618
- out = append(out, rec)
772
+ p := filepath.Join(dir, e.Name())
773
+ rec, err := readRecord(p)
774
+ if err != nil {
775
+ /* 坏文件(写了一半 / 非法 JSON)永远读不出来:顺手清掉,别让它每次 List
776
+ 都白读一遍。只删 `.json` 且确实解析失败的,其余一概不动。 */
777
+ _ = os.Remove(p)
778
+ continue
779
+ }
780
+ if normalizeDead(&rec) {
781
+ writeRecord(rec)
619
782
  }
783
+ out = append(out, rec)
620
784
  }
621
785
  sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
622
786
  return out, nil
@@ -201,6 +201,13 @@ func TestFinishKeepsStoppedState(t *testing.T) {
201
201
  // 增量读的另一端会漏掉终态;登记表里旧 run 又一直 running,Find 永远答「在跑」。
202
202
  func TestBeginSupersedesAliveSibling(t *testing.T) {
203
203
  isolate(t)
204
+ /* ⚠️ 必须显式关掉工作空间锁(2026-10-26):本用例在**同一个 workspace** 里连着
205
+ Begin 两次。锁默认启用之后,第二次 Begin 会先卡在锁上(等 10 分钟),
206
+ 根本走不到 supersedeSiblings —— 而这个用例验的正是 supersede 那一步。
207
+ 两者是两道独立的防线:锁管「不同进程同时进同一目录」,supersede 管
208
+ 「同一条会话被重复接管」(跨 workspace 续接同一 session 时锁挡不住)。
209
+ 关锁之后若 supersede 失效,这个用例照样会红 —— 它没有失去意义。 */
210
+ t.Setenv(EnvWorkspaceLock, "0")
204
211
 
205
212
  // 用一个真实存活进程冒充「同会话的上一次运行」(supersedeSiblings 会按 pid 判活、下手)。
206
213
  old := exec.Command("sleep", "30")
@@ -140,6 +140,27 @@ func StatusForAny(sessionID, runID string) string {
140
140
  return m[strings.TrimSpace(runID)].State
141
141
  }
142
142
 
143
+ // StatusSnapshot 取整张状态表的**只读快照**,供批量读取(列表页)用。
144
+ //
145
+ // 为什么需要(2026-10-26):StatusForAny 每次都走 loadStatuses(),而它为了不让调用方
146
+ // 误改缓存,会把整张表**深拷贝**一遍再返回。列表页逐行调它 = 每行一次全表拷贝 + 一次
147
+ // 加锁解锁;407 行就是 407 次。这里只拷一次,调用方自己按 id 取。
148
+ //
149
+ // 返回的 map 是**副本**,调用方随便读(也别指望去改它会影响缓存)。
150
+ func StatusSnapshot() map[string]StatusEntry {
151
+ m, _ := loadStatuses()
152
+ return m
153
+ }
154
+
155
+ // StatusOf 从快照里取一条会话的状态(session_id 优先,其次 run_id)——
156
+ // 与 StatusForAny 同口径,只是不再重复加载表。表为 nil 时返回空串。
157
+ func StatusOf(snapshot map[string]StatusEntry, sessionID, runID string) string {
158
+ if e, ok := snapshot[strings.TrimSpace(sessionID)]; ok && e.State != "" {
159
+ return e.State
160
+ }
161
+ return snapshot[strings.TrimSpace(runID)].State
162
+ }
163
+
143
164
  // setStatus 把一条登记记录的状态写进会话级状态表(writeRecord 的收口点)。
144
165
  //
145
166
  // 两件事保证「统一」而不是「谁最后写谁算」: