magic-agent 2.3.2 → 2.4.2

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 (35) hide show
  1. package/CLIENT-CONTRACT.md +30 -4
  2. package/README.md +1 -1
  3. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  4. package/client-ui/shared/conversation.js +39 -0
  5. package/client-ui/shared/transport-http.js +10 -0
  6. package/client-ui-mobile/index.html +431 -46
  7. package/cmd/magic-client/main.go +5 -0
  8. package/internal/agent/engine.go +2 -2
  9. package/internal/agent/llmengine.go +0 -8
  10. package/internal/agent/llmengine_test.go +0 -17
  11. package/internal/agent/llmmodels.go +0 -35
  12. package/internal/client/cors_test.go +32 -0
  13. package/internal/client/instance.go +59 -0
  14. package/internal/client/jobs.go +1160 -0
  15. package/internal/client/jobs_test.go +369 -0
  16. package/internal/client/service.go +251 -141
  17. package/internal/client/service_test.go +46 -1
  18. package/internal/session/session.go +15 -1
  19. package/internal/session/workspace_lock.go +165 -0
  20. package/internal/session/workspace_lock_test.go +90 -0
  21. package/internal/suggest/suggest.go +8 -25
  22. package/internal/suggest/suggest_test.go +0 -6
  23. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  24. package/npm/apk/version.json +3 -3
  25. package/npm/dist/darwin-arm64/magic-agent +0 -0
  26. package/npm/dist/darwin-arm64/magic-client +0 -0
  27. package/npm/dist/darwin-x64/magic-agent +0 -0
  28. package/npm/dist/darwin-x64/magic-client +0 -0
  29. package/npm/dist/linux-arm64/magic-agent +0 -0
  30. package/npm/dist/linux-arm64/magic-client +0 -0
  31. package/npm/dist/linux-x64/magic-agent +0 -0
  32. package/npm/dist/linux-x64/magic-client +0 -0
  33. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  34. package/npm/dist/win32-x64/magic-client.exe +0 -0
  35. package/package.json +1 -1
@@ -80,6 +80,14 @@ type Options struct {
80
80
  // 为什么可配:UA 会决定站点给哪一版页面。中国大陆出口 + 移动 UA 时,Google 会跳到
81
81
  // http://www.google.cn/m(该路径本身就是 404);换成桌面 UA 则走香港跳转、可正常打开。
82
82
  BrowseUA string
83
+ // AllowAnyOrigin 对**任意 Origin** 回 CORS 头(2026-10-23)。
84
+ //
85
+ // 默认只放行「本机页面」(见 withCORS):APK / 桌面壳这些容器源的 Origin 各不相同,
86
+ // 自托管部署的入口也不止本机 —— 只放行 loopback 时,从别的设备 / 别的源打开的页面
87
+ // 会被浏览器判成跨域失败(用户侧就是「只能本地访问」)。
88
+ // 打开它 = 任意网页都能**读**响应;真正的门锁仍是令牌(没有 X-Magic-Token 一律 401)。
89
+ // `--lan` 时默认开启(都绑 0.0.0.0 了,再按 loopback 卡 Origin 没有意义)。
90
+ AllowAnyOrigin bool
83
91
  }
84
92
 
85
93
  // Service 本地服务。
@@ -95,6 +103,12 @@ type Service struct {
95
103
  runs map[string]*askRun
96
104
  seq int64
97
105
 
106
+ // jobs 服务端作业队列(同一 workspace 串行 + 幂等 + 持久化),见 jobs.go。
107
+ jobs *jobQueue
108
+
109
+ // inst 服务实例的稳定 id(见 instance.go):同一台桌面的多个入口靠它归一。
110
+ inst instanceIDCache
111
+
98
112
  // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
99
113
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
100
114
  browseOnce sync.Once
@@ -196,6 +210,9 @@ func New(opts Options) (*Service, error) {
196
210
  return nil, fmt.Errorf("core 缺通道能力 %s(需要支持 --stream --events 与 --control 的版本)", f)
197
211
  }
198
212
  }
213
+ /* 作业队列:服务端权威的排队层(多客户端串行 / 幂等 / 重启恢复)。
214
+ 必须在 New 里就建好:/desk/ask 第一次进来就要能入队。 */
215
+ s.jobs = newJobQueue(s)
199
216
  return s, nil
200
217
  }
201
218
 
