magic-agent 2.3.2 → 2.3.9

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 (33) 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 +368 -44
  7. package/internal/agent/engine.go +2 -2
  8. package/internal/agent/llmengine.go +0 -8
  9. package/internal/agent/llmengine_test.go +0 -17
  10. package/internal/agent/llmmodels.go +0 -35
  11. package/internal/client/cors_test.go +32 -0
  12. package/internal/client/jobs.go +1160 -0
  13. package/internal/client/jobs_test.go +369 -0
  14. package/internal/client/service.go +235 -140
  15. package/internal/client/service_test.go +43 -0
  16. package/internal/session/session.go +15 -1
  17. package/internal/session/workspace_lock.go +165 -0
  18. package/internal/session/workspace_lock_test.go +90 -0
  19. package/internal/suggest/suggest.go +8 -25
  20. package/internal/suggest/suggest_test.go +0 -6
  21. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  22. package/npm/apk/version.json +3 -3
  23. package/npm/dist/darwin-arm64/magic-agent +0 -0
  24. package/npm/dist/darwin-arm64/magic-client +0 -0
  25. package/npm/dist/darwin-x64/magic-agent +0 -0
  26. package/npm/dist/darwin-x64/magic-client +0 -0
  27. package/npm/dist/linux-arm64/magic-agent +0 -0
  28. package/npm/dist/linux-arm64/magic-client +0 -0
  29. package/npm/dist/linux-x64/magic-agent +0 -0
  30. package/npm/dist/linux-x64/magic-client +0 -0
  31. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  32. package/npm/dist/win32-x64/magic-client.exe +0 -0
  33. package/package.json +1 -1
@@ -95,6 +95,9 @@ type Service struct {
95
95
  runs map[string]*askRun
96
96
  seq int64
97
97
 
98
+ // jobs 服务端作业队列(同一 workspace 串行 + 幂等 + 持久化),见 jobs.go。
99
+ jobs *jobQueue
100
+
98
101
  // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
99
102
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
100
103
  browseOnce sync.Once
@@ -196,6 +199,9 @@ func New(opts Options) (*Service, error) {
196
199
  return nil, fmt.Errorf("core 缺通道能力 %s(需要支持 --stream --events 与 --control 的版本)", f)
197
200
  }
198
201
  }
202
+ /* 作业队列:服务端权威的排队层(多客户端串行 / 幂等 / 重启恢复)。
203
+ 必须在 New 里就建好:/desk/ask 第一次进来就要能入队。 */
204
+ s.jobs = newJobQueue(s)
199
205
  return s, nil
200
206
  }
201
207
 
@@ -218,6 +224,10 @@ func (s *Service) Handler() http.Handler {
218
224
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
219
225
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
220
226
  mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
227
+ /* 服务端作业队列(2026-10-23):跨客户端提交 / 查询 / 取消 / 事件。
228
+ /desk/ask 也走同一个调度器,这里给需要「先入队、不挂长连接」的客户端用。 */
229
+ mux.HandleFunc("/desk/jobs", s.guard(s.handleJobs))
230
+ mux.HandleFunc("/desk/jobs/", s.guard(s.handleJobsSub))
221
231
  mux.HandleFunc("/desk/upload", s.guard(s.handleUpload))
222
232
  // 回读已上传附件(缩略图 / 历史回看):<img> 带不了自定义头,靠 guard 支持的 ?token= 过鉴权。
223
233
  mux.HandleFunc("/desk/uploads/", s.guard(s.handleUploadGet))
@@ -301,7 +311,13 @@ func (s *Service) withCORS(next http.Handler) http.Handler {
301
311
  w.Header().Add("Vary", "Origin")
302
312
  if localOrigin(origin) {
303
313
  w.Header().Set("Access-Control-Allow-Origin", origin)
304
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
314
+ /* ⚠️ 写方法必须点名(2026-10-23 用户:「删除无法删除」)。
315
+ 只写 GET/POST 时,容器里(源=https://localhost)的删除 / 重命名 / 置顶同步
316
+ 全是带 X-Magic-Token 的 DELETE、PUT → 必发 OPTIONS 预检 → 浏览器看到
317
+ DELETE 未被允许,**真请求根本发不出去**(fetch 直接 reject)。
318
+ 同源页面(/m/ 远程加载、桌面壳)不走预检,所以只有打包进 App 的那侧坏 ——
319
+ curl 也永远看不见这条,必须靠 cors_test 钉住。 */
320
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
305
321
  w.Header().Set("Access-Control-Allow-Headers", "X-Magic-Token, Content-Type")
306
322
  w.Header().Set("Access-Control-Max-Age", "600")
307
323
  }
@@ -396,6 +412,9 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
396
412
  "engines": len(s.contract.Engines),
397
413
  "defaultWorkspace": wd,
398
414
  "workspacesRoot": filepath.Dir(wd),
415
+ // 服务端作业队列(2026-10-23):有它才谈得上「多客户端串行 + 幂等」。
416
+ // 老插件没有该字段 → 客户端退回本地排队(见 client-ui-mobile 的 fallback)。
417
+ "jobs": true,
399
418
  // 远程移动端界面在不在(见 handleMobileUI)。
400
419
  // 客户端的闸门页要据此决定「跳远程 /m/ 还是用包里自带的 ui.html」。
401
420
  // 为什么不放在 features 里:features 是**core 的能力位**(s.contract.Features),
@@ -681,8 +700,15 @@ func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request)
681
700
  }
