magic-agent 2.3.2 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CLIENT-CONTRACT.md +30 -4
  2. package/README.md +1 -1
  3. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  4. package/client-ui/shared/conversation.js +39 -0
  5. package/client-ui/shared/transport-http.js +10 -0
  6. package/client-ui-mobile/index.html +431 -46
  7. package/cmd/magic-client/main.go +5 -0
  8. package/internal/agent/engine.go +2 -2
  9. package/internal/agent/llmengine.go +0 -8
  10. package/internal/agent/llmengine_test.go +0 -17
  11. package/internal/agent/llmmodels.go +0 -35
  12. package/internal/client/cors_test.go +32 -0
  13. package/internal/client/instance.go +59 -0
  14. package/internal/client/jobs.go +1160 -0
  15. package/internal/client/jobs_test.go +369 -0
  16. package/internal/client/service.go +251 -141
  17. package/internal/client/service_test.go +46 -1
  18. package/internal/session/session.go +15 -1
  19. package/internal/session/workspace_lock.go +165 -0
  20. package/internal/session/workspace_lock_test.go +90 -0
  21. package/internal/suggest/suggest.go +8 -25
  22. package/internal/suggest/suggest_test.go +0 -6
  23. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  24. package/npm/apk/version.json +3 -3
  25. package/npm/dist/darwin-arm64/magic-agent +0 -0
  26. package/npm/dist/darwin-arm64/magic-client +0 -0
  27. package/npm/dist/darwin-x64/magic-agent +0 -0
  28. package/npm/dist/darwin-x64/magic-client +0 -0
  29. package/npm/dist/linux-arm64/magic-agent +0 -0
  30. package/npm/dist/linux-arm64/magic-client +0 -0
  31. package/npm/dist/linux-x64/magic-agent +0 -0
  32. package/npm/dist/linux-x64/magic-client +0 -0
  33. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  34. package/npm/dist/win32-x64/magic-client.exe +0 -0
  35. package/package.json +1 -1
@@ -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 同一上限)。