@@ -218,6 +235,10 @@ func (s *Service) Handler() http.Handler {
218
235
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
219
236
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
220
237
  mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
238
+ /* 服务端作业队列(2026-10-23):跨客户端提交 / 查询 / 取消 / 事件。
239
+ /desk/ask 也走同一个调度器,这里给需要「先入队、不挂长连接」的客户端用。 */
240
+ mux.HandleFunc("/desk/jobs", s.guard(s.handleJobs))
241
+ mux.HandleFunc("/desk/jobs/", s.guard(s.handleJobsSub))
221
242
  mux.HandleFunc("/desk/upload", s.guard(s.handleUpload))
222
243
  // 回读已上传附件(缩略图 / 历史回看):<img> 带不了自定义头,靠 guard 支持的 ?token= 过鉴权。
223
244
  mux.HandleFunc("/desk/uploads/", s.guard(s.handleUploadGet))
@@ -299,9 +320,15 @@ func (s *Service) withCORS(next http.Handler) http.Handler {
299
320
  if origin != "" {
300
321
  // Origin 会随请求方变,不加 Vary 会把一个源的响应喂给另一个源。
301
322
  w.Header().Add("Vary", "Origin")
302
- if localOrigin(origin) {
323
+ if s.opts.AllowAnyOrigin || localOrigin(origin) {
303
324
  w.Header().Set("Access-Control-Allow-Origin", origin)
304
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
325
+ /* ⚠️ 写方法必须点名(2026-10-23 用户:「删除无法删除」)。
326
+ 只写 GET/POST 时,容器里(源=https://localhost)的删除 / 重命名 / 置顶同步
327
+ 全是带 X-Magic-Token 的 DELETE、PUT → 必发 OPTIONS 预检 → 浏览器看到
328
+ DELETE 未被允许,**真请求根本发不出去**(fetch 直接 reject)。
329
+ 同源页面(/m/ 远程加载、桌面壳)不走预检,所以只有打包进 App 的那侧坏 ——
330
+ curl 也永远看不见这条,必须靠 cors_test 钉住。 */
331
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
305
332
  w.Header().Set("Access-Control-Allow-Headers", "X-Magic-Token, Content-Type")
306
333
  w.Header().Set("Access-Control-Max-Age", "600")
307
334
  }
@@ -396,6 +423,13 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
396
423
  "engines": len(s.contract.Engines),
397
424
  "defaultWorkspace": wd,
398
425
  "workspacesRoot": filepath.Dir(wd),
426
+ // 服务端作业队列(2026-10-23):有它才谈得上「多客户端串行 + 幂等」。
427
+ // 老插件没有该字段 → 客户端退回本地排队(见 client-ui-mobile 的 fallback)。
428
+ "jobs": true,
429
+ // 服务实例的稳定 id(2026-10-23,只增字段):同一台桌面的本地 / 局域网 / 公网
430
+ // 入口可能有多条 URL,客户端据此把它们归一成同一个环境(队列与设置共用)。
431
+ // 老插件没有该字段 → 客户端退回按连接各自算环境。
432
+ "instanceId": s.instanceID(),
399
433
  // 远程移动端界面在不在(见 handleMobileUI)。
400
434
  // 客户端的闸门页要据此决定「跳远程 /m/ 还是用包里自带的 ui.html」。
401
435
  // 为什么不放在 features 里:features 是**core 的能力位**(s.contract.Features),
@@ -681,8 +715,15 @@ func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request)
681
715
  }
682
716
  return
683
717
  }
718
+ /* 停止这一轮:**跨进程**用 —— 手机 / 另一台设备只尾随了别人进程里跑的那一轮,
719
+ 手里没有 ask id(/desk/ask/{id}/control 打不到),只能按会话 id 停。
720
+ 与 core 的 `--stop` 同一条路(登记表里的 pid + SIGTERM)。 */
721
+ if ok && action == "stop" && r.Method == http.MethodPost {
722
+ s.handleSessionStop(w, r, id)
723
+ return
724
+ }
684
725
  writeErr(w, http.StatusMethodNotAllowed,
685
- "POST / PUT 只支持 /desk/session/{id}/title 与 /desk/session/{id}/title/summarize")
726
+ "POST / PUT 只支持 /desk/session/{id}/title、/desk/session/{id}/title/summarize 与 /desk/session/{id}/stop")
686
727
  return
687
728
  }