682
701
  return
683
702
  }
703
+ /* 停止这一轮:**跨进程**用 —— 手机 / 另一台设备只尾随了别人进程里跑的那一轮,
704
+ 手里没有 ask id(/desk/ask/{id}/control 打不到),只能按会话 id 停。
705
+ 与 core 的 `--stop` 同一条路(登记表里的 pid + SIGTERM)。 */
706
+ if ok && action == "stop" && r.Method == http.MethodPost {
707
+ s.handleSessionStop(w, r, id)
708
+ return
709
+ }
684
710
  writeErr(w, http.StatusMethodNotAllowed,
685
- "POST / PUT 只支持 /desk/session/{id}/title 与 /desk/session/{id}/title/summarize")
711
+ "POST / PUT 只支持 /desk/session/{id}/title、/desk/session/{id}/title/summarize 与 /desk/session/{id}/stop")
686
712
  return
687
713
  }
688
714
  if r.Method != http.MethodGet {
@@ -1071,6 +1097,27 @@ func truncateForMsg(s string) string {
1071
1097
  return string(r[:cap]) + "…"
1072
1098
  }
1073
1099
 
1100
+ // handleSessionStop 停止某条会话正在跑的那一轮(跨进程)。
1101
+ //
1102
+ // 语义与 core 的 `magic-agent --stop <id>` 完全一致(session.Stop:SIGTERM 进程组,
1103
+ // 宽限期不退再 SIGKILL,最后把状态写成 cancelled):
1104
+ // - id 收 session_id 或 run_id;
1105
+ // - 幂等:进程已不在 / 会话已结束 → stopped=false + reason,仍是 200;
1106
+ // - 找不到 id → 404(多半是 id 写错了)。
1107
+ //
1108
+ // 为什么不让客户端一律走 /desk/ask/{id}/control:那个 id 只存在于**拉起 core 的那个
1109
+ // 进程**里。手机、另一台设备、桌面壳直接 exec 的轮次都只能读盘尾随(见 /desk/session/{id}/follow),
1110
+ // 手里没有 ask id;这时不看登记表就永远停不下来(用户侧表现:「点了停止只回一句
1111
+ // 还没拿到轮次 id」)。
1112
+ func (s *Service) handleSessionStop(w http.ResponseWriter, r *http.Request, id string) {
1113
+ res, err := session.Stop(id)
1114
+ if err != nil {
1115
+ writeErr(w, http.StatusNotFound, fmt.Sprintf("停止会话 %s 失败:%v", id, err))
1116
+ return
1117
+ }
1118
+ writeJSON(w, http.StatusOK, res)
1119
+ }
1120
+
1074
1121
  // handleSessionDelete DELETE /desk/session/{id}:删掉一个会话 —— 登记表记录(可多条 run)
1075
1122
  // 与落盘的事件日志。id 可以是 session_id 或 run_id。
1076
1123
  //
@@ -1796,6 +1843,15 @@ type askRequest struct {
1796
1843
  // 不改变执行语义。core 没有 feature.session.hosted 时插件**忽略**它 ——
1797
1844
  // 实时气泡仍由客户端自己标,老 core 上只是历史回放少一个标识,不该阻断发送。
1798
1845
  Hosted bool `json:"hosted,omitempty"`
1846
+ // RequestID 客户端生成的幂等键(2026-10-23,只增字段)。
1847
+ //
1848
+ // 同一个 id 重复提交(断线重连 / App 重启 / 两个标签页同时发)只执行一次:
1849
+ // 服务端按它找到原作业并接回去,见 jobs.go。空 = 旧客户端,按匿名提交处理
1850
+ // (仍参与 workspace 串行,但没有幂等保护)。
1851
+ RequestID string `json:"request_id,omitempty"`
1852
+ // Serial 是否参与「同一 workspace 单槽」串行(nil / true = 参与)。
1853
+ // 客户端「并行模式」显式传 false;服务端仍会套一层全局并发上限。
1854
+ Serial *bool `json:"serial,omitempty"`
1799
1855
  }
1800
1856
 
1801
1857
  // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
@@ -1982,6 +2038,19 @@ func (r *askRun) finish(code int) {
1982
2038
  r.mu.Unlock()
1983
2039
  }
1984
2040
 
2041
+ // snapshot 取 seq > after 的事件(after=0 时额外放行网关合成的用户提问,seq=0)。
2042
+ // 与 handleAskEvents 的返回口径一致,供 SSE 中继与作业事件共用。
2043
+ func (r *askRun) snapshot(after uint64) (out []askEvent, lastSeq uint64, done bool, code int, stderr string) {
2044
+ r.mu.Lock()
2045
+ defer r.mu.Unlock()
2046
+ for _, e := range r.events {
2047
+ if e.seq > after || (e.seq == 0 && after == 0) {
2048
+ out = append(out, e)
2049
+ }
2050
+ }
2051
+ return out, r.seq, r.ended, r.code, r.stderr.String()
2052
+ }
2053
+
1985
2054
  // handleAsk 发起一轮并**把 core 的 NDJSON 事件原样中继**给调用方(SSE)。
1986
2055
  //
1987
2056
  // 中继而不是解释:事件契约的解析权在客户端(桌面 / 移动端各一份实现),
@@ -1996,18 +2065,79 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
1996
2065
  writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
1997
2066
  return
1998
2067
  }
