magic-agent 2.4.3 → 2.6.0

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -67,17 +67,22 @@ type Job struct {
67
67
  // ExplicitWorkspace 原请求是否显式带了 -w:恢复时原样下发,不能替用户改写默认语义。
68
68
  ExplicitWorkspace bool `json:"explicit_workspace,omitempty"`
69
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"`
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
+ // FallbackEngine / FallbackModel 主引擎的备用(2026-10-24,只增字段)。
80
+ // 作业会落盘(排队 / 重启续跑),所以必须一起存 —— 只在请求期存在的话,
81
+ // 排队到真正启动时备用就丢了(实测:/desk/ask 直接提交能降级,走作业队列不能)。
82
+ FallbackEngine string `json:"fallback_engine,omitempty"`
83
+ FallbackModel string `json:"fallback_model,omitempty"`
84
+ Attachments []string `json:"attachments,omitempty"`
85
+ Team *teamRequest `json:"team,omitempty"`
81
86
 
82
87
  // Serial 是否参与 workspace 单槽串行(默认 true)。
83
88
  Serial bool `json:"serial"`
@@ -91,6 +96,15 @@ type Job struct {
91
96
  SessionID string `json:"session_id,omitempty"`
92
97
  PID int `json:"pid,omitempty"`
93
98
  Error string `json:"error,omitempty"`
99
+ // FallbackUsed / FallbackUsedEngine / FallbackUsedModel 本轮由备用答的案
100
+ // (2026-10-26,只增字段;「配了什么」是上面的 Fallback*,「用没用上」是这里)。
101
+ //
102
+ // 作业记录是排查主备的**唯一现场**:以前只有「配了什么」,没有「用没用上」,
103
+ // 用户报「主备没生效」时无从判断(2026-10-26 现场就是如此)。
104
+ // 由服务端扫事件流里 type=fallback 的帧回填;没降级时不出现。
105
+ FallbackUsed bool `json:"fallback_used,omitempty"`
106
+ FallbackUsedEngine string `json:"fallback_engine_used,omitempty"`
107
+ FallbackUsedModel string `json:"fallback_model_used,omitempty"`
94
108
 
95
109
  CreatedAt time.Time `json:"created_at"`
96
110
  StartedAt time.Time `json:"started_at,omitempty"`
@@ -121,7 +135,15 @@ func (j *Job) askRequest() askRequest {
121
135
  Attachments: append([]string(nil), j.Attachments...),
122
136
  Team: j.Team,
123
137
  Client: j.Owner.Source,
124
- }
138
+ // 备用引擎跟着作业一起还原(否则排队到开跑时降级配置就丢了)。
139
+ FallbackEngine: j.FallbackEngine,
140
+ FallbackModel: j.FallbackModel,
141
+ }
142
+ /* ⚠️ 必须回填 Serial:startAsk 按它决定要不要注入 core 侧工作空间锁
143
+ (见 service.go 的 EnvWorkspaceLock)。漏掉 → req.Serial == nil → 按「串行」处理 →
144
+ 并行作业在 core 的 Begin 处又去抢锁,把「并行」架空(切并行后看着没生效)。 */
145
+ serial := j.Serial
146
+ req.Serial = &serial
125
147
  if j.ExplicitWorkspace {
126
148
  req.Workspace = j.Workspace
127
149
  }
@@ -152,6 +174,12 @@ type jobQueue struct {
152
174
  const envJobsDir = "MAGIC_CLIENT_JOBS_DIR"
153
175
  const envMaxRunning = "MAGIC_CLIENT_MAX_RUNNING"
154
176
 
177
+ // parallelHeadroom 全局并发满员(max)后,并行作业仍可借用的放行余量。
178
+ // 并行作业是交互式轻活(客户端并行模式下的聊天),不该被一批长任务压死 ——
179
+ // 2026-10-24 事故:4 个长任务占满 max=4,手机发条「hi」在队列里干等 6 分钟。
180
+ // 只给余量而非不设限:防 UI 故障连环提交把引擎进程数打爆。
181
+ const parallelHeadroom = 4
182
+
155
183
  func newJobQueue(s *Service) *jobQueue {
156
184
  dir := strings.TrimSpace(os.Getenv(envJobsDir))
157
185
  if dir == "" {
@@ -191,7 +219,14 @@ func (q *jobQueue) path(id string) string { return filepath.Join(q.dir, id+".jso
191
219
 
192
220
  // persistLocked 原子写一条作业(tmp + rename);调用方须持 q.mu(或独占该 Job)。
193
221
  func (q *jobQueue) persistLocked(j *Job) {
194
- if q == nil || q.dir == "" || j == nil {
222
+ if q == nil || j == nil {
223
+ return
224
+ }
225
+ /* 会话登记表跟着作业状态走(2026-10-24 用户:「没跑也先登记啊」):
226
+ queued 的作业**受理即登记**,列表页不用先看到「真开跑」才有这条;
227
+ 开跑 / 终结时抹掉占位,交给 core 自己那条 running 记录。 */
228
+ q.syncQueuedSessionLocked(j)
229
+ if q.dir == "" {
195
230
  return
196
231
  }
197
232
  b, err := json.MarshalIndent(j, "", " ")
@@ -212,6 +247,79 @@ func (q *jobQueue) persist(j *Job) {
212
247
  q.persistLocked(j)
213
248
  }
214
249
 
250
+ // queuedSessionID 排队占位的登记 id:客户端的幂等键优先(客户端据此把本地那条
251
+ // 「等待中」与登记行认成同一条),没有才用 job id。
252
+ // core 自己的 run_id 是 run-… 前缀,正常不会与它们相撞。
253
+ func queuedSessionID(j *Job) string {
254
+ if j == nil {
255
+ return ""
256
+ }
257
+ if id := strings.TrimSpace(j.RequestID); id != "" && safeFileNameID(id) {
258
+ return id
259
+ }
260
+ return j.ID
261
+ }
262
+
263
+ // safeFileNameID 客户端给的 request_id 会变成登记文件名(<run_id>.json):
264
+ // 只接受字母 / 数字 / - _ .(且不能是 . / ..),其余一律退回 job id ——
265
+ // 不让一个自定义 request_id 把文件写到登记目录外面去。
266
+ func safeFileNameID(id string) bool {
267
+ if id == "" || id == "." || id == ".." || len(id) > 96 {
268
+ return false
269
+ }
270
+ for _, r := range id {
271
+ switch {
272
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
273
+ default:
274
+ return false
275
+ }
276
+ }
277
+ return true
278
+ }
279
+
280
+ // syncQueuedSessionLocked 让会话登记表与作业状态一致(调用方须持 q.mu)。
281
+ //
282
+ // 只在「排队中 + 新会话」时写占位:追问(j.Session 非空)本来就是已有会话的一条
283
+ // 排队消息,不该再冒出一行新会话;开跑 / 终结后占位必须抹掉,否则列表里会同时有
284
+ // 占位行和 core 的真记录。
285
+ func (q *jobQueue) syncQueuedSessionLocked(j *Job) {
286
+ if q == nil || j == nil {
287
+ return
288
+ }
289
+ id := queuedSessionID(j)
290
+ if id == "" {
291
+ return
292
+ }
293
+ if j.State != JobQueued || strings.TrimSpace(j.Session) != "" {
294
+ session.RemoveQueued(id)
295
+ return
296
+ }
297
+ client := ""
298
+ if q.svc != nil {
299
+ client = q.svc.opts.Profile.AppID()
300
+ }
301
+ cwd, _ := os.Getwd()
302
+ rec := session.Record{
303
+ RunID: id,
304
+ Engine: j.Engine,
305
+ Model: j.Model,
306
+ Source: j.Owner.Source,
307
+ Client: client,
308
+ Cwd: cwd,
309
+ PromptHead: j.Prompt,
310
+ StartedAt: j.CreatedAt,
311
+ }
312
+ /* workspace 的口径与 core 的 Begin 一致:只有**显式选了空间**才算 Workspace;
313
+ 没选时列表按空 workspace 归到「任务」段(core 那时用 cwd)。 */
314
+ if j.ExplicitWorkspace {
315
+ rec.Workspace = j.Workspace
316
+ }
317
+ if j.Team != nil {
318
+ rec.Mode = session.ModeTeam
319
+ }
320
+ _ = session.RegisterQueued(rec) // 登记失败不该影响作业本身:最多这条列表晚一点出现
321
+ }
322
+
215
323
  func (q *jobQueue) removeFile(id string) {
216
324
  if q == nil || q.dir == "" {
217
325
  return
@@ -270,6 +378,9 @@ func (q *jobQueue) load() {
270
378
  }
271
379
  case JobDone, JobFailed, JobCancelled, JobExpired:
272
380
  if !j.UpdatedAt.IsZero() && now.Sub(j.UpdatedAt) > jobKeepTerminal {
381
+ /* 作业文件都要清了,排队占位(如果上次崩在「终态已落盘、占位还没删」
382
+ 的窗口里)也一并清掉,别留一条永远排队的幽灵行。 */
383
+ session.RemoveQueued(queuedSessionID(&j))
273
384
  _ = os.Remove(filepath.Join(q.dir, e.Name()))
274
385
  continue
275
386
  }
@@ -279,6 +390,13 @@ func (q *jobQueue) load() {
279
390
  j.UpdatedAt = now
280
391
  }
281
392
  q.jobs[j.ID] = &j
393
+ /* 与登记表对账:queued 的占位必须存在(受理时写过,这里幂等补一次);
394
+ 其余状态一律不留占位 —— 真开跑后由 core 自己的记录接手。 */
395
+ if j.State == JobQueued {
396
+ q.syncQueuedSessionLocked(&j)
397
+ } else {
398
+ session.RemoveQueued(queuedSessionID(&j))
399
+ }
282
400
  if j.RequestID != "" {
283
401
  q.byReq[j.RequestID] = j.ID
284
402
  }
@@ -286,6 +404,54 @@ func (q *jobQueue) load() {
286
404
  q.byAsk[j.AskID] = j.ID
287
405
  }
288
406
  }
407
+ q.pruneOrphanPlaceholdersLocked()
408
+ }
409
+
410
+ /*
411
+ orphanPlaceholderGrace 占位写入与作业文件落盘之间有一个极小窗口(persistLocked 先写登记、
412
+
413
+ 再写作业文件,见其注释),启动对账时给新占位留一点余地,免得把「另一个进程刚提交」的
414
+ 那一条当成陈迹删掉。
415
+ */
416
+ var orphanPlaceholderGrace = time.Minute
417
+
418
+ // pruneOrphanPlaceholdersLocked 清掉**作业已经不存在**的排队占位(幽灵「等待中」行)。
419
+ //
420
+ // 何时会出现(2026-10-24 用户:「清掉」):占位是受理那一刻写的(core 的 RegisterQueued),
421
+ // 正常由 syncQueuedSessionLocked 在开跑 / 终结时删掉。但作业文件被 prune、服务崩在
422
+ // 「占位已写、作业还没落盘」之后、或换过作业目录(MAGIC_CLIENT_JOBS_DIR)时,占位就
423
+ // 没人认领了 —— 它 PID=0、state 永远 queued,列表上一直挂着「等待中」,点也点不开。
424
+ //
425
+ // 口径:只看**本客户端**写的占位(Record.Client == 本 profile 的 AppID)—— 别的 magic-*
426
+ // 应用有自己的作业目录,本进程无从判断它们是否还排着,不能替它们删。RunID 命中任一
427
+ // queued 作业(queuedSessionID)即保留。调用方须持 q.mu。
428
+ func (q *jobQueue) pruneOrphanPlaceholdersLocked() {
429
+ if q == nil || q.dir == "" || q.svc == nil {
430
+ return
431
+ }
432
+ live := map[string]bool{}
433
+ for _, j := range q.jobs {
434
+ if j != nil && j.State == JobQueued {
435
+ if id := queuedSessionID(j); id != "" {
436
+ live[id] = true
437
+ }
438
+ }
439
+ }
440
+ recs, err := session.List()
441
+ if err != nil {
442
+ return
443
+ }
444
+ ours := q.svc.opts.Profile.AppID()
445
+ cutoff := time.Now().Add(-orphanPlaceholderGrace)
446
+ for _, r := range recs {
447
+ if r.State != session.StateQueued || r.Client != ours || live[r.RunID] {
448
+ continue
449
+ }
450
+ if r.UpdatedAt.After(cutoff) || r.StartedAt.After(cutoff) {
451
+ continue
452
+ }
453
+ session.RemoveQueued(r.RunID)
454
+ }
289
455
  }
290
456
 
291
457
  /* ── 提交 / 查询 / 取消 ── */
@@ -350,6 +516,8 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
350
516
  Idle: req.Idle,
351
517
  NoThinking: req.NoThinking,
352
518
  Hosted: req.Hosted,
519
+ FallbackEngine: strings.TrimSpace(req.FallbackEngine),
520
+ FallbackModel: strings.TrimSpace(req.FallbackModel),
353
521
  Attachments: append([]string(nil), req.Attachments...),
354
522
  Team: req.Team,
355
523
  Serial: serial,
@@ -683,17 +851,24 @@ func (q *jobQueue) dispatch() {
683
851
  })
684
852
  var admitted []*Job
685
853
  for _, j := range cands {
686
- if q.running >= q.max {
687
- break
854
+ limit := q.max
855
+ if !j.Serial {
856
+ // 并行作业(交互式轻活)满员后仍有余量可借:交互不该被长任务压死。
857
+ // 用 continue 而不是 break —— FIFO 里排在串行作业后面的并行作业不陪绑。
858
+ limit += parallelHeadroom
859
+ }
860
+ if q.running >= limit {
861
+ continue
688
862
  }
863
+ /* 串行作业占住工作空间单槽;并行作业不占槽、也不让路 ——
864
+ 客户端把模式从串行切到并行后,同空间的排队作业要能立刻跟着已经在跑的
865
+ 串行作业一起放出去(真正的互斥由 core 侧工作空间锁只对串行注入来保证,
866
+ 见 startAsk)。 */
689
867
  if j.Serial {
690
868
  if q.active[j.Workspace] != "" {
691
869
  continue
692
870
  }
693
871
  q.active[j.Workspace] = j.ID
694
- } else if q.active[j.Workspace] != "" {
695
- // 并行作业不占槽,但不该和同空间的串行作业抢跑。
696
- continue
697
872
  }
698
873
  j.State = JobRunning
699
874
  j.StartedAt = now
@@ -874,6 +1049,12 @@ func (q *jobQueue) runEnded(run *askRun) {
874
1049
  if rid != "" {
875
1050
  j.RunID = rid
876
1051
  }
1052
+ // 主备结论落作业记录(2026-10-26):配了什么上面有,用没用上在这里。
1053
+ if fbe, fbm := run.fallbackState(); fbe != "" {
1054
+ j.FallbackUsed = true
1055
+ j.FallbackUsedEngine = fbe
1056
+ j.FallbackUsedModel = fbm
1057
+ }
877
1058
  j.UpdatedAt = time.Now()
878
1059
  // 先释放(running-- / 清 active),再算位置;否则自己会被当成「还在跑」。
879
1060
  if j.Serial && q.active[j.Workspace] == j.ID {
@@ -946,6 +1127,49 @@ func (q *jobQueue) cancelByRequest(requestID, reason string) bool {
946
1127
  return q.cancel(id, reason)
947
1128
  }
948
1129
 
1130
+ // updateSerial 更新作业的串行/并行标志:客户端把任务模式切了之后,已排队但还没开跑的
1131
+ // 作业要立刻按新模式裁决,而不是沿用提交那一刻的快照 —— 不然「串行切并行」要等整条
1132
+ // 串行队消化完才生效。只认排队中的作业:running 的模式在拉起 core 时(工作空间锁注入
1133
+ // 与否)已经定死,改了也不生效,所以原样返回 updated=false。
1134
+ func (q *jobQueue) updateSerial(id string, serial bool) (*Job, bool) {
1135
+ q.mu.Lock()
1136
+ j := q.jobs[id]
1137
+ if j == nil {
1138
+ q.mu.Unlock()
1139
+ return nil, false
1140
+ }
1141
+ if j.State != JobQueued {
1142
+ cp := *j
1143
+ cp.Position = q.positionLocked(j)
1144
+ q.mu.Unlock()
1145
+ return &cp, false
1146
+ }
1147
+ j.Serial = serial
1148
+ j.UpdatedAt = time.Now()
1149
+ q.recomputePositionsLocked()
1150
+ q.persistLocked(j)
1151
+ cp := *j
1152
+ cp.Position = q.positionLocked(j)
1153
+ q.mu.Unlock()
1154
+ q.signal() // 唤醒调度循环:并行化的排队作业可能现在就能放行
1155
+ return &cp, true
1156
+ }
1157
+
1158
+ // updateSerialByRequest 按客户端幂等键更新(客户端还没拿到 job id 时用)。
1159
+ func (q *jobQueue) updateSerialByRequest(requestID string, serial bool) (*Job, bool) {
1160
+ requestID = strings.TrimSpace(requestID)
1161
+ if requestID == "" {
1162
+ return nil, false
1163
+ }
1164
+ q.mu.Lock()
1165
+ id := q.byReq[requestID]
1166
+ q.mu.Unlock()
1167
+ if id == "" {
1168
+ return nil, false
1169
+ }
1170
+ return q.updateSerial(id, serial)
1171
+ }
1172
+
949
1173
  /* ── HTTP ── */
950
1174
 
951
1175
  // handleJobs /desk/jobs:GET 列表 / POST 提交。
@@ -997,7 +1221,7 @@ func (s *Service) handleJobs(w http.ResponseWriter, r *http.Request) {
997
1221
  }
998
1222
  }
999
1223
 
1000
- // handleJobsSub /desk/jobs/{id}、/{id}/events、/{id}/cancel、/events(全局流)。
1224
+ // handleJobsSub /desk/jobs/{id}、/{id}/events、/{id}/cancel、/{id}/update、/events(全局流)。
1001
1225
  func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
1002
1226
  if s.jobs == nil {
1003
1227
  writeErr(w, http.StatusServiceUnavailable, "作业队列未初始化")
@@ -1030,10 +1254,37 @@ func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
1030
1254
  writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
1031
1255
  return
1032
1256
  }
1257
+ /* POST /desk/jobs/update {request_id, serial}:按幂等键改排队作业的串行/并行
1258
+ (客户端切任务模式时同步旧排队条目;只对排队中的作业生效)。 */
1259
+ if rest == "update" {
1260
+ if r.Method != http.MethodPost {
1261
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
1262
+ return
1263
+ }
1264
+ var body struct {
1265
+ RequestID string `json:"request_id"`
1266
+ Serial *bool `json:"serial"`
1267
+ }
1268
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
1269
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
1270
+ return
1271
+ }
1272
+ if body.Serial == nil {
1273
+ writeErr(w, http.StatusBadRequest, "缺少 serial 字段")
1274
+ return
1275
+ }
1276
+ job, updated := s.jobs.updateSerialByRequest(body.RequestID, *body.Serial)
1277
+ if job == nil {
1278
+ writeErr(w, http.StatusNotFound, "没有这个 request_id 对应的作业:"+body.RequestID)
1279
+ return
1280
+ }
1281
+ writeJSON(w, http.StatusOK, map[string]any{"job": job, "updated": updated})
1282
+ return
1283
+ }
1033
1284
  parts := strings.Split(rest, "/")
1034
1285
  id := parts[0]
1035
1286
  if id == "" {
1036
- writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel")
1287
+ writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel|update")
1037
1288
  return
1038
1289
  }
1039
1290
  action := ""
@@ -1065,8 +1316,30 @@ func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
1065
1316
  }
1066
1317
  ok := s.jobs.cancel(id, "客户端取消")
1067
1318
  writeJSON(w, http.StatusOK, map[string]any{"cancelled": ok, "job": s.jobs.get(id)})
1319
+ case "update":
1320
+ if r.Method != http.MethodPost {
1321
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
1322
+ return
1323
+ }
1324
+ var body struct {
1325
+ Serial *bool `json:"serial"`
1326
+ }
1327
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
1328
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
1329
+ return
1330
+ }
1331
+ if body.Serial == nil {
1332
+ writeErr(w, http.StatusBadRequest, "缺少 serial 字段")
1333
+ return
1334
+ }
1335
+ job, updated := s.jobs.updateSerial(id, *body.Serial)
1336
+ if job == nil {
1337
+ writeErr(w, http.StatusNotFound, "没有这个作业:"+id)
1338
+ return
1339
+ }
1340
+ writeJSON(w, http.StatusOK, map[string]any{"job": job, "updated": updated})
1068
1341
  default:
1069
- writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel")
1342
+ writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel|update")
1070
1343
  }
1071
1344
  }
1072
1345