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
@@ -10,21 +10,62 @@ import (
10
10
  "net/http/httptest"
11
11
  "os"
12
12
  "path/filepath"
13
+ "runtime"
13
14
  "strings"
14
15
  "testing"
15
16
  "time"
17
+
18
+ "github.com/darren/magic-agent/internal/session"
16
19
  )
17
20
 
18
21
  /* 服务端作业队列的回归闸门(2026-10-23):
19
22
  · 同一 workspace 串行、不同 workspace 并行;
20
23
  · request_id 幂等(重复提交不多跑一遍);
21
24
  · 排队中可取消,取消后不会偷偷开跑;
25
+ · 排队作业可改 serial(客户端切模式马上生效,2026-10-24);并行作业不等同空间的串行作业收尾;
26
+ · 全局满员时串行照常排队、并行借 headroom 余量放行(2026-10-24「hi」无响应事故);
22
27
  · 落盘 + 重启恢复(queued 留着、running 标失败)。 */
23
28
 
24
29
  // jobCore 假 core:记下每一次「真正开跑」的参数,然后停在 stdin 上等 stop。
30
+ // Windows 上 exec 不了 shebang 脚本(CreateProcess 不认 #!,报 executable file not found),
31
+ // 改发同名 .bat(Go 的 exec 在 Windows 会用 cmd.exe 跑批处理),语义与 sh 版逐条对齐。
25
32
  func jobCore(t *testing.T) string {
26
33
  t.Helper()
27
34
  dir := t.TempDir()
35
+ if runtime.GOOS == "windows" {
36
+ path := filepath.Join(dir, "magic-agent.bat")
37
+ // 批处理必须 CRLF(LF-only 的 goto 标签扫描有历史 bug);逐行对齐 sh 版逻辑。
38
+ lines := []string{
39
+ `@echo off`,
40
+ `set "A=%*"`,
41
+ `if "%A:--contract=%" == "%A%" goto engines`,
42
+ `echo {"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`,
43
+ `exit /b 0`,
44
+ `:engines`,
45
+ `if "%A:--engines=%" == "%A%" goto sessions`,
46
+ `echo [{"engine":"claude","ok":true,"capabilities":["session.stream"]}]`,
47
+ `exit /b 0`,
48
+ `:sessions`,
49
+ `if "%A:--sessions=%" == "%A%" goto run`,
50
+ `echo []`,
51
+ `exit /b 0`,
52
+ `:run`,
53
+ `if defined JOB_TEST_LOG >>"%JOB_TEST_LOG%" echo %A%`,
54
+ `echo {"v":1,"seq":1,"type":"ready","engine":"claude"}`,
55
+ `echo {"v":1,"seq":2,"type":"text","text":"ok"}`,
56
+ `:loop`,
57
+ `set "line="`,
58
+ `set /p line=`,
59
+ `if not defined line exit /b 0`,
60
+ `if not "%line:stop=%" == "%line%" echo {"v":1,"seq":3,"type":"result"}`,
61
+ `if not "%line:stop=%" == "%line%" exit /b 0`,
62
+ `goto loop`,
63
+ }
64
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")+"\r\n"), 0o755); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ return path
68
+ }
28
69
  path := filepath.Join(dir, "magic-agent")
29
70
  script := `#!/bin/sh
30
71
  case "$*" in
@@ -65,6 +106,9 @@ func jobTestService(t *testing.T, core string) (*Service, string) {
65
106
  t.Setenv("MAGIC_CLIENT_JOBS_DIR", filepath.Join(dir, "jobs"))
66
107
  t.Setenv("MAGIC_CLIENT_MAX_RUNNING", "8")
67
108
  t.Setenv("JOB_TEST_LOG", logPath)
109
+ /* 作业受理即写会话登记占位(RegisterQueued):登记表是**全机共享**的,
110
+ 不隔离的话测试会在开发者真实的 ~/.magic-agent/sessions 里堆一堆 queued 行。 */
111
+ t.Setenv(session.EnvDir, filepath.Join(dir, "sessions"))
68
112
  svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
69
113
  if err != nil {
70
114
  t.Fatalf("New: %v", err)
@@ -147,6 +191,50 @@ func readRuns(t *testing.T, path string) []string {
147
191
  return out
148
192
  }
149
193
 
194
+ // waitRuns 等日志里出现 n 次「真正开跑」再断言:running 状态在 spawn **之前**就落定
195
+ // (jobs.go dispatch),假 core 要等进程起来才写日志 —— Windows 上 cmd 冷启动可能
196
+ // 比一轮状态轮询还慢,读一次就断言会跟进程启动竞速。
197
+ func waitRuns(t *testing.T, path string, n int) []string {
198
+ t.Helper()
199
+ deadline := time.Now().Add(5 * time.Second)
200
+ for {
201
+ runs := readRuns(t, path)
202
+ if len(runs) >= n {
203
+ return runs
204
+ }
205
+ if time.Now().After(deadline) {
206
+ t.Fatalf("日志应在超时前出现 %d 次开跑,实际 %d:%v", n, len(runs), runs)
207
+ }
208
+ time.Sleep(50 * time.Millisecond)
209
+ }
210
+ }
211
+
212
+ // sessionRecord 按 run_id 查会话登记表(排队占位的 run_id = request_id)。
213
+ func sessionRecord(id string) (session.Record, bool) {
214
+ recs, _ := session.List()
215
+ for _, r := range recs {
216
+ if r.RunID == id {
217
+ return r, true
218
+ }
219
+ }
220
+ return session.Record{}, false
221
+ }
222
+
223
+ // waitNoRecord 等某条登记记录消失(占位撤除是落盘动作,与状态轮询不完全同拍)。
224
+ func waitNoRecord(t *testing.T, id string, timeout time.Duration) {
225
+ t.Helper()
226
+ deadline := time.Now().Add(timeout)
227
+ for {
228
+ if _, ok := sessionRecord(id); !ok {
229
+ return
230
+ }
231
+ if time.Now().After(deadline) {
232
+ t.Fatalf("会话登记里的 %s 应在 %s 内消失(排队占位没撤)", id, timeout)
233
+ }
234
+ time.Sleep(50 * time.Millisecond)
235
+ }
236
+ }
237
+
150
238
  func TestJobsSerialPerWorkspace(t *testing.T) {
151
239
  svc, runs := jobTestService(t, jobCore(t))
152
240
  srv := httptest.NewServer(svc.Handler())
@@ -158,6 +246,9 @@ func TestJobsSerialPerWorkspace(t *testing.T) {
158
246
  t.Errorf("刚提交应是 queued,实际 %s", j1.State)
159
247
  }
160
248
  waitJob(t, srv.URL, j1.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
249
+ /* 受理即登记(2026-10-24 用户:「没跑也先登记啊」):开跑后排队占位要抹掉,
250
+ 交给 core 自己那条 running 记录 —— 否则列表里会有两行(占位 + 真会话)。 */
251
+ waitNoRecord(t, "r1", 3*time.Second)
161
252
 
162
253
  j2 := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "二", "workspace": ws, "serial": true, "request_id": "r2"})
163
254
  time.Sleep(600 * time.Millisecond) // 给调度器足够时间:它**不该**开第二条
@@ -165,6 +256,18 @@ func TestJobsSerialPerWorkspace(t *testing.T) {
165
256
  if j2.State != JobQueued {
166
257
  t.Fatalf("同 workspace 第二条必须排队,实际 %s", j2.State)
167
258
  }
259
+ /* 排队中的那条此刻**还没开跑**,但必须已经在会话登记表里(列表页据此显示它),
260
+ 且 id 用客户端幂等键 —— 客户端靠它把本地那条「等待中」和登记行认成同一条。 */
261
+ rec, ok := sessionRecord("r2")
262
+ if !ok {
263
+ t.Fatalf("排队中的作业应在会话登记表里先登记一行(request_id=r2)")
264
+ }
265
+ if rec.State != session.StateQueued {
266
+ t.Fatalf("排队占位 state 应为 queued,实际 %q(%+v)", rec.State, rec)
267
+ }
268
+ if rec.Client == "" || rec.PromptHead == "" {
269
+ t.Fatalf("排队占位缺来源客户端 / 提示词,列表页画不出来:%+v", rec)
270
+ }
168
271
  if got := len(readRuns(t, runs)); got != 1 {
169
272
  t.Fatalf("同 workspace 只应有 1 次真正开跑,实际 %d:%v", got, readRuns(t, runs))
170
273
  }
@@ -181,6 +284,8 @@ func TestJobsSerialPerWorkspace(t *testing.T) {
181
284
  if j2.State != JobCancelled {
182
285
  t.Fatalf("取消失败:%+v", j2)
183
286
  }
287
+ /* 取消排队后登记占位也要撤掉:留着就是一条永远「等待中」的幽灵行。 */
288
+ waitNoRecord(t, "r2", 3*time.Second)
184
289
 
185
290
  // 停掉第一条,槽位释放。
186
291
  cr2, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+j1.ID+"/cancel", nil)
@@ -205,8 +310,9 @@ func TestJobsParallelAcrossWorkspaces(t *testing.T) {
205
310
  b := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "b", "workspace": wsB, "serial": true, "request_id": "pb"})
206
311
  waitJob(t, srv.URL, a.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
207
312
  waitJob(t, srv.URL, b.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
208
- if got := len(readRuns(t, runs)); got != 2 {
209
- t.Fatalf("不同 workspace 应并行开跑(2 次),实际 %d:%v", got, readRuns(t, runs))
313
+ started := waitRuns(t, runs, 2)
314
+ if len(started) != 2 {
315
+ t.Fatalf("不同 workspace 应并行开跑(2 次),实际 %d:%v", len(started), started)
210
316
  }
211
317
  for _, id := range []string{a.ID, b.ID} {
212
318
  r, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+id+"/cancel", nil)
@@ -217,6 +323,41 @@ func TestJobsParallelAcrossWorkspaces(t *testing.T) {
217
323
  }
218
324
  }
219
325
 
326
+ // 全局并发满员(max)时:串行新作业照常排队,并行作业借 headroom 余量放行 ——
327
+ // 交互式轻活不能被一批长任务压死(2026-10-24「hi」无响应事故的复发路径)。
328
+ func TestJobsParallelHeadroomWhenFull(t *testing.T) {
329
+ svc, runs := jobTestService(t, jobCore(t))
330
+ srv := httptest.NewServer(svc.Handler())
331
+ defer srv.Close()
332
+
333
+ // jobTestService 把 MAGIC_CLIENT_MAX_RUNNING 固定成 8:
334
+ // 先放 8 条串行(各占一个 workspace)把全局名额占满。
335
+ var fill []*Job
336
+ for i := 0; i < 8; i++ {
337
+ j := postJob(t, srv.URL, map[string]any{
338
+ "engine": "claude", "prompt": fmt.Sprintf("占位%d", i),
339
+ "workspace": t.TempDir(), "serial": true, "request_id": fmt.Sprintf("full-%d", i),
340
+ })
341
+ fill = append(fill, j)
342
+ }
343
+ for _, j := range fill {
344
+ waitJob(t, srv.URL, j.ID, func(x *Job) bool { return x.State == JobRunning }, 10*time.Second)
345
+ }
346
+
347
+ // 第 9 条串行:满员 → 必须排队。
348
+ s9 := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "第九", "workspace": t.TempDir(), "serial": true, "request_id": "full-9"})
349
+ time.Sleep(600 * time.Millisecond) // 给调度器足够时间:它不该开这条
350
+ if got := getJob(t, srv.URL, s9.ID); got.State != JobQueued {
351
+ t.Fatalf("满员后串行作业应排队,实际 %s", got.State)
352
+ }
353
+
354
+ // 并行作业:headroom 余量 → 立即放行。
355
+ p := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "并行", "workspace": t.TempDir(), "serial": false, "request_id": "full-p"})
356
+ waitJob(t, srv.URL, p.ID, func(x *Job) bool { return x.State == JobRunning }, 5*time.Second)
357
+
358
+ waitRuns(t, runs, 9)
359
+ }
360
+
220
361
  func TestJobsIdempotentRequestID(t *testing.T) {
221
362
  svc, runs := jobTestService(t, jobCore(t))
222
363
  srv := httptest.NewServer(svc.Handler())
@@ -244,6 +385,8 @@ func TestJobsIdempotentRequestID(t *testing.T) {
244
385
  func TestJobQueueRecoversAfterRestart(t *testing.T) {
245
386
  dir := t.TempDir()
246
387
  t.Setenv("MAGIC_CLIENT_JOBS_DIR", dir)
388
+ /* 登记表是全机共享的:不隔离的话启动对账会动开发者真实的 ~/.magic-agent/sessions。 */
389
+ t.Setenv(session.EnvDir, filepath.Join(dir, "sessions"))
247
390
  now := time.Now()
248
391
  running := Job{
249
392
  ID: "job-running", Workspace: "/tmp/ws", Engine: "claude", Prompt: "旧的",
@@ -275,6 +418,70 @@ func TestJobQueueRecoversAfterRestart(t *testing.T) {
275
418
  }
276
419
  }
277
420
 
421
+ /*
422
+ 启动对账:作业已经不在的排队占位要清掉(幽灵「等待中」行,2026-10-24 用户:「清掉」)。
423
+
424
+ 台阶:本客户端的陈迹 → 删;仍在排队的 → 留;刚写的(窗口期)→ 留;别的客户端的 → 不碰。
425
+ */
426
+ func TestJobsPruneOrphanQueuedPlaceholder(t *testing.T) {
427
+ dir := t.TempDir()
428
+ jobsDir := filepath.Join(dir, "jobs")
429
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", jobsDir)
430
+ t.Setenv(session.EnvDir, filepath.Join(dir, "sessions"))
431
+ if err := os.MkdirAll(jobsDir, 0o700); err != nil {
432
+ t.Fatal(err)
433
+ }
434
+ now := time.Now()
435
+ /* 一个**还排着**的作业:它的占位必须留着。 */
436
+ live := Job{
437
+ ID: "job-live", RequestID: "req-live", Workspace: "/tmp/ws", Engine: "claude", Prompt: "排队",
438
+ State: JobQueued, Serial: true, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(time.Hour),
439
+ }
440
+ b, _ := json.Marshal(live)
441
+ if err := os.WriteFile(filepath.Join(jobsDir, live.ID+".json"), b, 0o600); err != nil {
442
+ t.Fatal(err)
443
+ }
444
+
445
+ ours := DefaultProfile().AppID()
446
+ old := now.Add(-10 * time.Minute)
447
+ writePlaceholder(t, "req-live", ours, old) // 对应上面的作业 → 留
448
+ writePlaceholder(t, "req-ghost", ours, old) // 作业文件早没了 → 删
449
+ writePlaceholder(t, "req-fresh", ours, now) // 刚写(落盘窗口期)→ 留
450
+ writePlaceholder(t, "req-other", "magic-test", old) // 别的客户端 → 不碰
451
+
452
+ svc := &Service{opts: Options{Profile: DefaultProfile()}, log: log.New(io.Discard, "", 0), runs: map[string]*askRun{}}
453
+ q := newJobQueue(svc)
454
+ defer q.close()
455
+
456
+ if _, ok := sessionRecord("req-ghost"); ok {
457
+ t.Error("作业已不存在的排队占位应被清掉(否则列表上是一条永远「等待中」的幽灵行)")
458
+ }
459
+ for _, id := range []string{"req-live", "req-fresh", "req-other"} {
460
+ if _, ok := sessionRecord(id); !ok {
461
+ t.Errorf("占位 %s 不该被清(仍在排队 / 刚写入 / 别的客户端)", id)
462
+ }
463
+ }
464
+ }
465
+
466
+ // writePlaceholder 直接落一条排队占位(时间可控,绕过 RegisterQueued 的「写当前时间」)。
467
+ func writePlaceholder(t *testing.T, id, client string, at time.Time) {
468
+ t.Helper()
469
+ rec := session.Record{
470
+ RunID: id, Client: client, PromptHead: "占位", Cwd: "/tmp/ws",
471
+ State: session.StateQueued, StartedAt: at, UpdatedAt: at,
472
+ }
473
+ b, err := json.Marshal(rec)
474
+ if err != nil {
475
+ t.Fatal(err)
476
+ }
477
+ if err := os.MkdirAll(session.Dir(), 0o700); err != nil {
478
+ t.Fatal(err)
479
+ }
480
+ if err := os.WriteFile(filepath.Join(session.Dir(), id+".json"), b, 0o600); err != nil {
481
+ t.Fatal(err)
482
+ }
483
+ }
484
+
278
485
  func TestAskIdempotentRequestID(t *testing.T) {
279
486
  svc, runs := jobTestService(t, jobCore(t))
280
487
  srv := httptest.NewServer(svc.Handler())
@@ -367,3 +574,93 @@ func TestJobsListAndSnapshot(t *testing.T) {
367
574
  resp.Body.Close()
368
575
  }
369
576
  }
577
+
578
+ func TestJobsUpdateSerialTakesEffect(t *testing.T) {
579
+ svc, runs := jobTestService(t, jobCore(t))
580
+ srv := httptest.NewServer(svc.Handler())
581
+ defer srv.Close()
582
+ ws := t.TempDir()
583
+
584
+ // 第一条串行占住同空间单槽;第二条(也是串行)只能排队。
585
+ j1 := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "占位", "workspace": ws, "serial": true, "request_id": "u1"})
586
+ waitJob(t, srv.URL, j1.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
587
+ j2 := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "排队", "workspace": ws, "serial": true, "request_id": "u2"})
588
+ time.Sleep(600 * time.Millisecond)
589
+ if j2 = getJob(t, srv.URL, j2.ID); j2.State != JobQueued {
590
+ t.Fatalf("同 workspace 第二条应排队,实际 %s", j2.State)
591
+ }
592
+
593
+ // 客户端切到并行模式:按 request_id 把排队的第二条改成并行 —— 应立刻放行,
594
+ // 与还在跑的第一条同空间并行(不再让路、core 侧锁也只对串行注入)。
595
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/update",
596
+ bytes.NewReader([]byte(`{"request_id":"u2","serial":false}`)))
597
+ req.Header.Set("X-Magic-Token", "tok")
598
+ req.Header.Set("Content-Type", "application/json")
599
+ resp, err := http.DefaultClient.Do(req)
600
+ if err != nil {
601
+ t.Fatal(err)
602
+ }
603
+ var out struct {
604
+ Job *Job `json:"job"`
605
+ Updated bool `json:"updated"`
606
+ }
607
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
608
+ t.Fatal(err)
609
+ }
610
+ resp.Body.Close()
611
+ if resp.StatusCode != http.StatusOK || !out.Updated {
612
+ t.Fatalf("update 应成功,实际 HTTP %d updated=%v", resp.StatusCode, out.Updated)
613
+ }
614
+ if out.Job.Serial {
615
+ t.Errorf("改完 serial 标志应落到作业上:%+v", out.Job)
616
+ }
617
+ waitJob(t, srv.URL, j2.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
618
+ started := waitRuns(t, runs, 2)
619
+ if len(started) != 2 {
620
+ t.Fatalf("并行化后应与第一条同空间并行开跑(2 次),实际 %d:%v", len(started), started)
621
+ }
622
+
623
+ // 已开跑的作业改不动(updated=false),也不被打断。
624
+ req2, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+j1.ID+"/update",
625
+ bytes.NewReader([]byte(`{"serial":true}`)))
626
+ req2.Header.Set("X-Magic-Token", "tok")
627
+ req2.Header.Set("Content-Type", "application/json")
628
+ resp2, err := http.DefaultClient.Do(req2)
629
+ if err != nil {
630
+ t.Fatal(err)
631
+ }
632
+ var out2 struct {
633
+ Job *Job `json:"job"`
634
+ Updated bool `json:"updated"`
635
+ }
636
+ if err := json.NewDecoder(resp2.Body).Decode(&out2); err != nil {
637
+ t.Fatal(err)
638
+ }
639
+ resp2.Body.Close()
640
+ if out2.Updated || out2.Job == nil || out2.Job.State != JobRunning {
641
+ t.Fatalf("running 作业不应被改(updated=%v job=%+v)", out2.Updated, out2.Job)
642
+ }
643
+
644
+ // 缺 serial 字段 → 400。
645
+ req3, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/update",
646
+ bytes.NewReader([]byte(`{"request_id":"u2"}`)))
647
+ req3.Header.Set("X-Magic-Token", "tok")
648
+ resp3, err := http.DefaultClient.Do(req3)
649
+ if err != nil {
650
+ t.Fatal(err)
651
+ }
652
+ io.Copy(io.Discard, resp3.Body)
653
+ resp3.Body.Close()
654
+ if resp3.StatusCode != http.StatusBadRequest {
655
+ t.Fatalf("缺 serial 应 400,实际 %d", resp3.StatusCode)
656
+ }
657
+
658
+ // 收尾。
659
+ for _, id := range []string{j1.ID, j2.ID} {
660
+ r, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+id+"/cancel", nil)
661
+ r.Header.Set("X-Magic-Token", "tok")
662
+ if resp, err := http.DefaultClient.Do(r); err == nil {
663
+ resp.Body.Close()
664
+ }
665
+ }
666
+ }