1999
- if strings.TrimSpace(req.Prompt) == "" {
2000
- writeErr(w, http.StatusBadRequest, "prompt 不能为空")
2068
+ engine, _, st, perr := s.prepareAsk(req)
2069
+ if perr != nil {
2070
+ writeErr(w, st, perr.Error())
2001
2071
  return
2002
2072
  }
2073
+
2074
+ /* 从这里开始交给服务端调度器(见 jobs.go):
2075
+ · 同一 workspace 同时只跑一条;
2076
+ · request_id 相同 = 同一次提交,重连 / 重发不会多跑一遍;
2077
+ · 排队期间在 SSE 里发 `queued`(老客户端忽略未知事件),开跑时再发 `open`。 */
2078
+ source := requestSource(req.Client, r.Header.Get("Origin"))
2079
+ job, created, jerr := s.jobs.submitAsk(req, engine, source)
2080
+ if jerr != nil {
2081
+ writeErr(w, http.StatusBadRequest, jerr.Error())
2082
+ return
2083
+ }
2084
+
2085
+ // SSE 头必须在**第一帧之前**发出,否则客户端拿到的是 application/json。
2086
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
2087
+ w.Header().Set("Cache-Control", "no-cache")
2088
+ w.Header().Set("X-Accel-Buffering", "no")
2089
+ flusher, _ := w.(http.Flusher)
2090
+ send := func(event string, payload any) {
2091
+ b, _ := json.Marshal(payload)
2092
+ if event != "" {
2093
+ fmt.Fprintf(w, "event: %s\n", event)
2094
+ }
2095
+ fmt.Fprintf(w, "data: %s\n\n", b)
2096
+ if flusher != nil {
2097
+ flusher.Flush()
2098
+ }
2099
+ }
2100
+
2101
+ ctx := r.Context()
2102
+ run, ok := s.jobs.waitStarted(ctx, job, func(pos int) {
2103
+ send("queued", map[string]any{"job": job.ID, "state": JobQueued, "position": pos})
2104
+ })
2105
+ if !ok {
2106
+ /* 排队期间客户端断开:匿名提交(没有 request_id)没人会再来接,直接撤掉;
2107
+ 带 request_id 的留着 —— 客户端重启后会按同一个 id 接回来。 */
2108
+ if ctx.Err() != nil && created && strings.TrimSpace(req.RequestID) == "" {
2109
+ s.jobs.cancel(job.ID, "client disconnected")
2110
+ }
2111
+ /* 开跑前就终结(启动失败 / 被取消 / 排队过期):补一帧 end,
2112
+ 否则客户端只能看到连接被关掉、不知道原因。 */
2113
+ if jv := s.jobs.get(job.ID); jv != nil && jv.terminal() {
2114
+ code := -1
2115
+ if jv.State == JobDone {
2116
+ code = 0
2117
+ }
2118
+ send("end", map[string]any{"id": job.ID, "code": code, "stderr": jv.Error, "state": jv.State})
2119
+ }
2120
+ return
2121
+ }
2122
+ send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent, "job": job.ID})
2123
+ s.relayRun(ctx, w, run, flusher, send)
2124
+ }
2125
+
2126
+ /*
2127
+ prepareAsk 校验请求并拼出 core 参数(/desk/ask 与 /desk/jobs 共用一份)。
2128
+
2129
+ 返回 (engine, args, HTTP 状态码, error);error 非空时状态码用于直接回给客户端。
2130
+ */
2131
+ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
2132
+ if strings.TrimSpace(req.Prompt) == "" {
2133
+ return "", nil, http.StatusBadRequest, fmt.Errorf("prompt 不能为空")
2134
+ }
2003
2135
  engine := strings.TrimSpace(req.Engine)
