magic-agent 2.6.0 → 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.
- package/CLIENT-CONTRACT.md +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +8 -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
|
+
}
|
|
@@ -148,6 +148,14 @@ func isLiveState(s string) bool {
|
|
|
148
148
|
return s == StateRunning || s == StateWaitingApproval || s == StateIdle
|
|
149
149
|
}
|
|
150
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
|
+
|
|
151
159
|
// normalizeState 把磁盘上的**旧状态值**归一成当前词表。
|
|
152
160
|
//
|
|
153
161
|
// 为什么必须有:`stopped` 在 2026-09-29 之前由 `--stop` 写入,一次性全部改写磁盘上的
|
|
@@ -485,14 +493,24 @@ func Begin(o BeginOptions) (*Handle, error) {
|
|
|
485
493
|
if !o.NoPersist {
|
|
486
494
|
dropUnreadable(dir)
|
|
487
495
|
}
|
|
488
|
-
/*
|
|
496
|
+
/* 工作空间串行锁(默认启用,见 workspace_lock.go):拿到锁之后才写 running 记录,
|
|
489
497
|
别的进程看到的「在跑」就一定是已经可以开跑的。
|
|
490
498
|
⚠️ 团队子任务(ParentRunID 非空)不参与:它们与主任务同属一个工作空间,
|
|
491
|
-
再取一把锁就是自己锁自己。
|
|
499
|
+
再取一把锁就是自己锁自己。
|
|
500
|
+
⚠️ 锁里要写**持有者身份**(client / source / run_id / session_id)——
|
|
501
|
+
等待方靠它回答「我在等谁」(手机上那条?还是另一个窗口?)。
|
|
502
|
+
只写 pid 的话,用户看到的永远是「有人在跑」,不知道该去停谁。 */
|
|
492
503
|
var wl *WorkspaceLock
|
|
493
504
|
if strings.TrimSpace(o.ParentRunID) == "" {
|
|
494
505
|
var lerr error
|
|
495
|
-
if wl, lerr = acquireWorkspaceLock(o.Workspace
|
|
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 {
|
|
496
514
|
return nil, lerr
|
|
497
515
|
}
|
|
498
516
|
}
|
|
@@ -523,9 +541,14 @@ func Begin(o BeginOptions) (*Handle, error) {
|
|
|
523
541
|
// 句柄里留着它,SetAppendPath 之类也不用知道自己是不是在存档模式。
|
|
524
542
|
h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist, lock: wl}
|
|
525
543
|
h.write() // noPersist 时这里什么都不做
|
|
544
|
+
/* 锁文件的持有者身份补上 run_id(取锁时它还没生成):等待方要能按 run_id 对上
|
|
545
|
+
`--sessions` 里那一行,否则「占用者 pid=123」这种线索在界面上没法点进去。 */
|
|
546
|
+
if wl != nil {
|
|
547
|
+
wl.noteRun(rec.RunID)
|
|
548
|
+
}
|
|
526
549
|
// 「新起即停旧」:同一条会话上还活着的上一次运行先停掉(见 supersedeSiblings)。
|
|
527
550
|
if !o.NoPersist && rec.SessionID != "" {
|
|
528
|
-
supersedeSiblings(rec.SessionID, rec.RunID, rec.PID)
|
|
551
|
+
supersedeSiblings(rec.SessionID, rec.RunID, rec.PID, rec.Client)
|
|
529
552
|
}
|
|
530
553
|
return h, nil
|
|
531
554
|
}
|
|
@@ -612,9 +635,27 @@ func RemoveQueued(runID string) {
|
|
|
612
635
|
// 登记表里那条旧 run 又一直 running,`Find` 于是永远答「在跑」。
|
|
613
636
|
// 而同一会话本来也不该被两个引擎进程同时续接(引擎侧的会话状态也会互相覆盖)。
|
|
614
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
|
+
//
|
|
615
656
|
// 口径与 Stop 一致:先 SIGTERM,宽限期不退再 SIGKILL,确认消失后写 cancelled。
|
|
616
657
|
// 只在落盘模式下做:NoPersist 的调用彼此看不见,也无从判定谁是「旧」的。
|
|
617
|
-
func supersedeSiblings(sessionID, runID string, selfPID int) {
|
|
658
|
+
func supersedeSiblings(sessionID, runID string, selfPID int, selfClient string) {
|
|
618
659
|
sessionID = strings.TrimSpace(sessionID)
|
|
619
660
|
if sessionID == "" {
|
|
620
661
|
return
|
|
@@ -623,6 +664,7 @@ func supersedeSiblings(sessionID, runID string, selfPID int) {
|
|
|
623
664
|
if err != nil {
|
|
624
665
|
return
|
|
625
666
|
}
|
|
667
|
+
self := normalizeClient(selfClient)
|
|
626
668
|
for _, r := range all {
|
|
627
669
|
if r.RunID == runID || r.SessionID != sessionID {
|
|
628
670
|
continue
|
|
@@ -633,6 +675,16 @@ func supersedeSiblings(sessionID, runID string, selfPID int) {
|
|
|
633
675
|
if r.PID <= 0 || r.PID == selfPID || !agent.PIDAlive(r.PID) {
|
|
634
676
|
continue
|
|
635
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
|
+
}
|
|
636
688
|
_ = agent.TerminatePID(r.PID, r.KillGroup)
|
|
637
689
|
if !waitGone(r.PID, 2*time.Second) {
|
|
638
690
|
_ = agent.KillPID(r.PID, r.KillGroup)
|
|
@@ -648,6 +700,15 @@ func supersedeSiblings(sessionID, runID string, selfPID int) {
|
|
|
648
700
|
}
|
|
649
701
|
}
|
|
650
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
|
+
|
|
651
712
|
// head 截取开头 n 个字符(按 rune,避免把中文切坏)。
|
|
652
713
|
func head(s string, n int) string {
|
|
653
714
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
|
@@ -685,12 +746,17 @@ func cwdOf() string {
|
|
|
685
746
|
}
|
|
686
747
|
|
|
687
748
|
// List 返回全部记录(新的在前);顺带清理读不出来的坏文件。
|
|
749
|
+
//
|
|
750
|
+
// ⚠️ 一次遍历读一遍就够了(2026-10-26 修):此前先调 dropUnreadable(dir)(把每个 json
|
|
751
|
+
// 读一遍)、主循环再 readRecord 一遍 —— 同一份目录被读了两遍、反序列化两遍。实测 407
|
|
752
|
+
// 条记录时是 814 次读盘 + 814 次 JSON 解析,而 go 侧整个 --sessions 只值 20ms,
|
|
753
|
+
// 客户端列表接口却要 200~900ms,这一份白读是其中一块。现在坏文件在**同一次遍历里**
|
|
754
|
+
// 顺手删掉,语义与原来的「先清后列」完全一致(坏文件本来就不出现在结果里)。
|
|
688
755
|
func List() ([]Record, error) {
|
|
689
756
|
dir := Dir()
|
|
690
757
|
if dir == "" {
|
|
691
758
|
return nil, fmt.Errorf("cannot resolve sessions dir")
|
|
692
759
|
}
|
|
693
|
-
dropUnreadable(dir)
|
|
694
760
|
entries, err := os.ReadDir(dir)
|
|
695
761
|
if err != nil {
|
|
696
762
|
if os.IsNotExist(err) {
|
|
@@ -703,12 +769,18 @@ func List() ([]Record, error) {
|
|
|
703
769
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
704
770
|
continue
|
|
705
771
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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)
|
|
711
782
|
}
|
|
783
|
+
out = append(out, rec)
|
|
712
784
|
}
|
|
713
785
|
sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
|
|
714
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
|
// 两件事保证「统一」而不是「谁最后写谁算」:
|
|
@@ -142,3 +142,31 @@ func TestDeleteDropsStatuses(t *testing.T) {
|
|
|
142
142
|
t.Fatalf("删除后状态该被清掉,实得 %q", got)
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
// TestStatusOfMatchesStatusForAny 快照读取必须与逐条读取**逐字一致**。
|
|
147
|
+
//
|
|
148
|
+
// 为什么单独立一条(2026-10-26):列表页从 StatusForAny 换成了 StatusSnapshot + StatusOf
|
|
149
|
+
// (逐行调前者会让底层把整张表深拷贝一遍,407 行 = 407 次全表拷贝)。这类「为了性能换一条
|
|
150
|
+
// 取数路径」的改动最容易悄悄改变语义 —— 键优先级(session 优先 / run 兜底)只要漏一处,
|
|
151
|
+
// 列表上的状态就会一部分对、一部分错,而且极难看出来。这里把两者钉成同一个答案。
|
|
152
|
+
func TestStatusOfMatchesStatusForAny(t *testing.T) {
|
|
153
|
+
withStatusHome(t)
|
|
154
|
+
now := time.Now()
|
|
155
|
+
setStatus(Record{RunID: "run-a", SessionID: "sess-a", State: StateIdle, StartedAt: now})
|
|
156
|
+
setStatus(Record{RunID: "run-b", State: StateRunning, StartedAt: now})
|
|
157
|
+
snap := StatusSnapshot()
|
|
158
|
+
|
|
159
|
+
cases := [][2]string{
|
|
160
|
+
{"sess-a", "run-a"}, // 两个键都有:取 session
|
|
161
|
+
{"", "run-b"}, // 只有 run:取 run
|
|
162
|
+
{"sess-a", "run-b"}, // 撞车:session 优先(与 StatusForAny 同口径)
|
|
163
|
+
{"nope", "nope2"}, // 都没有:空
|
|
164
|
+
{"", ""}, // 空 id:空
|
|
165
|
+
}
|
|
166
|
+
for _, c := range cases {
|
|
167
|
+
want := StatusForAny(c[0], c[1])
|
|
168
|
+
if got := StatusOf(snap, c[0], c[1]); got != want {
|
|
169
|
+
t.Fatalf("StatusOf(%q,%q) = %q,StatusForAny 却是 %q", c[0], c[1], got, want)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -133,6 +133,22 @@ func TitleForAny(sessionID, runID string) string {
|
|
|
133
133
|
return m[strings.TrimSpace(runID)]
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// TitleSnapshot 取整张标题表的**只读快照**,供批量读取(列表页)用。
|
|
137
|
+
//
|
|
138
|
+
// 与 StatusSnapshot 同一个理由(2026-10-26):TitleForAny 每次都会让 loadTitles()
|
|
139
|
+
// 把整张表深拷贝一份(缓存不能交给调用方乱改)。列表页逐行调它,407 行就是 407 次
|
|
140
|
+
// 全表拷贝 —— 而它要的只是一个 O(1) 的查表。这里拷一次,调用方自己按 id 取。
|
|
141
|
+
func TitleSnapshot() map[string]string { return Titles() }
|
|
142
|
+
|
|
143
|
+
// TitleOf 从快照里取一条会话的标题:**session_id 优先,其次 run_id** ——
|
|
144
|
+
// 与 TitleForAny 同口径,只是不再重复加载表。
|
|
145
|
+
func TitleOf(snapshot map[string]string, sessionID, runID string) string {
|
|
146
|
+
if t := snapshot[strings.TrimSpace(sessionID)]; t != "" {
|
|
147
|
+
return t
|
|
148
|
+
}
|
|
149
|
+
return snapshot[strings.TrimSpace(runID)]
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
// AdoptTitle 把标题从 run_id 迁到 session_id(幂等),并**清掉那个 run_id 键**。
|
|
137
153
|
//
|
|
138
154
|
// 只在新键**为空**时搬:用户可能已经手动重命名过(那就是权威值),不能被旧键覆盖回去。
|
|
@@ -296,3 +296,29 @@ func TestSummarizeCachedAcrossKeys(t *testing.T) {
|
|
|
296
296
|
t.Fatal("命中时应把标题迁到 session_id(否则下一轮又要重算)")
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
|
+
|
|
300
|
+
// TestTitleOfMatchesTitleForAny 快照读取必须与逐条读取**逐字一致**(理由见 status_test.go
|
|
301
|
+
// 的 TestStatusOfMatchesStatusForAny:列表页为省掉逐行全表拷贝换了取数路径)。
|
|
302
|
+
func TestTitleOfMatchesTitleForAny(t *testing.T) {
|
|
303
|
+
withTitleHome(t)
|
|
304
|
+
if err := SetTitle("sess-a", "会话标题"); err != nil {
|
|
305
|
+
t.Fatal(err)
|
|
306
|
+
}
|
|
307
|
+
if err := SetTitle("run-b", "只按 run 存的"); err != nil {
|
|
308
|
+
t.Fatal(err)
|
|
309
|
+
}
|
|
310
|
+
snap := TitleSnapshot()
|
|
311
|
+
|
|
312
|
+
cases := [][2]string{
|
|
313
|
+
{"sess-a", "run-b"}, // 撞车:session 优先
|
|
314
|
+
{"", "run-b"}, // 只按 run
|
|
315
|
+
{"nope", "nope"}, // 都没有
|
|
316
|
+
{"", ""}, // 空 id
|
|
317
|
+
}
|
|
318
|
+
for _, c := range cases {
|
|
319
|
+
want := TitleForAny(c[0], c[1])
|
|
320
|
+
if got := TitleOf(snap, c[0], c[1]); got != want {
|
|
321
|
+
t.Fatalf("TitleOf(%q,%q) = %q,TitleForAny 却是 %q", c[0], c[1], got, want)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|