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.
- package/CLIENT-CONTRACT.md +30 -4
- package/README.md +1 -1
- package/client-ui/shared/conv/07-stream-delegation.js +15 -0
- package/client-ui/shared/conversation.js +39 -0
- package/client-ui/shared/transport-http.js +10 -0
- package/client-ui-mobile/index.html +431 -46
- package/cmd/magic-client/main.go +5 -0
- package/internal/agent/engine.go +2 -2
- package/internal/agent/llmengine.go +0 -8
- package/internal/agent/llmengine_test.go +0 -17
- package/internal/agent/llmmodels.go +0 -35
- package/internal/client/cors_test.go +32 -0
- package/internal/client/instance.go +59 -0
- package/internal/client/jobs.go +1160 -0
- package/internal/client/jobs_test.go +369 -0
- package/internal/client/service.go +251 -141
- package/internal/client/service_test.go +46 -1
- package/internal/session/session.go +15 -1
- package/internal/session/workspace_lock.go +165 -0
- package/internal/session/workspace_lock_test.go +90 -0
- package/internal/suggest/suggest.go +8 -25
- package/internal/suggest/suggest_test.go +0 -6
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- 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/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
|
-
|
|
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}/
|
|
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
|
-
|
|
2000
|
-
|
|
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
|
-
|
|
2006
|
-
return
|
|
2152
|
+
return "", nil, http.StatusBadRequest, fmt.Errorf("engine 不能为空(清单见 /desk/engines)")
|
|
2007
2153
|
}
|
|
2008
2154
|
if !s.opts.Profile.AllowsEngine(engine) {
|
|
2009
|
-
|
|
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
|
-
|
|
2060
|
-
"当前 core 不支持团队模式(缺少
|
|
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
|
-
|
|
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
|
-
|
|
2083
|
-
return
|
|
2225
|
+
return "", nil, http.StatusBadRequest, fmt.Errorf("附件不可访问:%s", p)
|
|
2084
2226
|
}
|
|
2085
2227
|
if !st.Mode().IsRegular() {
|
|
2086
|
-
|
|
2087
|
-
return
|
|
2228
|
+
return "", nil, http.StatusBadRequest, fmt.Errorf("附件不是普通文件:%s", p)
|
|
2088
2229
|
}
|
|
2089
2230
|
if st.Size() > maxUploadBytes {
|
|
2090
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
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
|
-
|
|
2256
|
+
· EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
|
|
2257
|
+
另外注入工作空间锁(选入式,见 internal/session/workspace_lock.go):core 在 Begin
|
|
2258
|
+
时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行。 */
|
|
2118
2259
|
cmd.Env = append(os.Environ(),
|
|
2119
|
-
session.EnvSource+"="+
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2158
|
-
|
|
2295
|
+
go s.pumpAsk(run, stdout)
|
|
2296
|
+
return run, nil
|
|
2297
|
+
}
|
|
2159
2298
|
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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
|
|
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 <-
|
|
2270
|
-
|
|
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
|
-
|
|
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 != "" {
|