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
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"sort"
|
|
11
|
+
"strconv"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
17
|
+
"github.com/darren/magic-agent/internal/session"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
/* ── 服务端作业队列(2026-10-23,多客户端串行的权威层)──
|
|
21
|
+
|
|
22
|
+
为什么队列一定要在服务端:
|
|
23
|
+
· 排队判断原先在每台手机的页面里,各判各的 —— 两台设备同时看到「空闲」就同时开跑;
|
|
24
|
+
· 页面被杀 / 切环境 / 锁屏,客户端队列就断了,而任务不该断;
|
|
25
|
+
· 同一个 request_id 需要跨客户端、跨页面实例去重,只有服务端做得到。
|
|
26
|
+
|
|
27
|
+
几个不变量:
|
|
28
|
+
1. 同一 workspace 同时只跑一条(Serial=true 时);不同 workspace 并行;
|
|
29
|
+
2. 全局并发上限(MAGIC_CLIENT_MAX_RUNNING,默认 4)兜底,防 fork 风暴;
|
|
30
|
+
3. request_id 相同 = 同一次提交:重复 POST 返回原作业,不会多跑一遍;
|
|
31
|
+
4. 排队/运行状态落盘(<data dir>/jobs/*.json),服务重启后 queued 继续、running 残留标失败;
|
|
32
|
+
5. 客户端全断也不撤销已开跑的任务;匿名排队(没有 request_id)在连接断开时撤销。
|
|
33
|
+
|
|
34
|
+
与 /desk/ask 的关系:/desk/ask 先入队再等准入,排队期间发 `event: queued`,
|
|
35
|
+
准入后照旧发 `open` + 事件流。老客户端不改一行也能拿到服务端串行。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const (
|
|
39
|
+
JobQueued = "queued"
|
|
40
|
+
JobRunning = "running"
|
|
41
|
+
JobDone = "done"
|
|
42
|
+
JobFailed = "failed"
|
|
43
|
+
JobCancelled = "cancelled"
|
|
44
|
+
JobExpired = "expired"
|
|
45
|
+
|
|
46
|
+
// jobQueuedTTL 排队多久没轮到就过期(避免关机一周后突然跑一堆旧任务)。
|
|
47
|
+
jobQueuedTTL = 24 * time.Hour
|
|
48
|
+
// jobKeepTerminal 终态作业保留多久(幂等重放窗口)。
|
|
49
|
+
jobKeepTerminal = 7 * 24 * time.Hour
|
|
50
|
+
// jobMaxQueued 每个 workspace 最多排多少条(防刷)。
|
|
51
|
+
jobMaxQueued = 100
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// JobOwner 谁提交的(列表页与取消权限展示用)。
|
|
55
|
+
type JobOwner struct {
|
|
56
|
+
Client string `json:"client,omitempty"`
|
|
57
|
+
Source string `json:"source,omitempty"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Job 一条排队/运行中的作业(磁盘形状即 JSON 形状)。
|
|
61
|
+
type Job struct {
|
|
62
|
+
ID string `json:"id"`
|
|
63
|
+
RequestID string `json:"request_id,omitempty"`
|
|
64
|
+
|
|
65
|
+
// Workspace 归一后的绝对路径;槽位键就用它(空工作空间 = 服务端 cwd)。
|
|
66
|
+
Workspace string `json:"workspace"`
|
|
67
|
+
// ExplicitWorkspace 原请求是否显式带了 -w:恢复时原样下发,不能替用户改写默认语义。
|
|
68
|
+
ExplicitWorkspace bool `json:"explicit_workspace,omitempty"`
|
|
69
|
+
|
|
70
|
+
Session string `json:"session,omitempty"`
|
|
71
|
+
Engine string `json:"engine"`
|
|
72
|
+
Model string `json:"model,omitempty"`
|
|
73
|
+
Prompt string `json:"prompt"`
|
|
74
|
+
Permission string `json:"permission,omitempty"`
|
|
75
|
+
Reasoning string `json:"reasoning,omitempty"`
|
|
76
|
+
Idle string `json:"idle,omitempty"`
|
|
77
|
+
NoThinking bool `json:"no_thinking,omitempty"`
|
|
78
|
+
Hosted bool `json:"hosted,omitempty"`
|
|
79
|
+
Attachments []string `json:"attachments,omitempty"`
|
|
80
|
+
Team *teamRequest `json:"team,omitempty"`
|
|
81
|
+
|
|
82
|
+
// Serial 是否参与 workspace 单槽串行(默认 true)。
|
|
83
|
+
Serial bool `json:"serial"`
|
|
84
|
+
|
|
85
|
+
State string `json:"state"`
|
|
86
|
+
Position int `json:"position,omitempty"`
|
|
87
|
+
Owner JobOwner `json:"owner"`
|
|
88
|
+
|
|
89
|
+
AskID string `json:"ask_id,omitempty"`
|
|
90
|
+
RunID string `json:"run_id,omitempty"`
|
|
91
|
+
SessionID string `json:"session_id,omitempty"`
|
|
92
|
+
PID int `json:"pid,omitempty"`
|
|
93
|
+
Error string `json:"error,omitempty"`
|
|
94
|
+
|
|
95
|
+
CreatedAt time.Time `json:"created_at"`
|
|
96
|
+
StartedAt time.Time `json:"started_at,omitempty"`
|
|
97
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
98
|
+
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func (j *Job) terminal() bool {
|
|
102
|
+
switch j.State {
|
|
103
|
+
case JobDone, JobFailed, JobCancelled, JobExpired:
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// askRequest 把作业还原成 core 启动参数需要的请求(不带上 RequestID:那是队列自己的事)。
|
|
110
|
+
func (j *Job) askRequest() askRequest {
|
|
111
|
+
req := askRequest{
|
|
112
|
+
Engine: j.Engine,
|
|
113
|
+
Prompt: j.Prompt,
|
|
114
|
+
Model: j.Model,
|
|
115
|
+
Session: j.Session,
|
|
116
|
+
Permission: j.Permission,
|
|
117
|
+
Reasoning: j.Reasoning,
|
|
118
|
+
Idle: j.Idle,
|
|
119
|
+
NoThinking: j.NoThinking,
|
|
120
|
+
Hosted: j.Hosted,
|
|
121
|
+
Attachments: append([]string(nil), j.Attachments...),
|
|
122
|
+
Team: j.Team,
|
|
123
|
+
Client: j.Owner.Source,
|
|
124
|
+
}
|
|
125
|
+
if j.ExplicitWorkspace {
|
|
126
|
+
req.Workspace = j.Workspace
|
|
127
|
+
}
|
|
128
|
+
return req
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type jobQueue struct {
|
|
132
|
+
svc *Service
|
|
133
|
+
dir string
|
|
134
|
+
|
|
135
|
+
mu sync.Mutex
|
|
136
|
+
jobs map[string]*Job
|
|
137
|
+
byReq map[string]string
|
|
138
|
+
byAsk map[string]string
|
|
139
|
+
runs map[string]*askRun
|
|
140
|
+
active map[string]string // workspace → job id
|
|
141
|
+
running int
|
|
142
|
+
max int
|
|
143
|
+
seq int64
|
|
144
|
+
|
|
145
|
+
wake chan struct{}
|
|
146
|
+
stop chan struct{}
|
|
147
|
+
closed bool
|
|
148
|
+
// leaseOwned 本进程是不是调度主(同一 jobs 目录只允许一个进程真正调度)。
|
|
149
|
+
leaseOwned bool
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const envJobsDir = "MAGIC_CLIENT_JOBS_DIR"
|
|
153
|
+
const envMaxRunning = "MAGIC_CLIENT_MAX_RUNNING"
|
|
154
|
+
|
|
155
|
+
func newJobQueue(s *Service) *jobQueue {
|
|
156
|
+
dir := strings.TrimSpace(os.Getenv(envJobsDir))
|
|
157
|
+
if dir == "" {
|
|
158
|
+
dir = filepath.Join(s.opts.Profile.DataDir(), "jobs")
|
|
159
|
+
}
|
|
160
|
+
q := &jobQueue{
|
|
161
|
+
svc: s,
|
|
162
|
+
dir: dir,
|
|
163
|
+
jobs: map[string]*Job{},
|
|
164
|
+
byReq: map[string]string{},
|
|
165
|
+
byAsk: map[string]string{},
|
|
166
|
+
runs: map[string]*askRun{},
|
|
167
|
+
active: map[string]string{},
|
|
168
|
+
max: 4,
|
|
169
|
+
wake: make(chan struct{}, 1),
|
|
170
|
+
stop: make(chan struct{}),
|
|
171
|
+
}
|
|
172
|
+
if v := strings.TrimSpace(os.Getenv(envMaxRunning)); v != "" {
|
|
173
|
+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
174
|
+
q.max = n
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
178
|
+
// 落不了盘就退化成纯内存:队列仍然工作,只是重启会丢 —— 日志里说清楚。
|
|
179
|
+
s.log.Printf("作业队列无法创建目录 %s(%v),本轮退化为内存队列", dir, err)
|
|
180
|
+
q.dir = ""
|
|
181
|
+
}
|
|
182
|
+
q.load()
|
|
183
|
+
q.leaseOwned = q.ensureLease()
|
|
184
|
+
go q.loop()
|
|
185
|
+
return q
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ── 持久化 ── */
|
|
189
|
+
|
|
190
|
+
func (q *jobQueue) path(id string) string { return filepath.Join(q.dir, id+".json") }
|
|
191
|
+
|
|
192
|
+
// persistLocked 原子写一条作业(tmp + rename);调用方须持 q.mu(或独占该 Job)。
|
|
193
|
+
func (q *jobQueue) persistLocked(j *Job) {
|
|
194
|
+
if q == nil || q.dir == "" || j == nil {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
b, err := json.MarshalIndent(j, "", " ")
|
|
198
|
+
if err != nil {
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
tmp := q.path(j.ID) + ".tmp"
|
|
202
|
+
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
_ = os.Rename(tmp, q.path(j.ID))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// persist 外部调用入口:自己加锁,marshal 时不会与调度器并发改字段。
|
|
209
|
+
func (q *jobQueue) persist(j *Job) {
|
|
210
|
+
q.mu.Lock()
|
|
211
|
+
defer q.mu.Unlock()
|
|
212
|
+
q.persistLocked(j)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func (q *jobQueue) removeFile(id string) {
|
|
216
|
+
if q == nil || q.dir == "" {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
_ = os.Remove(q.path(id))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// load 启动时重建索引;queued 保留(带 request_id 的),running 残留标失败。
|
|
223
|
+
func (q *jobQueue) load() {
|
|
224
|
+
if q.dir == "" {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
entries, err := os.ReadDir(q.dir)
|
|
228
|
+
if err != nil {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
q.mu.Lock()
|
|
232
|
+
defer q.mu.Unlock()
|
|
233
|
+
now := time.Now()
|
|
234
|
+
for _, e := range entries {
|
|
235
|
+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
b, err := os.ReadFile(filepath.Join(q.dir, e.Name()))
|
|
239
|
+
if err != nil {
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
var j Job
|
|
243
|
+
if err := json.Unmarshal(b, &j); err != nil || strings.TrimSpace(j.ID) == "" {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
switch j.State {
|
|
247
|
+
case JobRunning:
|
|
248
|
+
/* 服务重启时还在 running:core 是 NoCancel 起的,可能仍在跑。
|
|
249
|
+
这里把作业标失败(调度器不再认它),但真正的互斥交给工作空间锁 +
|
|
250
|
+
登记表里的活记录 —— 新任务会被 externalBusy 挡住,直到那轮结束。 */
|
|
251
|
+
j.State = JobFailed
|
|
252
|
+
j.Error = "服务重启,未完成的轮次已中断"
|
|
253
|
+
j.UpdatedAt = now
|
|
254
|
+
q.persistLocked(&j)
|
|
255
|
+
case JobQueued:
|
|
256
|
+
if j.ExpiresAt.IsZero() {
|
|
257
|
+
j.ExpiresAt = j.CreatedAt.Add(jobQueuedTTL)
|
|
258
|
+
}
|
|
259
|
+
if !j.ExpiresAt.After(now) {
|
|
260
|
+
j.State = JobExpired
|
|
261
|
+
j.Error = "排队超时,未执行"
|
|
262
|
+
j.UpdatedAt = now
|
|
263
|
+
q.persistLocked(&j)
|
|
264
|
+
} else if strings.TrimSpace(j.RequestID) == "" {
|
|
265
|
+
// 匿名排队的主人(那条 SSE)已经随进程消失,没人会来接。
|
|
266
|
+
j.State = JobCancelled
|
|
267
|
+
j.Error = "服务重启,匿名排队已撤销"
|
|
268
|
+
j.UpdatedAt = now
|
|
269
|
+
q.persistLocked(&j)
|
|
270
|
+
}
|
|
271
|
+
case JobDone, JobFailed, JobCancelled, JobExpired:
|
|
272
|
+
if !j.UpdatedAt.IsZero() && now.Sub(j.UpdatedAt) > jobKeepTerminal {
|
|
273
|
+
_ = os.Remove(filepath.Join(q.dir, e.Name()))
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
default:
|
|
277
|
+
j.State = JobFailed
|
|
278
|
+
j.Error = "未知状态,已作废"
|
|
279
|
+
j.UpdatedAt = now
|
|
280
|
+
}
|
|
281
|
+
q.jobs[j.ID] = &j
|
|
282
|
+
if j.RequestID != "" {
|
|
283
|
+
q.byReq[j.RequestID] = j.ID
|
|
284
|
+
}
|
|
285
|
+
if j.AskID != "" {
|
|
286
|
+
q.byAsk[j.AskID] = j.ID
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ── 提交 / 查询 / 取消 ── */
|
|
292
|
+
|
|
293
|
+
func normalizeWorkspace(ws string) string {
|
|
294
|
+
ws = strings.TrimSpace(ws)
|
|
295
|
+
if ws == "" {
|
|
296
|
+
ws, _ = os.Getwd()
|
|
297
|
+
}
|
|
298
|
+
if abs, err := filepath.Abs(ws); err == nil {
|
|
299
|
+
ws = abs
|
|
300
|
+
}
|
|
301
|
+
return filepath.Clean(ws)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// submitAsk 入队(或按 request_id 接回原作业)。created=false 表示命中幂等。
|
|
305
|
+
// ⚠️ 返回的是**副本**:调度器随时会把状态改成 running,直接把内部指针交出去
|
|
306
|
+
// 会让 HTTP 响应一边 marshal 一边被改(数据竞争,且客户端可能先看到 running)。
|
|
307
|
+
func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool, error) {
|
|
308
|
+
reqID := strings.TrimSpace(req.RequestID)
|
|
309
|
+
q.mu.Lock()
|
|
310
|
+
if reqID != "" {
|
|
311
|
+
if id, ok := q.byReq[reqID]; ok {
|
|
312
|
+
j := q.jobs[id]
|
|
313
|
+
if j != nil {
|
|
314
|
+
cp := *j
|
|
315
|
+
cp.Position = q.positionLocked(j)
|
|
316
|
+
q.mu.Unlock()
|
|
317
|
+
return &cp, false, nil
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// 每个 workspace 的排队上限:防客户端刷爆内存/磁盘。
|
|
322
|
+
ws := normalizeWorkspace(req.Workspace)
|
|
323
|
+
queued := 0
|
|
324
|
+
for _, j := range q.jobs {
|
|
325
|
+
if j.State == JobQueued && j.Workspace == ws {
|
|
326
|
+
queued++
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if queued >= jobMaxQueued {
|
|
330
|
+
q.mu.Unlock()
|
|
331
|
+
return nil, false, fmt.Errorf("工作空间 %s 的排队任务过多(%d),请稍后再试", ws, queued)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
serial := true
|
|
335
|
+
if req.Serial != nil {
|
|
336
|
+
serial = *req.Serial
|
|
337
|
+
}
|
|
338
|
+
now := time.Now()
|
|
339
|
+
j := &Job{
|
|
340
|
+
ID: q.nextJobIDLocked(),
|
|
341
|
+
RequestID: reqID,
|
|
342
|
+
Workspace: ws,
|
|
343
|
+
ExplicitWorkspace: strings.TrimSpace(req.Workspace) != "",
|
|
344
|
+
Session: strings.TrimSpace(req.Session),
|
|
345
|
+
Engine: engine,
|
|
346
|
+
Model: req.Model,
|
|
347
|
+
Prompt: req.Prompt,
|
|
348
|
+
Permission: req.Permission,
|
|
349
|
+
Reasoning: req.Reasoning,
|
|
350
|
+
Idle: req.Idle,
|
|
351
|
+
NoThinking: req.NoThinking,
|
|
352
|
+
Hosted: req.Hosted,
|
|
353
|
+
Attachments: append([]string(nil), req.Attachments...),
|
|
354
|
+
Team: req.Team,
|
|
355
|
+
Serial: serial,
|
|
356
|
+
State: JobQueued,
|
|
357
|
+
Owner: JobOwner{Client: strings.TrimSpace(req.Client), Source: source},
|
|
358
|
+
CreatedAt: now,
|
|
359
|
+
UpdatedAt: now,
|
|
360
|
+
ExpiresAt: now.Add(jobQueuedTTL),
|
|
361
|
+
}
|
|
362
|
+
q.jobs[j.ID] = j
|
|
363
|
+
if reqID != "" {
|
|
364
|
+
q.byReq[reqID] = j.ID
|
|
365
|
+
}
|
|
366
|
+
q.recomputePositionsLocked()
|
|
367
|
+
cp := *j
|
|
368
|
+
cp.Position = q.positionLocked(j)
|
|
369
|
+
q.persistLocked(j)
|
|
370
|
+
q.mu.Unlock()
|
|
371
|
+
q.signal()
|
|
372
|
+
return &cp, true, nil
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
func (q *jobQueue) nextJobIDLocked() string {
|
|
376
|
+
q.seq++
|
|
377
|
+
return fmt.Sprintf("job-%d-%d", time.Now().UnixNano()/1e6, q.seq)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// get 返回作业副本(调用方不持锁)。
|
|
381
|
+
func (q *jobQueue) get(id string) *Job {
|
|
382
|
+
q.mu.Lock()
|
|
383
|
+
defer q.mu.Unlock()
|
|
384
|
+
j := q.jobs[id]
|
|
385
|
+
if j == nil {
|
|
386
|
+
return nil
|
|
387
|
+
}
|
|
388
|
+
cp := *j
|
|
389
|
+
cp.Position = q.positionLocked(j)
|
|
390
|
+
return &cp
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// list 返回作业副本(按创建时间倒序);workspace 非空时只看该空间。
|
|
394
|
+
func (q *jobQueue) list(workspace string, limit int) []*Job {
|
|
395
|
+
q.mu.Lock()
|
|
396
|
+
defer q.mu.Unlock()
|
|
397
|
+
ws := ""
|
|
398
|
+
if strings.TrimSpace(workspace) != "" {
|
|
399
|
+
ws = normalizeWorkspace(workspace)
|
|
400
|
+
}
|
|
401
|
+
out := make([]*Job, 0, len(q.jobs))
|
|
402
|
+
for _, j := range q.jobs {
|
|
403
|
+
if ws != "" && j.Workspace != ws {
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
cp := *j
|
|
407
|
+
cp.Position = q.positionLocked(j)
|
|
408
|
+
out = append(out, &cp)
|
|
409
|
+
}
|
|
410
|
+
sort.Slice(out, func(i, k int) bool { return out[i].CreatedAt.After(out[k].CreatedAt) })
|
|
411
|
+
if limit > 0 && len(out) > limit {
|
|
412
|
+
out = out[:limit]
|
|
413
|
+
}
|
|
414
|
+
return out
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// positionLocked 该作业在**同 workspace 队列**里排第几(1 起;运行/终态为 0)。
|
|
418
|
+
func (q *jobQueue) positionLocked(j *Job) int {
|
|
419
|
+
if j.State != JobQueued {
|
|
420
|
+
return 0
|
|
421
|
+
}
|
|
422
|
+
pos := 1
|
|
423
|
+
for _, o := range q.jobs {
|
|
424
|
+
if o == j || o.State != JobQueued || o.Workspace != j.Workspace {
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
if o.CreatedAt.Before(j.CreatedAt) ||
|
|
428
|
+
(o.CreatedAt.Equal(j.CreatedAt) && o.ID < j.ID) {
|
|
429
|
+
pos++
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return pos
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func (q *jobQueue) recomputePositionsLocked() {
|
|
436
|
+
for _, j := range q.jobs {
|
|
437
|
+
j.Position = q.positionLocked(j)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// runOf 作业当前挂着的 askRun(排队中为 nil)。
|
|
442
|
+
func (q *jobQueue) runOf(id string) *askRun {
|
|
443
|
+
q.mu.Lock()
|
|
444
|
+
defer q.mu.Unlock()
|
|
445
|
+
return q.runs[id]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
func (q *jobQueue) signal() {
|
|
449
|
+
select {
|
|
450
|
+
case q.wake <- struct{}{}:
|
|
451
|
+
default:
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// close 停止调度循环,并给还在跑的轮次发 stop(测试收尾 / 进程优雅退出用)。
|
|
456
|
+
func (q *jobQueue) close() {
|
|
457
|
+
q.mu.Lock()
|
|
458
|
+
defer q.mu.Unlock()
|
|
459
|
+
if q.closed {
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
q.closed = true
|
|
463
|
+
close(q.stop)
|
|
464
|
+
for _, run := range q.runs {
|
|
465
|
+
if run != nil && run.stdin != nil {
|
|
466
|
+
_, _ = run.stdin.Write([]byte("{\"op\":\"stop\"}\n"))
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
q.releaseLease()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ── 准入等待(/desk/ask 用)── */
|
|
473
|
+
|
|
474
|
+
// waitStarted 阻塞到作业开跑(返回 run),或作业在开跑前就终结 / 客户端断开。
|
|
475
|
+
func (q *jobQueue) waitStarted(ctx contextLike, job *Job, onQueued func(pos int)) (*askRun, bool) {
|
|
476
|
+
tick := time.NewTicker(200 * time.Millisecond)
|
|
477
|
+
defer tick.Stop()
|
|
478
|
+
lastPos := -1
|
|
479
|
+
began := time.Now()
|
|
480
|
+
var lastSent time.Time
|
|
481
|
+
for {
|
|
482
|
+
q.mu.Lock()
|
|
483
|
+
internal := q.jobs[job.ID]
|
|
484
|
+
run := q.runs[job.ID]
|
|
485
|
+
state := ""
|
|
486
|
+
pos := 0
|
|
487
|
+
if internal != nil {
|
|
488
|
+
state = internal.State
|
|
489
|
+
pos = q.positionLocked(internal)
|
|
490
|
+
}
|
|
491
|
+
q.mu.Unlock()
|
|
492
|
+
if run != nil {
|
|
493
|
+
return run, true
|
|
494
|
+
}
|
|
495
|
+
if state != JobQueued && state != JobRunning {
|
|
496
|
+
return nil, false
|
|
497
|
+
}
|
|
498
|
+
/* ⚠️ 空队列直通时**不能**先发 queued:老客户端按「第一帧是 open」解析,
|
|
499
|
+
而且界面会白闪一下「排队中」。给调度器 400ms 的直通窗口,之后仍在队里才算真排队。
|
|
500
|
+
排队超过 10s 也重发一次:既刷新位置,也当 SSE 心跳养住连接。 */
|
|
501
|
+
if onQueued != nil && pos > 0 && (pos != lastPos || time.Since(lastSent) >= 10*time.Second) &&
|
|
502
|
+
time.Since(began) >= 400*time.Millisecond {
|
|
503
|
+
lastPos = pos
|
|
504
|
+
lastSent = time.Now()
|
|
505
|
+
onQueued(pos)
|
|
506
|
+
}
|
|
507
|
+
select {
|
|
508
|
+
case <-ctx.Done():
|
|
509
|
+
return nil, false
|
|
510
|
+
case <-tick.C:
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// contextLike 只用到 Done(),避免把 context 的类型细节带进队列签名。
|
|
516
|
+
type contextLike interface {
|
|
517
|
+
Done() <-chan struct{}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ── 调度 ── */
|
|
521
|
+
|
|
522
|
+
func (q *jobQueue) loop() {
|
|
523
|
+
tick := time.NewTicker(time.Second)
|
|
524
|
+
defer tick.Stop()
|
|
525
|
+
for {
|
|
526
|
+
select {
|
|
527
|
+
case <-q.stop:
|
|
528
|
+
return
|
|
529
|
+
case <-q.wake:
|
|
530
|
+
case <-tick.C:
|
|
531
|
+
}
|
|
532
|
+
q.leaseOwned = q.ensureLease()
|
|
533
|
+
q.reloadNew()
|
|
534
|
+
q.dispatch()
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/*
|
|
539
|
+
── 多实例租约 ──
|
|
540
|
+
|
|
541
|
+
同一个 jobs 目录(同一 AppID 的 DataDir)只允许一个进程真正调度:否则两个
|
|
542
|
+
magic-client 会各自拉起 core,靠 core 侧的工作空间锁才不至于并跑 —— 但那样
|
|
543
|
+
第二个进程会卡在 Begin 里等,状态显示也不对。
|
|
544
|
+
非主进程仍然接受提交(写同一个目录),主进程每秒重读目录把新作业收进来;
|
|
545
|
+
跨进程取消暂不支持(只能停已开跑的 session)—— 这是单实例默认部署之外的兜底。
|
|
546
|
+
*/
|
|
547
|
+
func (q *jobQueue) leasePath() string { return filepath.Join(q.dir, "scheduler.lock") }
|
|
548
|
+
|
|
549
|
+
func (q *jobQueue) ensureLease() bool {
|
|
550
|
+
if q.dir == "" {
|
|
551
|
+
return true
|
|
552
|
+
}
|
|
553
|
+
path := q.leasePath()
|
|
554
|
+
now := time.Now()
|
|
555
|
+
if b, err := os.ReadFile(path); err == nil {
|
|
556
|
+
pid := 0
|
|
557
|
+
if lines := strings.Split(strings.TrimSpace(string(b)), "\n"); len(lines) > 0 {
|
|
558
|
+
pid, _ = strconv.Atoi(strings.TrimSpace(lines[0]))
|
|
559
|
+
}
|
|
560
|
+
if pid == os.Getpid() {
|
|
561
|
+
_ = os.Chtimes(path, now, now)
|
|
562
|
+
return true
|
|
563
|
+
}
|
|
564
|
+
alive := pid > 0 && agent.PIDAlive(pid)
|
|
565
|
+
fresh := false
|
|
566
|
+
if st, serr := os.Stat(path); serr == nil {
|
|
567
|
+
fresh = now.Sub(st.ModTime()) < 20*time.Second
|
|
568
|
+
}
|
|
569
|
+
if alive && fresh {
|
|
570
|
+
return false
|
|
571
|
+
}
|
|
572
|
+
_ = os.Remove(path) // 主进程已不在或心跳过期:接管
|
|
573
|
+
}
|
|
574
|
+
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
575
|
+
if err != nil {
|
|
576
|
+
return false
|
|
577
|
+
}
|
|
578
|
+
_, _ = fmt.Fprintf(f, "%d\n%d\n", os.Getpid(), now.UnixNano())
|
|
579
|
+
_ = f.Close()
|
|
580
|
+
return true
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
func (q *jobQueue) releaseLease() {
|
|
584
|
+
if q.dir == "" {
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
b, err := os.ReadFile(q.leasePath())
|
|
588
|
+
if err != nil {
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
pid, _ := strconv.Atoi(strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0]))
|
|
592
|
+
if pid == os.Getpid() {
|
|
593
|
+
_ = os.Remove(q.leasePath())
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// reloadNew 把别的实例写进同一目录的作业收进来(主进程负责调度它们)。
|
|
598
|
+
func (q *jobQueue) reloadNew() {
|
|
599
|
+
if q.dir == "" {
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
entries, err := os.ReadDir(q.dir)
|
|
603
|
+
if err != nil {
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
q.mu.Lock()
|
|
607
|
+
defer q.mu.Unlock()
|
|
608
|
+
for _, e := range entries {
|
|
609
|
+
name := e.Name()
|
|
610
|
+
if e.IsDir() || !strings.HasSuffix(name, ".json") {
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
b, rerr := os.ReadFile(filepath.Join(q.dir, name))
|
|
614
|
+
if rerr != nil {
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
617
|
+
var j Job
|
|
618
|
+
if json.Unmarshal(b, &j) != nil || j.ID == "" {
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
cur := q.jobs[j.ID]
|
|
622
|
+
if cur != nil {
|
|
623
|
+
// 本地正在跑的不覆盖;其余以磁盘更新时间为准(别的实例可能推进过状态)。
|
|
624
|
+
if q.runs[j.ID] == nil && j.UpdatedAt.After(cur.UpdatedAt) {
|
|
625
|
+
*cur = j
|
|
626
|
+
}
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
q.jobs[j.ID] = &j
|
|
630
|
+
if j.RequestID != "" {
|
|
631
|
+
q.byReq[j.RequestID] = j.ID
|
|
632
|
+
}
|
|
633
|
+
if j.AskID != "" {
|
|
634
|
+
q.byAsk[j.AskID] = j.ID
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
q.recomputePositionsLocked()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
func (q *jobQueue) dispatch() {
|
|
641
|
+
if !q.leaseOwned {
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
q.mu.Lock()
|
|
645
|
+
if q.closed {
|
|
646
|
+
q.mu.Unlock()
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
// 过期清理 + 终态文件清理(幂等窗口 = jobKeepTerminal)
|
|
650
|
+
now := time.Now()
|
|
651
|
+
for id, j := range q.jobs {
|
|
652
|
+
if j.State == JobQueued && !j.ExpiresAt.IsZero() && !j.ExpiresAt.After(now) {
|
|
653
|
+
j.State = JobExpired
|
|
654
|
+
j.Error = "排队超时,未执行"
|
|
655
|
+
j.UpdatedAt = now
|
|
656
|
+
q.persistLocked(j)
|
|
657
|
+
}
|
|
658
|
+
if j.terminal() && !j.UpdatedAt.IsZero() && now.Sub(j.UpdatedAt) > jobKeepTerminal {
|
|
659
|
+
if j.RequestID != "" {
|
|
660
|
+
delete(q.byReq, j.RequestID)
|
|
661
|
+
}
|
|
662
|
+
delete(q.byAsk, j.AskID)
|
|
663
|
+
delete(q.jobs, id)
|
|
664
|
+
q.removeFile(j.ID)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// 候选:queued,按创建时间 FIFO;跳过已被占用的 workspace(串行模式)。
|
|
668
|
+
var cands []*Job
|
|
669
|
+
for _, j := range q.jobs {
|
|
670
|
+
if j.State != JobQueued {
|
|
671
|
+
continue
|
|
672
|
+
}
|
|
673
|
+
if j.Serial && q.active[j.Workspace] != "" {
|
|
674
|
+
continue
|
|
675
|
+
}
|
|
676
|
+
cands = append(cands, j)
|
|
677
|
+
}
|
|
678
|
+
sort.Slice(cands, func(i, k int) bool {
|
|
679
|
+
if cands[i].CreatedAt.Equal(cands[k].CreatedAt) {
|
|
680
|
+
return cands[i].ID < cands[k].ID
|
|
681
|
+
}
|
|
682
|
+
return cands[i].CreatedAt.Before(cands[k].CreatedAt)
|
|
683
|
+
})
|
|
684
|
+
var admitted []*Job
|
|
685
|
+
for _, j := range cands {
|
|
686
|
+
if q.running >= q.max {
|
|
687
|
+
break
|
|
688
|
+
}
|
|
689
|
+
if j.Serial {
|
|
690
|
+
if q.active[j.Workspace] != "" {
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
q.active[j.Workspace] = j.ID
|
|
694
|
+
} else if q.active[j.Workspace] != "" {
|
|
695
|
+
// 并行作业不占槽,但不该和同空间的串行作业抢跑。
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
698
|
+
j.State = JobRunning
|
|
699
|
+
j.StartedAt = now
|
|
700
|
+
j.UpdatedAt = now
|
|
701
|
+
q.running++
|
|
702
|
+
admitted = append(admitted, j)
|
|
703
|
+
}
|
|
704
|
+
q.recomputePositionsLocked()
|
|
705
|
+
q.mu.Unlock()
|
|
706
|
+
|
|
707
|
+
for _, j := range admitted {
|
|
708
|
+
/* 外部占用(CLI / 上个进程留下的活会话):串行作业先让路。
|
|
709
|
+
这里查登记表而不是只信自己:锁在 core,别的进程也可能正在跑。 */
|
|
710
|
+
if j.Serial && q.externalBusy(j.Workspace, j.ID) {
|
|
711
|
+
q.requeue(j.ID, "等待同工作空间的另一个进程结束")
|
|
712
|
+
continue
|
|
713
|
+
}
|
|
714
|
+
go q.launch(j)
|
|
715
|
+
}
|
|
716
|
+
if len(admitted) > 0 {
|
|
717
|
+
q.signal()
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
func (q *jobQueue) requeue(id, why string) {
|
|
722
|
+
q.mu.Lock()
|
|
723
|
+
defer q.mu.Unlock()
|
|
724
|
+
j := q.jobs[id]
|
|
725
|
+
if j == nil || j.State != JobRunning {
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
if j.Serial && q.active[j.Workspace] == id {
|
|
729
|
+
delete(q.active, j.Workspace)
|
|
730
|
+
}
|
|
731
|
+
j.State = JobQueued
|
|
732
|
+
j.StartedAt = time.Time{}
|
|
733
|
+
j.UpdatedAt = time.Now()
|
|
734
|
+
if j.ExpiresAt.IsZero() || j.ExpiresAt.Before(time.Now()) {
|
|
735
|
+
j.ExpiresAt = time.Now().Add(jobQueuedTTL)
|
|
736
|
+
}
|
|
737
|
+
q.running--
|
|
738
|
+
if q.running < 0 {
|
|
739
|
+
q.running = 0
|
|
740
|
+
}
|
|
741
|
+
q.recomputePositionsLocked()
|
|
742
|
+
q.persistLocked(j)
|
|
743
|
+
q.signal()
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// externalBusy 登记表里同 workspace 是否还有别的活进程。
|
|
747
|
+
func (q *jobQueue) externalBusy(ws string, selfJob string) bool {
|
|
748
|
+
recs, err := session.List()
|
|
749
|
+
if err != nil {
|
|
750
|
+
return false
|
|
751
|
+
}
|
|
752
|
+
q.mu.Lock()
|
|
753
|
+
selfPID := 0
|
|
754
|
+
if j := q.jobs[selfJob]; j != nil {
|
|
755
|
+
selfPID = j.PID
|
|
756
|
+
}
|
|
757
|
+
q.mu.Unlock()
|
|
758
|
+
target := normalizeWorkspace(ws)
|
|
759
|
+
for _, r := range recs {
|
|
760
|
+
if r.PID <= 0 || r.PID == selfPID {
|
|
761
|
+
continue
|
|
762
|
+
}
|
|
763
|
+
if r.State != session.StateRunning && r.State != session.StateWaitingApproval && r.State != session.StateIdle {
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
w := strings.TrimSpace(r.Workspace)
|
|
767
|
+
if w == "" {
|
|
768
|
+
w = strings.TrimSpace(r.Cwd)
|
|
769
|
+
}
|
|
770
|
+
if w == "" {
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
if normalizeWorkspace(w) == target {
|
|
774
|
+
return true
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return false
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// launch 准备参数并拉起 core;收尾由 pumpAsk → runEnded 负责。
|
|
781
|
+
func (q *jobQueue) launch(j *Job) {
|
|
782
|
+
/* 排队中被取消的那一瞬间可能已经进了 launch:下手前再确认一次,别把已取消的任务跑起来。 */
|
|
783
|
+
q.mu.Lock()
|
|
784
|
+
if q.jobs[j.ID] == nil || j.State != JobRunning {
|
|
785
|
+
q.mu.Unlock()
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
q.mu.Unlock()
|
|
789
|
+
req := j.askRequest()
|
|
790
|
+
engine, args, st, err := q.svc.prepareAsk(req)
|
|
791
|
+
if err != nil {
|
|
792
|
+
q.fail(j.ID, httpStatusPrefix(st)+err.Error())
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
run, err := q.svc.startAsk(req, engine, args, j.Owner.Source)
|
|
796
|
+
if err != nil {
|
|
797
|
+
q.fail(j.ID, err.Error())
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
q.mu.Lock()
|
|
801
|
+
j.AskID = run.id
|
|
802
|
+
j.PID = 0
|
|
803
|
+
if run.cmd.Process != nil {
|
|
804
|
+
j.PID = run.cmd.Process.Pid
|
|
805
|
+
}
|
|
806
|
+
j.UpdatedAt = time.Now()
|
|
807
|
+
q.runs[j.ID] = run
|
|
808
|
+
q.byAsk[run.id] = j.ID
|
|
809
|
+
q.persistLocked(j)
|
|
810
|
+
q.mu.Unlock()
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
func httpStatusPrefix(code int) string {
|
|
814
|
+
if code == 0 || code == http.StatusOK {
|
|
815
|
+
return ""
|
|
816
|
+
}
|
|
817
|
+
return fmt.Sprintf("(%d) ", code)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
func (q *jobQueue) fail(id, reason string) {
|
|
821
|
+
q.mu.Lock()
|
|
822
|
+
defer q.mu.Unlock()
|
|
823
|
+
j := q.jobs[id]
|
|
824
|
+
if j == nil {
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
wasRunning := j.State == JobRunning
|
|
828
|
+
j.State = JobFailed
|
|
829
|
+
j.Error = strings.TrimSpace(reason)
|
|
830
|
+
j.UpdatedAt = time.Now()
|
|
831
|
+
if j.Serial && q.active[j.Workspace] == j.ID {
|
|
832
|
+
delete(q.active, j.Workspace)
|
|
833
|
+
}
|
|
834
|
+
if wasRunning && q.running > 0 {
|
|
835
|
+
q.running--
|
|
836
|
+
}
|
|
837
|
+
delete(q.runs, j.ID)
|
|
838
|
+
q.recomputePositionsLocked()
|
|
839
|
+
q.persistLocked(j)
|
|
840
|
+
q.signal()
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// runEnded pumpAsk 在 core 退出后调用:按退出码落终态、释放槽位。
|
|
844
|
+
func (q *jobQueue) runEnded(run *askRun) {
|
|
845
|
+
if q == nil || run == nil {
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
q.mu.Lock()
|
|
849
|
+
id := q.byAsk[run.id]
|
|
850
|
+
j := q.jobs[id]
|
|
851
|
+
if j == nil {
|
|
852
|
+
q.mu.Unlock()
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
code := 0
|
|
856
|
+
run.mu.Lock()
|
|
857
|
+
code = run.code
|
|
858
|
+
sid := strings.TrimSpace(run.session)
|
|
859
|
+
rid := strings.TrimSpace(run.run)
|
|
860
|
+
run.mu.Unlock()
|
|
861
|
+
if code == 0 {
|
|
862
|
+
j.State = JobDone
|
|
863
|
+
j.Error = ""
|
|
864
|
+
} else {
|
|
865
|
+
j.State = JobFailed
|
|
866
|
+
j.Error = strings.TrimSpace(run.stderr.String())
|
|
867
|
+
if j.Error == "" {
|
|
868
|
+
j.Error = fmt.Sprintf("core 退出码 %d", code)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if sid != "" {
|
|
872
|
+
j.SessionID = sid
|
|
873
|
+
}
|
|
874
|
+
if rid != "" {
|
|
875
|
+
j.RunID = rid
|
|
876
|
+
}
|
|
877
|
+
j.UpdatedAt = time.Now()
|
|
878
|
+
// 先释放(running-- / 清 active),再算位置;否则自己会被当成「还在跑」。
|
|
879
|
+
if j.Serial && q.active[j.Workspace] == j.ID {
|
|
880
|
+
delete(q.active, j.Workspace)
|
|
881
|
+
}
|
|
882
|
+
if q.running > 0 {
|
|
883
|
+
q.running--
|
|
884
|
+
}
|
|
885
|
+
/* ⚠️ **不要**立刻从 runs 里摘除:/desk/ask 的 waitStarted 可能还在等这一条
|
|
886
|
+
(几十毫秒就收工的轮次尤其明显)。保留 askRetention,与 s.runs 同窗口。 */
|
|
887
|
+
q.recomputePositionsLocked()
|
|
888
|
+
q.persistLocked(j)
|
|
889
|
+
q.mu.Unlock()
|
|
890
|
+
time.AfterFunc(askRetention, func() {
|
|
891
|
+
q.mu.Lock()
|
|
892
|
+
delete(q.runs, j.ID)
|
|
893
|
+
q.mu.Unlock()
|
|
894
|
+
})
|
|
895
|
+
q.signal()
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// cancel 取消作业:排队中直接出队;运行中把 stop 写进 core 的 stdin。
|
|
899
|
+
func (q *jobQueue) cancel(id, reason string) bool {
|
|
900
|
+
q.mu.Lock()
|
|
901
|
+
j := q.jobs[id]
|
|
902
|
+
if j == nil {
|
|
903
|
+
q.mu.Unlock()
|
|
904
|
+
return false
|
|
905
|
+
}
|
|
906
|
+
run := q.runs[id]
|
|
907
|
+
if run == nil {
|
|
908
|
+
/* 还没真正拉起 core(queued / 刚被调度器标成 running 的那一瞬):
|
|
909
|
+
直接出队,launch 在下手前会再确认状态,不会偷偷跑起来。 */
|
|
910
|
+
wasRunning := j.State == JobRunning
|
|
911
|
+
j.State = JobCancelled
|
|
912
|
+
j.Error = strings.TrimSpace(reason)
|
|
913
|
+
j.UpdatedAt = time.Now()
|
|
914
|
+
if j.Serial && q.active[j.Workspace] == j.ID {
|
|
915
|
+
delete(q.active, j.Workspace)
|
|
916
|
+
}
|
|
917
|
+
if wasRunning && q.running > 0 {
|
|
918
|
+
q.running--
|
|
919
|
+
}
|
|
920
|
+
q.recomputePositionsLocked()
|
|
921
|
+
q.persistLocked(j)
|
|
922
|
+
q.mu.Unlock()
|
|
923
|
+
q.signal()
|
|
924
|
+
return true
|
|
925
|
+
}
|
|
926
|
+
q.mu.Unlock()
|
|
927
|
+
if run != nil && run.stdin != nil {
|
|
928
|
+
_, err := run.stdin.Write([]byte("{\"op\":\"stop\"}\n"))
|
|
929
|
+
return err == nil
|
|
930
|
+
}
|
|
931
|
+
return false
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// cancelByRequest 按客户端幂等键取消(客户端还没拿到 job id 时用)。
|
|
935
|
+
func (q *jobQueue) cancelByRequest(requestID, reason string) bool {
|
|
936
|
+
requestID = strings.TrimSpace(requestID)
|
|
937
|
+
if requestID == "" {
|
|
938
|
+
return false
|
|
939
|
+
}
|
|
940
|
+
q.mu.Lock()
|
|
941
|
+
id := q.byReq[requestID]
|
|
942
|
+
q.mu.Unlock()
|
|
943
|
+
if id == "" {
|
|
944
|
+
return false
|
|
945
|
+
}
|
|
946
|
+
return q.cancel(id, reason)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* ── HTTP ── */
|
|
950
|
+
|
|
951
|
+
// handleJobs /desk/jobs:GET 列表 / POST 提交。
|
|
952
|
+
func (s *Service) handleJobs(w http.ResponseWriter, r *http.Request) {
|
|
953
|
+
if s.jobs == nil {
|
|
954
|
+
writeErr(w, http.StatusServiceUnavailable, "作业队列未初始化")
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
switch r.Method {
|
|
958
|
+
case http.MethodGet:
|
|
959
|
+
limit := 200
|
|
960
|
+
if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" {
|
|
961
|
+
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
|
962
|
+
limit = n
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
jobs := s.jobs.list(r.URL.Query().Get("workspace"), limit)
|
|
966
|
+
s.jobs.mu.Lock()
|
|
967
|
+
running, max := s.jobs.running, s.jobs.max
|
|
968
|
+
s.jobs.mu.Unlock()
|
|
969
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
970
|
+
"jobs": jobs,
|
|
971
|
+
"running": running,
|
|
972
|
+
"max": max,
|
|
973
|
+
})
|
|
974
|
+
case http.MethodPost:
|
|
975
|
+
var req askRequest
|
|
976
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
|
977
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
engine, _, st, err := s.prepareAsk(req)
|
|
981
|
+
if err != nil {
|
|
982
|
+
writeErr(w, st, err.Error())
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
job, created, err := s.jobs.submitAsk(req, engine, requestSource(req.Client, r.Header.Get("Origin")))
|
|
986
|
+
if err != nil {
|
|
987
|
+
writeErr(w, http.StatusTooManyRequests, err.Error())
|
|
988
|
+
return
|
|
989
|
+
}
|
|
990
|
+
code := http.StatusOK
|
|
991
|
+
if created {
|
|
992
|
+
code = http.StatusAccepted
|
|
993
|
+
}
|
|
994
|
+
writeJSON(w, code, map[string]any{"job": job, "created": created})
|
|
995
|
+
default:
|
|
996
|
+
writeErr(w, http.StatusMethodNotAllowed, "GET / POST only")
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// handleJobsSub /desk/jobs/{id}、/{id}/events、/{id}/cancel、/events(全局流)。
|
|
1001
|
+
func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
|
|
1002
|
+
if s.jobs == nil {
|
|
1003
|
+
writeErr(w, http.StatusServiceUnavailable, "作业队列未初始化")
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/desk/jobs/"), "/")
|
|
1007
|
+
if rest == "events" {
|
|
1008
|
+
s.handleJobsStream(w, r)
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
/* POST /desk/jobs/cancel {request_id}:客户端只知道自己的幂等键、还没拿到 job id 时用
|
|
1012
|
+
(例如排队中点「停止」)。 */
|
|
1013
|
+
if rest == "cancel" {
|
|
1014
|
+
if r.Method != http.MethodPost {
|
|
1015
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
1016
|
+
return
|
|
1017
|
+
}
|
|
1018
|
+
var body struct {
|
|
1019
|
+
RequestID string `json:"request_id"`
|
|
1020
|
+
}
|
|
1021
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
|
1022
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
ok := s.jobs.cancelByRequest(body.RequestID, "客户端取消")
|
|
1026
|
+
if !ok {
|
|
1027
|
+
writeErr(w, http.StatusNotFound, "没有这个 request_id 对应的作业:"+body.RequestID)
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
parts := strings.Split(rest, "/")
|
|
1034
|
+
id := parts[0]
|
|
1035
|
+
if id == "" {
|
|
1036
|
+
writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel")
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
action := ""
|
|
1040
|
+
if len(parts) > 1 {
|
|
1041
|
+
action = parts[1]
|
|
1042
|
+
}
|
|
1043
|
+
switch action {
|
|
1044
|
+
case "":
|
|
1045
|
+
if r.Method != http.MethodGet {
|
|
1046
|
+
writeErr(w, http.StatusMethodNotAllowed, "GET only")
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
j := s.jobs.get(id)
|
|
1050
|
+
if j == nil {
|
|
1051
|
+
writeErr(w, http.StatusNotFound, "没有这个作业:"+id)
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
writeJSON(w, http.StatusOK, j)
|
|
1055
|
+
case "events":
|
|
1056
|
+
s.handleJobEvents(w, r, id)
|
|
1057
|
+
case "cancel":
|
|
1058
|
+
if r.Method != http.MethodPost {
|
|
1059
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
if s.jobs.get(id) == nil {
|
|
1063
|
+
writeErr(w, http.StatusNotFound, "没有这个作业:"+id)
|
|
1064
|
+
return
|
|
1065
|
+
}
|
|
1066
|
+
ok := s.jobs.cancel(id, "客户端取消")
|
|
1067
|
+
writeJSON(w, http.StatusOK, map[string]any{"cancelled": ok, "job": s.jobs.get(id)})
|
|
1068
|
+
default:
|
|
1069
|
+
writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel")
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// handleJobEvents 作业事件:排队中返回空列表 + position,开跑后按 seq 增量回放。
|
|
1074
|
+
func (s *Service) handleJobEvents(w http.ResponseWriter, r *http.Request, id string) {
|
|
1075
|
+
if r.Method != http.MethodGet {
|
|
1076
|
+
writeErr(w, http.StatusMethodNotAllowed, "GET only")
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
var after uint64
|
|
1080
|
+
if v := strings.TrimSpace(r.URL.Query().Get("after")); v != "" {
|
|
1081
|
+
n, err := strconv.ParseUint(v, 10, 64)
|
|
1082
|
+
if err != nil {
|
|
1083
|
+
writeErr(w, http.StatusBadRequest, "after 必须是十进制序号:"+v)
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
after = n
|
|
1087
|
+
}
|
|
1088
|
+
j := s.jobs.get(id)
|
|
1089
|
+
if j == nil {
|
|
1090
|
+
writeErr(w, http.StatusNotFound, "没有这个作业:"+id)
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
run := s.jobs.runOf(id)
|
|
1094
|
+
if run == nil {
|
|
1095
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
1096
|
+
"job": j,
|
|
1097
|
+
"id": j.AskID,
|
|
1098
|
+
"events": []any{},
|
|
1099
|
+
"count": 0,
|
|
1100
|
+
"after": after,
|
|
1101
|
+
"lastSeq": 0,
|
|
1102
|
+
"done": j.terminal(),
|
|
1103
|
+
"snapshotRequired": false,
|
|
1104
|
+
})
|
|
1105
|
+
return
|
|
1106
|
+
}
|
|
1107
|
+
events, lastSeq, done, code, stderr := run.snapshot(after)
|
|
1108
|
+
raw := make([]json.RawMessage, 0, len(events))
|
|
1109
|
+
for _, e := range events {
|
|
1110
|
+
raw = append(raw, json.RawMessage(append([]byte(nil), e.data...)))
|
|
1111
|
+
}
|
|
1112
|
+
resp := map[string]any{
|
|
1113
|
+
"job": j,
|
|
1114
|
+
"id": run.id,
|
|
1115
|
+
"events": raw,
|
|
1116
|
+
"count": len(raw),
|
|
1117
|
+
"after": after,
|
|
1118
|
+
"lastSeq": lastSeq,
|
|
1119
|
+
"done": done,
|
|
1120
|
+
"snapshotRequired": false,
|
|
1121
|
+
}
|
|
1122
|
+
if done {
|
|
1123
|
+
resp["code"] = code
|
|
1124
|
+
resp["stderr"] = stderr
|
|
1125
|
+
}
|
|
1126
|
+
writeJSON(w, http.StatusOK, resp)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// handleJobsStream 全局作业列表(SSE 快照流):多客户端据此实时刷新队列视图。
|
|
1130
|
+
func (s *Service) handleJobsStream(w http.ResponseWriter, r *http.Request) {
|
|
1131
|
+
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
1132
|
+
w.Header().Set("Cache-Control", "no-cache")
|
|
1133
|
+
w.Header().Set("X-Accel-Buffering", "no")
|
|
1134
|
+
flusher, _ := w.(http.Flusher)
|
|
1135
|
+
ws := r.URL.Query().Get("workspace")
|
|
1136
|
+
send := func() {
|
|
1137
|
+
jobs := s.jobs.list(ws, 300)
|
|
1138
|
+
s.jobs.mu.Lock()
|
|
1139
|
+
running, max := s.jobs.running, s.jobs.max
|
|
1140
|
+
s.jobs.mu.Unlock()
|
|
1141
|
+
b, _ := json.Marshal(map[string]any{"jobs": jobs, "running": running, "max": max, "at": time.Now().UTC()})
|
|
1142
|
+
fmt.Fprintf(w, "event: jobs\ndata: %s\n\n", b)
|
|
1143
|
+
if flusher != nil {
|
|
1144
|
+
flusher.Flush()
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
send()
|
|
1148
|
+
tick := time.NewTicker(2 * time.Second)
|
|
1149
|
+
defer tick.Stop()
|
|
1150
|
+
for {
|
|
1151
|
+
select {
|
|
1152
|
+
case <-r.Context().Done():
|
|
1153
|
+
return
|
|
1154
|
+
case <-tick.C:
|
|
1155
|
+
send()
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// ioLimit 已内联为 io.LimitReader(保持与 /desk/ask 同一上限)。
|