688
729
  if r.Method != http.MethodGet {
@@ -1071,6 +1112,27 @@ func truncateForMsg(s string) string {
1071
1112
  return string(r[:cap]) + "…"
1072
1113
  }
1073
1114
 
1115
+ // handleSessionStop 停止某条会话正在跑的那一轮(跨进程)。
1116
+ //
1117
+ // 语义与 core 的 `magic-agent --stop <id>` 完全一致(session.Stop:SIGTERM 进程组,
1118
+ // 宽限期不退再 SIGKILL,最后把状态写成 cancelled):
1119
+ // - id 收 session_id 或 run_id;
1120
+ // - 幂等:进程已不在 / 会话已结束 → stopped=false + reason,仍是 200;
1121
+ // - 找不到 id → 404(多半是 id 写错了)。
1122
+ //
1123
+ // 为什么不让客户端一律走 /desk/ask/{id}/control:那个 id 只存在于**拉起 core 的那个
1124
+ // 进程**里。手机、另一台设备、桌面壳直接 exec 的轮次都只能读盘尾随(见 /desk/session/{id}/follow),
1125
+ // 手里没有 ask id;这时不看登记表就永远停不下来(用户侧表现:「点了停止只回一句
1126
+ // 还没拿到轮次 id」)。
1127
+ func (s *Service) handleSessionStop(w http.ResponseWriter, r *http.Request, id string) {
1128
+ res, err := session.Stop(id)
1129
+ if err != nil {
1130
+ writeErr(w, http.StatusNotFound, fmt.Sprintf("停止会话 %s 失败:%v", id, err))
1131
+ return
1132
+ }
1133
+ writeJSON(w, http.StatusOK, res)
1134
+ }
1135
+
1074
1136
  // handleSessionDelete DELETE /desk/session/{id}:删掉一个会话 —— 登记表记录(可多条 run)
1075
1137
  // 与落盘的事件日志。id 可以是 session_id 或 run_id。
1076
1138
  //
@@ -1796,6 +1858,15 @@ type askRequest struct {
1796
1858
  // 不改变执行语义。core 没有 feature.session.hosted 时插件**忽略**它 ——
1797
1859
  // 实时气泡仍由客户端自己标,老 core 上只是历史回放少一个标识,不该阻断发送。
1798
1860
  Hosted bool `json:"hosted,omitempty"`
1861
+ // RequestID 客户端生成的幂等键(2026-10-23,只增字段)。
1862
+ //
1863
+ // 同一个 id 重复提交(断线重连 / App 重启 / 两个标签页同时发)只执行一次:
1864
+ // 服务端按它找到原作业并接回去,见 jobs.go。空 = 旧客户端,按匿名提交处理
1865
+ // (仍参与 workspace 串行,但没有幂等保护)。
1866
+ RequestID string `json:"request_id,omitempty"`
1867
+ // Serial 是否参与「同一 workspace 单槽」串行(nil / true = 参与)。
1868
+ // 客户端「并行模式」显式传 false;服务端仍会套一层全局并发上限。
1869
+ Serial *bool `json:"serial,omitempty"`
1799
1870
  }
1800
1871
 
1801
1872
  // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
@@ -1982,6 +2053,19 @@ func (r *askRun) finish(code int) {
1982
2053
  r.mu.Unlock()
1983
2054
  }
1984
2055
 
2056
+ // snapshot 取 seq > after 的事件(after=0 时额外放行网关合成的用户提问,seq=0)。
2057
+ // 与 handleAskEvents 的返回口径一致,供 SSE 中继与作业事件共用。
2058
+ func (r *askRun) snapshot(after uint64) (out []askEvent, lastSeq uint64, done bool, code int, stderr string) {
2059
+ r.mu.Lock()
2060
+ defer r.mu.Unlock()
2061
+ for _, e := range r.events {
2062
+ if e.seq > after || (e.seq == 0 && after == 0) {
2063
+ out = append(out, e)
2064
+ }
2065
+ }
2066
+ return out, r.seq, r.ended, r.code, r.stderr.String()
2067
+ }
2068
+
1985
2069
  // handleAsk 发起一轮并**把 core 的 NDJSON 事件原样中继**给调用方(SSE)。
1986
2070
  //
1987
2071
  // 中继而不是解释:事件契约的解析权在客户端(桌面 / 移动端各一份实现),
@@ -1996,18 +2080,79 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
1996
2080
  writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
1997
2081
  return
1998
2082
  }