2004
2136
  if engine == "" {
2005
- writeErr(w, http.StatusBadRequest, "engine 不能为空(清单见 /desk/engines)")
2006
- return
2137
+ return "", nil, http.StatusBadRequest, fmt.Errorf("engine 不能为空(清单见 /desk/engines)")
2007
2138
  }
2008
2139
  if !s.opts.Profile.AllowsEngine(engine) {
2009
- writeErr(w, http.StatusForbidden, fmt.Sprintf("profile 白名单不允许引擎 %q", engine))
2010
- return
2140
+ return "", nil, http.StatusForbidden, fmt.Errorf("profile 白名单不允许引擎 %q", engine)
2011
2141
  }
2012
2142
 
2013
2143
  idle := strings.TrimSpace(req.Idle)
@@ -2056,14 +2186,12 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2056
2186
  /* 老 core 不认识 --team(exit 2),这里先挡下并**说清楚为什么**:
2057
2187
  让用户看到「core 太旧,请升级」比让它跑一轮后报 usage error 好得多。 */
2058
2188
  if !hasStr(s.contract.Features, FeatureSessionTeam) {
2059
- writeErr(w, http.StatusNotImplemented,
2060
- "当前 core 不支持团队模式(缺少 "+FeatureSessionTeam+" 能力位,请升级 magic-agent)")
2061
- return
2189
+ return "", nil, http.StatusNotImplemented,
2190
+ fmt.Errorf("当前 core 不支持团队模式(缺少 %s 能力位,请升级 magic-agent)", FeatureSessionTeam)
2062
2191
  }
2063
2192
  teamArgs, terr := teamFlags(req.Team, s.opts.Profile, s.teamRoleNames())
2064
2193
  if terr != nil {
2065
- writeErr(w, http.StatusBadRequest, terr.Error())
2066
- return
2194
+ return "", nil, http.StatusBadRequest, terr
2067
2195
  }
2068
2196
  args = append(args, teamArgs...)
2069
2197
  }
@@ -2079,16 +2207,13 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2079
2207
  }
2080
2208
  st, err := os.Stat(p)