1999
- if strings.TrimSpace(req.Prompt) == "" {
2000
- writeErr(w, http.StatusBadRequest, "prompt 不能为空")
2083
+ engine, _, st, perr := s.prepareAsk(req)
2084
+ if perr != nil {
2085
+ writeErr(w, st, perr.Error())
2086
+ return
2087
+ }
2088
+
2089
+ /* 从这里开始交给服务端调度器(见 jobs.go):
2090
+ · 同一 workspace 同时只跑一条;
2091
+ · request_id 相同 = 同一次提交,重连 / 重发不会多跑一遍;
2092
+ · 排队期间在 SSE 里发 `queued`(老客户端忽略未知事件),开跑时再发 `open`。 */
2093
+ source := requestSource(req.Client, r.Header.Get("Origin"))
2094
+ job, created, jerr := s.jobs.submitAsk(req, engine, source)
2095
+ if jerr != nil {
2096
+ writeErr(w, http.StatusBadRequest, jerr.Error())
2097
+ return
2098
+ }
2099
+
2100
+ // SSE 头必须在**第一帧之前**发出,否则客户端拿到的是 application/json。
2101
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
2102
+ w.Header().Set("Cache-Control", "no-cache")
2103
+ w.Header().Set("X-Accel-Buffering", "no")
2104
+ flusher, _ := w.(http.Flusher)
2105
+ send := func(event string, payload any) {
2106
+ b, _ := json.Marshal(payload)
2107
+ if event != "" {
2108
+ fmt.Fprintf(w, "event: %s\n", event)
2109
+ }
2110
+ fmt.Fprintf(w, "data: %s\n\n", b)
2111
+ if flusher != nil {
2112
+ flusher.Flush()
2113
+ }
2114
+ }
2115
+
2116
+ ctx := r.Context()
2117
+ run, ok := s.jobs.waitStarted(ctx, job, func(pos int) {
2118
+ send("queued", map[string]any{"job": job.ID, "state": JobQueued, "position": pos})
2119
+ })
2120
+ if !ok {
2121
+ /* 排队期间客户端断开:匿名提交(没有 request_id)没人会再来接,直接撤掉;
2122
+ 带 request_id 的留着 —— 客户端重启后会按同一个 id 接回来。 */
2123
+ if ctx.Err() != nil && created && strings.TrimSpace(req.RequestID) == "" {
2124
+ s.jobs.cancel(job.ID, "client disconnected")
2125
+ }
2126
+ /* 开跑前就终结(启动失败 / 被取消 / 排队过期):补一帧 end,
2127
+ 否则客户端只能看到连接被关掉、不知道原因。 */
2128
+ if jv := s.jobs.get(job.ID); jv != nil && jv.terminal() {
2129
+ code := -1
2130
+ if jv.State == JobDone {
2131
+ code = 0
2132
+ }
2133
+ send("end", map[string]any{"id": job.ID, "code": code, "stderr": jv.Error, "state": jv.State})
2134
+ }
2001
2135
  return
2002
2136
  }
2137
+ send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent, "job": job.ID})
2138
+ s.relayRun(ctx, w, run, flusher, send)
2139
+ }
2140
+
2141
+ /*
2142
+ prepareAsk 校验请求并拼出 core 参数(/desk/ask 与 /desk/jobs 共用一份)。
2143
+
2144
+ 返回 (engine, args, HTTP 状态码, error);error 非空时状态码用于直接回给客户端。
2145
+ */
2146
+ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
2147
+ if strings.TrimSpace(req.Prompt) == "" {
2148
+ return "", nil, http.StatusBadRequest, fmt.Errorf("prompt 不能为空")
2149
+ }
2003
2150
  engine := strings.TrimSpace(req.Engine)
2004
2151
  if engine == "" {
2005
- writeErr(w, http.StatusBadRequest, "engine 不能为空(清单见 /desk/engines)")
2006
- return
2152
+ return "", nil, http.StatusBadRequest, fmt.Errorf("engine 不能为空(清单见 /desk/engines)")
2007
2153
  }
2008
2154
  if !s.opts.Profile.AllowsEngine(engine) {
2009
- writeErr(w, http.StatusForbidden, fmt.Sprintf("profile 白名单不允许引擎 %q", engine))
2010
- return
2155
+ return "", nil, http.StatusForbidden, fmt.Errorf("profile 白名单不允许引擎 %q", engine)
2011
2156
  }
2012
2157
 
2013
2158
  idle := strings.TrimSpace(req.Idle)
@@ -2056,14 +2201,12 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2056
2201
  /* 老 core 不认识 --team(exit 2),这里先挡下并**说清楚为什么**:
2057
2202
  让用户看到「core 太旧,请升级」比让它跑一轮后报 usage error 好得多。 */
2058
2203
  if !hasStr(s.contract.Features, FeatureSessionTeam) {
2059
- writeErr(w, http.StatusNotImplemented,
2060
- "当前 core 不支持团队模式(缺少 "+FeatureSessionTeam+" 能力位,请升级 magic-agent)")
2061
- return
2204
+ return "", nil, http.StatusNotImplemented,
2205
+ fmt.Errorf("当前 core 不支持团队模式(缺少 %s 能力位,请升级 magic-agent)", FeatureSessionTeam)
2062
2206
  }
2063
2207
  teamArgs, terr := teamFlags(req.Team, s.opts.Profile, s.teamRoleNames())
2064
2208
  if terr != nil {
2065
- writeErr(w, http.StatusBadRequest, terr.Error())
2066
- return
2209
+ return "", nil, http.StatusBadRequest, terr
2067
2210
  }
2068
2211
  args = append(args, teamArgs...)
2069
2212
  }
@@ -2079,16 +2222,13 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2079
2222
  }
2080
2223
  st, err := os.Stat(p)
2081
2224
  if err != nil {
2082
- writeErr(w, http.StatusBadRequest, "附件不可访问:"+p)
2083
- return
2225
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件不可访问:%s", p)
2084
2226
  }
2085
2227
  if !st.Mode().IsRegular() {
2086
- writeErr(w, http.StatusBadRequest, "附件不是普通文件:"+p)
2087
- return
2228
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件不是普通文件:%s", p)
2088
2229
  }
2089
2230
  if st.Size() > maxUploadBytes {
2090
- writeErr(w, http.StatusBadRequest, fmt.Sprintf("附件超过 %dMB:%s", maxUploadBytes>>20, p))
2091
- return
2231
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件超过 %dMB:%s", maxUploadBytes>>20, p)
2092
2232
  }
2093
2233
  args = append(args, "-a", p)
2094
2234
  }
@@ -2098,35 +2238,35 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2098
2238
  if req.Hosted && hasStr(s.contract.Features, FeatureSessionHosted) {
2099
2239
  args = append(args, "--hosted")
2100
2240
  }
2241
+ return engine, args, http.StatusOK, nil
2242
+ }
2101
2243
 
2102
- /* 子进程**不绑 HTTP 请求的生命周期**(2026-10-19):此前用 r.Context(),手机端 SSE
2103
- 一断(锁屏 / 切后台 / WebView 回收 / 页面刷新)就会 SIGKILL core —— core 来不及写
2104
- 终态,登记表里那条会话就永远停在 running:界面显示「一直不结束」,移动端的串行门
2105
- (同空间有 running 就排队)还会被它永久挡住,新任务发不出去。
2106
- 客户端断连只意味着「没人看直播了」,不是「取消任务」;任务只应由显式控制
2107
- (/desk/ask/{id}/control 的 stop)或自然结束来终止。
2108
- WithoutCancel 保留请求里的值(便于排障),但不继承取消信号。
2109
- 也**不能**在这里 WithCancel + defer cancel:handler 断连就会返回,defer 一执行
2110
- 就等于把仍在跑的任务杀掉 —— 那正是要修的行为。 */
2111
- ctx := context.WithoutCancel(r.Context())
2244
+ /*
2245
+ startAsk 真正拉起 core 并开始吸收 stdout(不绑任何 HTTP 请求)。
2112
2246
 
2247
+ 排队与否由 jobs.go 决定;这里只负责「起了之后」的事:
2248
+ stdout 全部进 askRun 的回放缓冲,谁来读(SSE / /desk/ask/{id}/events / 作业事件)
2249
+ 都用同一份,断连不影响任务本身。
2250
+ */
2251
+ func (s *Service) startAsk(req askRequest, engine string, args []string, source string) (*askRun, error) {
2252
+ ctx := context.WithoutCancel(context.Background())
2113
2253
  cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
2114
2254
  /* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
2115
2255
  · EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
2116
- · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**,
2117
- 不把直接跑 CLI / 别的应用的任务混进来(用户 2026-10-12)。 */
2256
+ · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
2257
+ 另外注入工作空间锁(选入式,见 internal/session/workspace_lock.go):core 在 Begin
2258
+ 时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行。 */
2118
2259
  cmd.Env = append(os.Environ(),
2119
- session.EnvSource+"="+requestSource(req.Client, r.Header.Get("Origin")),
2120
- session.EnvClient+"="+s.opts.Profile.AppID())
2260
+ session.EnvSource+"="+source,
2261
+ session.EnvClient+"="+s.opts.Profile.AppID(),
2262
+ session.EnvWorkspaceLock+"=1")
2121
2263
  stdin, err := cmd.StdinPipe()
2122
2264
  if err != nil {
2123
- writeErr(w, http.StatusInternalServerError, "stdin pipe: "+err.Error())
2124
- return
2265
+ return nil, fmt.Errorf("stdin pipe: %w", err)
2125
2266
  }
2126
2267
  stdout, err := cmd.StdoutPipe()
2127
2268
  if err != nil {
2128
- writeErr(w, http.StatusInternalServerError, "stdout pipe: "+err.Error())
2129
- return
2269
+ return nil, fmt.Errorf("stdout pipe: %w", err)
2130
2270
  }
2131
2271
  run := &askRun{
2132
2272
  id: s.nextID(),
@@ -2139,8 +2279,7 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2139
2279
  session: strings.TrimSpace(req.Session),
2140
2280
  /* 这一轮算在哪个工作区(收尾后自动归纳要按它定位)。
2141
2281
  请求没带 workspace 时 core 会用它自己的 cwd 记录会话 —— 插件 spawn core 时
2142
- 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。
2143
- 两处口径必须一致,否则「聊了半天需求页没变」:归纳去的是另一个目录。 */
2282
+ 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。 */
2144
2283
  workspace: pinnedWorkspace(req.Workspace),
2145
2284
  }
2146
2285
  cmd.Stderr = run.stderr
@@ -2148,117 +2287,67 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2148
2287
  断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
2149
2288
  run.appendPrompt(req.Prompt, req.Attachments, req.Hosted)
2150
2289
  if err := cmd.Start(); err != nil {
2151
- writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
2152
- return
2290
+ return nil, fmt.Errorf("启动 core 失败:%w", err)
2153
2291
  }
2154
2292
  s.mu.Lock()
2155
2293
  s.runs[run.id] = run
2156
2294
  s.mu.Unlock()
2157
- // 注意:这里**不** defer 摘除 s.runs —— 断连后 handler 会先返回,任务仍在跑;
2158
- // 摘除由后台读 stdout 的 goroutine 在 core 真正退出时做(见下)。
2295
+ go s.pumpAsk(run, stdout)
2296
+ return run, nil
2297
+ }
2159
2298
 