2081
2209
  if err != nil {
2082
- writeErr(w, http.StatusBadRequest, "附件不可访问:"+p)
2083
- return
2210
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件不可访问:%s", p)
2084
2211
  }
2085
2212
  if !st.Mode().IsRegular() {
2086
- writeErr(w, http.StatusBadRequest, "附件不是普通文件:"+p)
2087
- return
2213
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件不是普通文件:%s", p)
2088
2214
  }
2089
2215
  if st.Size() > maxUploadBytes {
2090
- writeErr(w, http.StatusBadRequest, fmt.Sprintf("附件超过 %dMB:%s", maxUploadBytes>>20, p))
2091
- return
2216
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件超过 %dMB:%s", maxUploadBytes>>20, p)
2092
2217
  }
2093
2218
  args = append(args, "-a", p)
2094
2219
  }
@@ -2098,35 +2223,35 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2098
2223
  if req.Hosted && hasStr(s.contract.Features, FeatureSessionHosted) {
2099
2224
  args = append(args, "--hosted")
2100
2225
  }
2226
+ return engine, args, http.StatusOK, nil
2227
+ }
2101
2228
 
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())
2229
+ /*
2230
+ startAsk 真正拉起 core 并开始吸收 stdout(不绑任何 HTTP 请求)。
2112
2231
 
2232
+ 排队与否由 jobs.go 决定;这里只负责「起了之后」的事:
2233
+ stdout 全部进 askRun 的回放缓冲,谁来读(SSE / /desk/ask/{id}/events / 作业事件)
2234
+ 都用同一份,断连不影响任务本身。
2235
+ */
2236
+ func (s *Service) startAsk(req askRequest, engine string, args []string, source string) (*askRun, error) {
2237
+ ctx := context.WithoutCancel(context.Background())
2113
2238
  cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
2114
2239
  /* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
2115
2240
  · EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
2116
- · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**,
2117
- 不把直接跑 CLI / 别的应用的任务混进来(用户 2026-10-12)。 */
2241
+ · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
2242
+ 另外注入工作空间锁(选入式,见 internal/session/workspace_lock.go):core 在 Begin
2243
+ 时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行。 */
2118
2244
  cmd.Env = append(os.Environ(),
2119
- session.EnvSource+"="+requestSource(req.Client, r.Header.Get("Origin")),
2120
- session.EnvClient+"="+s.opts.Profile.AppID())
2245
+ session.EnvSource+"="+source,
2246
+ session.EnvClient+"="+s.opts.Profile.AppID(),
2247
+ session.EnvWorkspaceLock+"=1")
2121
2248
  stdin, err := cmd.StdinPipe()
2122
2249
  if err != nil {
2123
- writeErr(w, http.StatusInternalServerError, "stdin pipe: "+err.Error())
2124
- return
2250
+ return nil, fmt.Errorf("stdin pipe: %w", err)
2125
2251
  }
2126
2252
  stdout, err := cmd.StdoutPipe()
2127
2253
  if err != nil {
2128
- writeErr(w, http.StatusInternalServerError, "stdout pipe: "+err.Error())
2129
- return
2254
+ return nil, fmt.Errorf("stdout pipe: %w", err)
2130
2255
  }
2131
2256
  run := &askRun{
2132
2257
  id: s.nextID(),
@@ -2139,8 +2264,7 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2139
2264
  session: strings.TrimSpace(req.Session),
2140
2265
  /* 这一轮算在哪个工作区(收尾后自动归纳要按它定位)。
2141
2266
  请求没带 workspace 时 core 会用它自己的 cwd 记录会话 —— 插件 spawn core 时
2142
- 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。
2143
- 两处口径必须一致,否则「聊了半天需求页没变」:归纳去的是另一个目录。 */
2267
+ 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。 */
2144
2268
  workspace: pinnedWorkspace(req.Workspace),
2145
2269
  }
2146
2270
  cmd.Stderr = run.stderr
@@ -2148,117 +2272,67 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2148
2272
  断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
2149
2273
  run.appendPrompt(req.Prompt, req.Attachments, req.Hosted)