2160
- // SSE 头必须在**第一行事件之前**发出,否则客户端拿到的是 application/json。
2161
- w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
2162
- w.Header().Set("Cache-Control", "no-cache")
2163
- w.Header().Set("X-Accel-Buffering", "no")
2164
- flusher, _ := w.(http.Flusher)
2165
- send := func(event string, payload any) {
2166
- b, _ := json.Marshal(payload)
2167
- if event != "" {
2168
- fmt.Fprintf(w, "event: %s\n", event)
2169
- }
2170
- fmt.Fprintf(w, "data: %s\n\n", b)
2171
- if flusher != nil {
2172
- flusher.Flush()
2299
+ // pumpAsk 读尽 stdout(断连也照读,否则管道写满会把 core 卡死),收尾后通知调度器。
2300
+ func (s *Service) pumpAsk(run *askRun, stdout io.Reader) {
2301
+ sc := bufio.NewScanner(stdout)
2302
+ sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
2303
+ for sc.Scan() {
2304
+ line := strings.TrimSpace(sc.Text())
2305
+ if line == "" {
2306
+ continue
2173
2307
  }
2308
+ run.appendEvent(line)
2174
2309
  }
2175
- send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent})
2310
+ werr := run.cmd.Wait()
2311
+ code := 0
2312
+ if werr != nil {
2313
+ var ee *exec.ExitError
2314
+ if errors.As(werr, &ee) {
2315
+ code = ee.ExitCode()
2316
+ } else {
2317
+ code = -1
2318
+ }
2319
+ }
2320
+ run.finish(code)
2321
+ s.jobs.runEnded(run)
2322
+ /* 需求自动归纳(2026-10-22):一轮收尾后防抖触发(12s 内再来一轮顺延)。
2323
+ 用 run 上记死的 workspace,不重新读 cwd —— 归纳必须落在**这一轮聊的那个目录**。 */
2324
+ s.maybeAutoConsolidate(run.workspace)
2325
+ close(run.done)
2326
+ /* 任务结束后**不立刻摘除**:客户端可能在断线重连后补拉终态(askRetention 窗口)。 */
2327
+ time.AfterFunc(askRetention, func() {
2328
+ s.mu.Lock()
2329
+ if cur := s.runs[run.id]; cur == run {
2330
+ delete(s.runs, run.id)
2331
+ }
2332
+ s.mu.Unlock()
2333
+ })
2334
+ }
2176
2335
 