2150
2274
  if err := cmd.Start(); err != nil {
2151
- writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
2152
- return
2275
+ return nil, fmt.Errorf("启动 core 失败:%w", err)
2153
2276
  }
2154
2277
  s.mu.Lock()
2155
2278
  s.runs[run.id] = run
2156
2279
  s.mu.Unlock()
2157
- // 注意:这里**不** defer 摘除 s.runs —— 断连后 handler 会先返回,任务仍在跑;
2158
- // 摘除由后台读 stdout 的 goroutine 在 core 真正退出时做(见下)。
2280
+ go s.pumpAsk(run, stdout)
2281
+ return run, nil
2282
+ }
2159
2283
 
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()
2284
+ // pumpAsk 读尽 stdout(断连也照读,否则管道写满会把 core 卡死),收尾后通知调度器。
2285
+ func (s *Service) pumpAsk(run *askRun, stdout io.Reader) {
2286
+ sc := bufio.NewScanner(stdout)
2287
+ sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
2288
+ for sc.Scan() {
2289
+ line := strings.TrimSpace(sc.Text())
2290
+ if line == "" {
2291
+ continue
2173
2292
  }
2293
+ run.appendEvent(line)
2174
2294
  }
2175
- send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent})
2295
+ werr := run.cmd.Wait()
2296
+ code := 0
2297
+ if werr != nil {
2298
+ var ee *exec.ExitError
2299
+ if errors.As(werr, &ee) {
2300
+ code = ee.ExitCode()
2301
+ } else {
2302
+ code = -1
2303
+ }
2304
+ }
2305
+ run.finish(code)
2306
+ s.jobs.runEnded(run)
2307
+ /* 需求自动归纳(2026-10-22):一轮收尾后防抖触发(12s 内再来一轮顺延)。
2308
+ 用 run 上记死的 workspace,不重新读 cwd —— 归纳必须落在**这一轮聊的那个目录**。 */
2309
+ s.maybeAutoConsolidate(run.workspace)
2310
+ close(run.done)
2311
+ /* 任务结束后**不立刻摘除**:客户端可能在断线重连后补拉终态(askRetention 窗口)。 */
2312
+ time.AfterFunc(askRetention, func() {
2313
+ s.mu.Lock()
2314
+ if cur := s.runs[run.id]; cur == run {
2315
+ delete(s.runs, run.id)
2316
+ }
2317
+ s.mu.Unlock()
2318
+ })
2319
+ }
2320
+
2321
+ /*
2322
+ relayRun 把一轮的事件按 seq 增量推给 SSE。
2176
2323
 
2177
- /* SSE 心跳(2026-10-19 真实报障:手机端 17.7s 就「连接中断」,而 core 在首段输出前
2178
- 静默了约 27s —— 期间一个字节都没写,移动网络 / WebView 会把这条连接当成死连接掐掉,
2179
- 服务端任务其实一直跑到了 turn_end)。
2180
- 注释帧(以 ':' 开头、没有 data 行)按 SSE 规范会被客户端忽略,
2181
- 本仓库的 transport-http.js 解析器也只认 data 行,因此安全。 */
2324
+ 不再用「读 stdout 的 goroutine → channel」那条路:作业可能先排队再开跑,
2325
+ 多一个消费者时 channel 会互相抢;回放缓冲天然支持多读者。
2326
+ */
2327
+ func (s *Service) relayRun(ctx context.Context, w http.ResponseWriter, run *askRun, flusher http.Flusher, send func(string, any)) {
2328
+ var after uint64
2329
+ tick := time.NewTicker(80 * time.Millisecond)
2182
2330
  ping := time.NewTicker(10 * time.Second)
2331
+ defer tick.Stop()
2183
2332
  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
2333
  for {
2249
2334
  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()})
2335
+ case <-ctx.Done():
2262
2336
  return
2263
2337
  case <-ping.C:
2264
2338
  // 心跳:只养连接,不下发业务数据(解析端会忽略没有 data 行的帧)。
@@ -2266,8 +2340,29 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2266
2340
  if flusher != nil {
2267
2341
  flusher.Flush()
2268
2342
  }