2177
- /* SSE 心跳(2026-10-19 真实报障:手机端 17.7s 就「连接中断」,而 core 在首段输出前
2178
- 静默了约 27s —— 期间一个字节都没写,移动网络 / WebView 会把这条连接当成死连接掐掉,
2179
- 服务端任务其实一直跑到了 turn_end)。
2180
- 注释帧(以 ':' 开头、没有 data 行)按 SSE 规范会被客户端忽略,
2181
- 本仓库的 transport-http.js 解析器也只认 data 行,因此安全。 */
2336
+ /*
2337
+ relayRun 把一轮的事件按 seq 增量推给 SSE。
2338
+
2339
+ 不再用「读 stdout 的 goroutine → channel」那条路:作业可能先排队再开跑,
2340
+ 多一个消费者时 channel 会互相抢;回放缓冲天然支持多读者。
2341
+ */
2342
+ func (s *Service) relayRun(ctx context.Context, w http.ResponseWriter, run *askRun, flusher http.Flusher, send func(string, any)) {
2343
+ var after uint64
2344
+ tick := time.NewTicker(80 * time.Millisecond)
2182
2345
  ping := time.NewTicker(10 * time.Second)
2346
+ defer tick.Stop()
2183
2347
  defer ping.Stop()
2184
-
2185
- /* 读 stdout 与写 SSE 分开成两个 goroutine(2026-10-19):
2186
- 客户端断连时 handler 立即返回,但**后台必须继续把 core 的 stdout 抽干** ——
2187
- 否则管道写满后 core 会阻塞在写事件上,任务反而被卡死。
2188
- handler 走人之前关掉 detached,后台就改为「只 drain、不再入队」。 */
2189
- lines := make(chan string, 64)
2190
- detached := make(chan struct{})
2191
- finished := make(chan int, 1)
2192
- go func() {
2193
- sc := bufio.NewScanner(stdout)
2194
- sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
2195
- gone := false
2196
- for sc.Scan() {
2197
- line := strings.TrimSpace(sc.Text())
2198
- if line == "" {
2199
- continue
2200
- }
2201
- // 先入回放缓冲,再判断有没有人在看直播:断连之后客户端要靠这份缓冲
2202
- // 按 seq 续传,漏掉「gone 之后」的行就等于把断点之后的内容丢了。
2203
- run.appendEvent(line)
2204
- if gone {
2205
- continue // 没人看了:只 drain,避免 core 写满管道
2206
- }
2207
- select {
2208
- case lines <- line:
2209
- case <-detached:
2210
- gone = true
2211
- }
2212
- }
2213
- close(lines)
2214
- werr := cmd.Wait()
2215
- code := 0
2216
- if werr != nil {
2217
- var ee *exec.ExitError
2218
- if errors.As(werr, &ee) {
2219
- code = ee.ExitCode()
2220
- } else {
2221
- code = -1
2222
- }
2223
- }
2224
- run.finish(code)
2225
- /* 需求自动归纳(2026-10-22):一轮收尾后防抖触发(12s 内再来一轮顺延)。
2226
- 用 run 上记死的 workspace,不重新读 cwd —— 归纳必须落在**这一轮聊的那个目录**,
2227
- 而进程 cwd 在这期间可能被 profile 切换(--ui-dir 之类的启动参数不影响,但
2228
- 以后会有切换空间的功能;钉住是零成本的正确做法)。
2229
- 放在 finish 之后:先让 /desk/asks 与 SSE 看到本轮结束,再让后台开始整理。 */
2230
- s.maybeAutoConsolidate(run.workspace)
2231
- close(run.done)
2232
- /* 任务结束后**不立刻摘除**:客户端可能在断线重连后在补拉终态
2233
- (`GET /desk/ask/{id}/events`),留 askRetention 的时间窗。
2234
- 保留期内若同一个 id 被重新登记(不可能,id 唯一)也不覆盖。
2235
- handleAsks 会按 ended 过滤,所以这个「僵尸条目」不会出现在运行中列表。 */
2236
- time.AfterFunc(askRetention, func() {
2237
- s.mu.Lock()
2238
- if cur := s.runs[run.id]; cur == run {
2239
- delete(s.runs, run.id)
2240
- }
2241
- s.mu.Unlock()
2242
- })
2243
- finished <- code
2244
- }()
2245
- // 断连后后台继续跑完任务;handler 只负责把已有事件送出去。
2246
- defer close(detached)
2247
-
2248
2348
  for {
2249
2349
  select {
2250
- case line, ok := <-lines:
2251
- if !ok {
2252
- lines = nil // 等 finished(stdout 已读尽,core 正在退出)
2253
- continue
2254
- }
2255
- // 原样中继:核心事件流自己就是 NDJSON,不再包一层。
2256
- fmt.Fprintf(w, "data: %s\n\n", line)
2257
- if flusher != nil {
2258
- flusher.Flush()
2259
- }
2260
- case code := <-finished:
2261
- send("end", map[string]any{"id": run.id, "code": code, "stderr": run.stderr.String()})
2350
+ case <-ctx.Done():
2262
2351
  return
2263
2352
  case <-ping.C:
2264
2353
  // 心跳:只养连接,不下发业务数据(解析端会忽略没有 data 行的帧)。
@@ -2266,8 +2355,29 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2266
2355
  if flusher != nil {
2267
2356
  flusher.Flush()
2268
2357
  }
2269
- case <-r.Context().Done():
2270
- return
2358
+ case <-tick.C:
2359
+ events, last, done, code, stderr := run.snapshot(after)
2360
+ for _, e := range events {
2361
+ /* seq=0 是网关合成的「用户提问」(见 appendPrompt):它只进回放缓冲,
2362
+ 不进实时 SSE —— 实时路径上客户端自己已经画过用户气泡了。 */
2363
+ if e.seq == 0 {
2364
+ continue
2365
+ }
2366
+ fmt.Fprintf(w, "data: %s\n\n", e.data)
2367
+ if e.seq > after {
2368
+ after = e.seq
2369
+ }
2370
+ }
2371
+ if last > after {
2372
+ after = last
2373
+ }
2374
+ if flusher != nil {
2375
+ flusher.Flush()
2376
+ }
2377
+ if done {
2378
+ send("end", map[string]any{"id": run.id, "code": code, "stderr": stderr})
2379
+ return
2380
+ }
2271
2381
  }
2272
2382
  }
2273
2383
  }