2269
- case <-r.Context().Done():
2270
- return
2343
+ case <-tick.C:
2344
+ events, last, done, code, stderr := run.snapshot(after)
2345
+ for _, e := range events {
2346
+ /* seq=0 是网关合成的「用户提问」(见 appendPrompt):它只进回放缓冲,
2347
+ 不进实时 SSE —— 实时路径上客户端自己已经画过用户气泡了。 */
2348
+ if e.seq == 0 {
2349
+ continue
2350
+ }
2351
+ fmt.Fprintf(w, "data: %s\n\n", e.data)
2352
+ if e.seq > after {
2353
+ after = e.seq
2354
+ }
2355
+ }
2356
+ if last > after {
2357
+ after = last
2358
+ }
2359
+ if flusher != nil {
2360
+ flusher.Flush()
2361
+ }
2362
+ if done {
2363
+ send("end", map[string]any{"id": run.id, "code": code, "stderr": stderr})
2364
+ return
2365
+ }
2271
2366
  }
2272
2367
  }
2273
2368
  }
@@ -71,6 +71,10 @@ 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
+ /* 测试互不干扰:作业目录放临时目录;并发上限放大(假 core 不退出,不放大就会把
75
+ 同一进程里后续用例堵在队列里)。串行语义由 TestJobs* 用独立服务端覆盖。 */
76
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", t.TempDir())
77
+ t.Setenv("MAGIC_CLIENT_MAX_RUNNING", "64")
74
78
  svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
75
79
  if err != nil {
76
80
  t.Fatalf("New: %v", err)
@@ -111,6 +115,33 @@ func TestNewRejectsContractMismatch(t *testing.T) {
111
115
  })
112
116
  }
113
117
 
118
+ // TestSessionStopRoute 手机 / 另一台设备只能按**会话 id** 停(那一轮在别人的进程里,
119
+ // 手里没有 ask id):路由必须存在,且未知 id 要诚实 404 —— 默默 200 会让界面以为停住了。
120
+ func TestSessionStopRoute(t *testing.T) {
121
+ t.Setenv(session.EnvDir, t.TempDir())
122
+ svc := newTestService(t, fakeCore(t, goodContract))
123
+ srv := httptest.NewServer(svc.Handler())
124
+ defer srv.Close()
125
+
126
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/session/no-such-session/stop", nil)
127
+ req.Header.Set("X-Magic-Token", "tok")
128
+ resp, err := http.DefaultClient.Do(req)
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ defer resp.Body.Close()
133
+ if resp.StatusCode != http.StatusNotFound {
134
+ t.Fatalf("未知会话应 404,实际 %d", resp.StatusCode)
135
+ }
136
+ var body map[string]any
137
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ if !strings.Contains(fmt.Sprint(body["error"]), "no such session") {
141
+ t.Errorf("404 应说明找不到该会话,实际 %v", body["error"])
142
+ }
143
+ }
144
+
114
145
  // TestAuthRequired 静态资源不鉴权,但 /desk/* 必须带令牌 ——
115
146
  // 本地端口对同机所有进程可见,不鉴权等于把引擎执行权交出去。
116
147
  func TestAuthRequired(t *testing.T) {
@@ -204,6 +235,18 @@ type sseReader struct {
204
235
  func openSSE(t *testing.T, url, token string, body any) *sseReader {
205
236
  t.Helper()
206
237
  b, _ := json.Marshal(body)
238
+ /* 这些用例验的是「中继 / 参数透传」,不是串行:显式 serial=false 让它们保持并发,
239
+ 否则假 core 跑完一轮后还等在 stdin 上,会把同一 workspace 的下一条堵死。
240
+ 串行语义由 TestJobs* 专门覆盖。 */
241
+ var m map[string]any
242
+ if err := json.Unmarshal(b, &m); err == nil && m != nil {
243
+ if _, ok := m["serial"]; !ok {
244
+ m["serial"] = false
245
+ if nb, err := json.Marshal(m); err == nil {
246
+ b = nb
247
+ }
248
+ }
249
+ }
207
250
  req, _ := http.NewRequest(http.MethodPost, url+"/desk/ask", bytes.NewReader(b))
208
251
  req.Header.Set("X-Magic-Token", token)
209
252
  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 != "" {