@@ -71,7 +71,13 @@ const hostedContract = `{"contractVersion":2,"features":["feature.session.events
71
71
 
72
72
  func newTestService(t *testing.T, core string) *Service {
73
73
  t.Helper()
74
- svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
74
+ /* 测试互不干扰:作业目录 / 实例 id 都放临时目录;并发上限放大(假 core 不退出,
75
+ 不放大就会把同一进程里后续用例堵在队列里)。串行语义由 TestJobs* 用独立服务端覆盖。 */
76
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", t.TempDir())
77
+ t.Setenv("MAGIC_CLIENT_MAX_RUNNING", "64")
78
+ prof := DefaultProfile()
79
+ prof.Storage.Dir = t.TempDir()
80
+ svc, err := New(Options{MagicAgent: core, Profile: prof, Token: "tok"})
75
81
  if err != nil {
76
82
  t.Fatalf("New: %v", err)
77
83
  }
@@ -111,6 +117,33 @@ func TestNewRejectsContractMismatch(t *testing.T) {
111
117
  })
112
118
  }
113
119
 
120
+ // TestSessionStopRoute 手机 / 另一台设备只能按**会话 id** 停(那一轮在别人的进程里,
121
+ // 手里没有 ask id):路由必须存在,且未知 id 要诚实 404 —— 默默 200 会让界面以为停住了。
122
+ func TestSessionStopRoute(t *testing.T) {
123
+ t.Setenv(session.EnvDir, t.TempDir())
124
+ svc := newTestService(t, fakeCore(t, goodContract))
125
+ srv := httptest.NewServer(svc.Handler())
126
+ defer srv.Close()
127
+
128
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/session/no-such-session/stop", nil)
129
+ req.Header.Set("X-Magic-Token", "tok")
130
+ resp, err := http.DefaultClient.Do(req)
131
+ if err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ defer resp.Body.Close()
135
+ if resp.StatusCode != http.StatusNotFound {
136
+ t.Fatalf("未知会话应 404,实际 %d", resp.StatusCode)
137
+ }
138
+ var body map[string]any
139
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ if !strings.Contains(fmt.Sprint(body["error"]), "no such session") {
143
+ t.Errorf("404 应说明找不到该会话,实际 %v", body["error"])
144
+ }
145
+ }
146
+
114
147
  // TestAuthRequired 静态资源不鉴权,但 /desk/* 必须带令牌 ——
115
148
  // 本地端口对同机所有进程可见,不鉴权等于把引擎执行权交出去。
116
149
  func TestAuthRequired(t *testing.T) {
@@ -204,6 +237,18 @@ type sseReader struct {
204
237
  func openSSE(t *testing.T, url, token string, body any) *sseReader {
205
238
  t.Helper()
206
239
  b, _ := json.Marshal(body)
240
+ /* 这些用例验的是「中继 / 参数透传」,不是串行:显式 serial=false 让它们保持并发,
241
+ 否则假 core 跑完一轮后还等在 stdin 上,会把同一 workspace 的下一条堵死。
242
+ 串行语义由 TestJobs* 专门覆盖。 */
243
+ var m map[string]any
244
+ if err := json.Unmarshal(b, &m); err == nil && m != nil {
245
+ if _, ok := m["serial"]; !ok {
246
+ m["serial"] = false
247
+ if nb, err := json.Marshal(m); err == nil {
248
+ b = nb
249
+ }
250
+ }
251
+ }
207
252
  req, _ := http.NewRequest(http.MethodPost, url+"/desk/ask", bytes.NewReader(b))
208
253
  req.Header.Set("X-Magic-Token", token)
209
254
  req.Header.Set("Accept", "text/event-stream")
@@ -325,6 +325,8 @@ type Handle struct {
325
325
  noPersist bool
326
326
  // lastTouch 上次 Touch 续期的时间(节流用,见 Touch)。
327
327
  lastTouch time.Time
328
+ // lock 工作空间串行锁(见 workspace_lock.go;未启用时为 nil)。
329
+ lock *WorkspaceLock
328
330
  }
329
331
 
330
332
  // RunID 本次运行的 id。
@@ -416,6 +418,7 @@ func (h *Handle) Finish(sessionID, state string) {
416
418
  if h == nil {
417
419
  return
418
420
  }
421
+ defer h.lock.Release() // 工作空间锁随本轮结束释放(未启用时是 nil,安全)
419
422
  if state == StateFailed {
420
423
  if cur, err := readRecord(h.path); err == nil && isTerminalOverride(cur.State) {
421
424
  state = cur.State
@@ -478,6 +481,17 @@ func Begin(o BeginOptions) (*Handle, error) {
478
481
  if !o.NoPersist {
479
482
  dropUnreadable(dir)
480
483
  }
484
+ /* 工作空间串行锁(选入式,见 workspace_lock.go):拿到锁之后才写 running 记录,
485
+ 别的进程看到的「在跑」就一定是已经可以开跑的。
486
+ ⚠️ 团队子任务(ParentRunID 非空)不参与:它们与主任务同属一个工作空间,
487
+ 再取一把锁就是自己锁自己。 */
488
+ var wl *WorkspaceLock
489
+ if strings.TrimSpace(o.ParentRunID) == "" {
490
+ var lerr error
491
+ if wl, lerr = acquireWorkspaceLock(o.Workspace); lerr != nil {
492
+ return nil, lerr
493
+ }
494
+ }
481
495
 
482
496
  now := time.Now()
483
497
  rec := Record{
@@ -503,7 +517,7 @@ func Begin(o BeginOptions) (*Handle, error) {
503
517
  }
504
518
  // path 照样算出来:追加入口的 socket 与它同目录同前缀(见 AppendSocketPath),
505
519
  // 句柄里留着它,SetAppendPath 之类也不用知道自己是不是在存档模式。
506
- h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist}
520
+ h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist, lock: wl}
507
521
  h.write() // noPersist 时这里什么都不做
508
522
  // 「新起即停旧」:同一条会话上还活着的上一次运行先停掉(见 supersedeSiblings)。
509
523
  if !o.NoPersist && rec.SessionID != "" {