magic-agent 2.4.3 → 2.6.3
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 +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -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/npm/install.js +11 -0
- package/package.json +14 -3
package/internal/client/jobs.go
CHANGED
|
@@ -42,6 +42,10 @@ const (
|
|
|
42
42
|
JobFailed = "failed"
|
|
43
43
|
JobCancelled = "cancelled"
|
|
44
44
|
JobExpired = "expired"
|
|
45
|
+
/* JobJudging 自动模式:已受理、正在判断「与同空间运行中任务是否冲突」。
|
|
46
|
+
它不是 dispatch 的候选(调度器只认 JobQueued),所以判定期间不会被抢跑;
|
|
47
|
+
判定完成(或失败降级)后回到 JobQueued,由 dispatch 正常准入。见 jobs_auto.go。 */
|
|
48
|
+
JobJudging = "judging"
|
|
45
49
|
|
|
46
50
|
// jobQueuedTTL 排队多久没轮到就过期(避免关机一周后突然跑一堆旧任务)。
|
|
47
51
|
jobQueuedTTL = 24 * time.Hour
|
|
@@ -67,20 +71,31 @@ type Job struct {
|
|
|
67
71
|
// ExplicitWorkspace 原请求是否显式带了 -w:恢复时原样下发,不能替用户改写默认语义。
|
|
68
72
|
ExplicitWorkspace bool `json:"explicit_workspace,omitempty"`
|
|
69
73
|
|
|
70
|
-
Session
|
|
71
|
-
Engine
|
|
72
|
-
Model
|
|
73
|
-
Prompt
|
|
74
|
-
Permission
|
|
75
|
-
Reasoning
|
|
76
|
-
Idle
|
|
77
|
-
NoThinking
|
|
78
|
-
Hosted
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
Session string `json:"session,omitempty"`
|
|
75
|
+
Engine string `json:"engine"`
|
|
76
|
+
Model string `json:"model,omitempty"`
|
|
77
|
+
Prompt string `json:"prompt"`
|
|
78
|
+
Permission string `json:"permission,omitempty"`
|
|
79
|
+
Reasoning string `json:"reasoning,omitempty"`
|
|
80
|
+
Idle string `json:"idle,omitempty"`
|
|
81
|
+
NoThinking bool `json:"no_thinking,omitempty"`
|
|
82
|
+
Hosted bool `json:"hosted,omitempty"`
|
|
83
|
+
// FallbackEngine / FallbackModel 主引擎的备用(2026-10-24,只增字段)。
|
|
84
|
+
// 作业会落盘(排队 / 重启续跑),所以必须一起存 —— 只在请求期存在的话,
|
|
85
|
+
// 排队到真正启动时备用就丢了(实测:/desk/ask 直接提交能降级,走作业队列不能)。
|
|
86
|
+
FallbackEngine string `json:"fallback_engine,omitempty"`
|
|
87
|
+
FallbackModel string `json:"fallback_model,omitempty"`
|
|
88
|
+
Attachments []string `json:"attachments,omitempty"`
|
|
89
|
+
Team *teamRequest `json:"team,omitempty"`
|
|
81
90
|
|
|
82
91
|
// Serial 是否参与 workspace 单槽串行(默认 true)。
|
|
83
92
|
Serial bool `json:"serial"`
|
|
93
|
+
/* Mode 任务模式(2026-10-26,只增):auto / parallel / serial 三档,
|
|
94
|
+
由客户端选择原样记录;空 = 老客户端(只看 Serial)。 */
|
|
95
|
+
Mode string `json:"mode,omitempty"`
|
|
96
|
+
/* Auto 自动模式的判定结论(2026-10-26,只增)。Serial 仍是最终裁决,
|
|
97
|
+
这里只回答「为什么」—— 界面据此解释「为什么在排队」。 */
|
|
98
|
+
Auto *JobAuto `json:"auto,omitempty"`
|
|
84
99
|
|
|
85
100
|
State string `json:"state"`
|
|
86
101
|
Position int `json:"position,omitempty"`
|
|
@@ -91,6 +106,15 @@ type Job struct {
|
|
|
91
106
|
SessionID string `json:"session_id,omitempty"`
|
|
92
107
|
PID int `json:"pid,omitempty"`
|
|
93
108
|
Error string `json:"error,omitempty"`
|
|
109
|
+
// FallbackUsed / FallbackUsedEngine / FallbackUsedModel 本轮由备用答的案
|
|
110
|
+
// (2026-10-26,只增字段;「配了什么」是上面的 Fallback*,「用没用上」是这里)。
|
|
111
|
+
//
|
|
112
|
+
// 作业记录是排查主备的**唯一现场**:以前只有「配了什么」,没有「用没用上」,
|
|
113
|
+
// 用户报「主备没生效」时无从判断(2026-10-26 现场就是如此)。
|
|
114
|
+
// 由服务端扫事件流里 type=fallback 的帧回填;没降级时不出现。
|
|
115
|
+
FallbackUsed bool `json:"fallback_used,omitempty"`
|
|
116
|
+
FallbackUsedEngine string `json:"fallback_engine_used,omitempty"`
|
|
117
|
+
FallbackUsedModel string `json:"fallback_model_used,omitempty"`
|
|
94
118
|
|
|
95
119
|
CreatedAt time.Time `json:"created_at"`
|
|
96
120
|
StartedAt time.Time `json:"started_at,omitempty"`
|
|
@@ -106,6 +130,25 @@ func (j *Job) terminal() bool {
|
|
|
106
130
|
return false
|
|
107
131
|
}
|
|
108
132
|
|
|
133
|
+
/* JobAuto 自动任务模式的判定结论(2026-10-26,只增字段)。
|
|
134
|
+
*
|
|
135
|
+
* 为什么单独存一份而不是只留 Serial:用户要看懂「为什么排在我前面那条后面」。
|
|
136
|
+
* 只给一个布尔的话,界面上只能写「等待中」,用户会以为卡住了(这正是 2026-10-24
|
|
137
|
+
* 「并行模式还在串行等待」那次报障的观感来源)。
|
|
138
|
+
*
|
|
139
|
+
* By 的取值:rule = 规则判定(同会话 / 提示词里的文件交集 / 同空间空闲);
|
|
140
|
+
*
|
|
141
|
+
* model = 建议引擎判的;undecided = 没判出来(超时/失败/没配引擎),按并行放行。
|
|
142
|
+
*/
|
|
143
|
+
type JobAuto struct {
|
|
144
|
+
Conflict bool `json:"conflict"`
|
|
145
|
+
Level string `json:"level"` // hard | soft | none
|
|
146
|
+
Reason string `json:"reason,omitempty"` // 一句中文理由(界面直接显示)
|
|
147
|
+
Overlaps []string `json:"overlaps,omitempty"`
|
|
148
|
+
By string `json:"by,omitempty"` // rule | model | undecided | pending
|
|
149
|
+
At time.Time `json:"at,omitempty"`
|
|
150
|
+
}
|
|
151
|
+
|
|
109
152
|
// askRequest 把作业还原成 core 启动参数需要的请求(不带上 RequestID:那是队列自己的事)。
|
|
110
153
|
func (j *Job) askRequest() askRequest {
|
|
111
154
|
req := askRequest{
|
|
@@ -121,7 +164,16 @@ func (j *Job) askRequest() askRequest {
|
|
|
121
164
|
Attachments: append([]string(nil), j.Attachments...),
|
|
122
165
|
Team: j.Team,
|
|
123
166
|
Client: j.Owner.Source,
|
|
124
|
-
|
|
167
|
+
// 备用引擎跟着作业一起还原(否则排队到开跑时降级配置就丢了)。
|
|
168
|
+
FallbackEngine: j.FallbackEngine,
|
|
169
|
+
FallbackModel: j.FallbackModel,
|
|
170
|
+
}
|
|
171
|
+
/* ⚠️ 必须回填 Serial:startAsk 按它决定要不要给 core **关掉**工作空间锁
|
|
172
|
+
(见 service.go 的 EnvWorkspaceLock;2026-10-26 起锁默认生效)。漏掉 →
|
|
173
|
+
req.Serial == nil → 按「串行」处理 → 并行作业在 core 的 Begin 处又重新串起来,
|
|
174
|
+
把「并行」架空(切并行后看着没生效)。 */
|
|
175
|
+
serial := j.Serial
|
|
176
|
+
req.Serial = &serial
|
|
125
177
|
if j.ExplicitWorkspace {
|
|
126
178
|
req.Workspace = j.Workspace
|
|
127
179
|
}
|
|
@@ -152,6 +204,12 @@ type jobQueue struct {
|
|
|
152
204
|
const envJobsDir = "MAGIC_CLIENT_JOBS_DIR"
|
|
153
205
|
const envMaxRunning = "MAGIC_CLIENT_MAX_RUNNING"
|
|
154
206
|
|
|
207
|
+
// parallelHeadroom 全局并发满员(max)后,并行作业仍可借用的放行余量。
|
|
208
|
+
// 并行作业是交互式轻活(客户端并行模式下的聊天),不该被一批长任务压死 ——
|
|
209
|
+
// 2026-10-24 事故:4 个长任务占满 max=4,手机发条「hi」在队列里干等 6 分钟。
|
|
210
|
+
// 只给余量而非不设限:防 UI 故障连环提交把引擎进程数打爆。
|
|
211
|
+
const parallelHeadroom = 4
|
|
212
|
+
|
|
155
213
|
func newJobQueue(s *Service) *jobQueue {
|
|
156
214
|
dir := strings.TrimSpace(os.Getenv(envJobsDir))
|
|
157
215
|
if dir == "" {
|
|
@@ -191,7 +249,14 @@ func (q *jobQueue) path(id string) string { return filepath.Join(q.dir, id+".jso
|
|
|
191
249
|
|
|
192
250
|
// persistLocked 原子写一条作业(tmp + rename);调用方须持 q.mu(或独占该 Job)。
|
|
193
251
|
func (q *jobQueue) persistLocked(j *Job) {
|
|
194
|
-
if q == nil ||
|
|
252
|
+
if q == nil || j == nil {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
/* 会话登记表跟着作业状态走(2026-10-24 用户:「没跑也先登记啊」):
|
|
256
|
+
queued 的作业**受理即登记**,列表页不用先看到「真开跑」才有这条;
|
|
257
|
+
开跑 / 终结时抹掉占位,交给 core 自己那条 running 记录。 */
|
|
258
|
+
q.syncQueuedSessionLocked(j)
|
|
259
|
+
if q.dir == "" {
|
|
195
260
|
return
|
|
196
261
|
}
|
|
197
262
|
b, err := json.MarshalIndent(j, "", " ")
|
|
@@ -212,6 +277,79 @@ func (q *jobQueue) persist(j *Job) {
|
|
|
212
277
|
q.persistLocked(j)
|
|
213
278
|
}
|
|
214
279
|
|
|
280
|
+
// queuedSessionID 排队占位的登记 id:客户端的幂等键优先(客户端据此把本地那条
|
|
281
|
+
// 「等待中」与登记行认成同一条),没有才用 job id。
|
|
282
|
+
// core 自己的 run_id 是 run-… 前缀,正常不会与它们相撞。
|
|
283
|
+
func queuedSessionID(j *Job) string {
|
|
284
|
+
if j == nil {
|
|
285
|
+
return ""
|
|
286
|
+
}
|
|
287
|
+
if id := strings.TrimSpace(j.RequestID); id != "" && safeFileNameID(id) {
|
|
288
|
+
return id
|
|
289
|
+
}
|
|
290
|
+
return j.ID
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// safeFileNameID 客户端给的 request_id 会变成登记文件名(<run_id>.json):
|
|
294
|
+
// 只接受字母 / 数字 / - _ .(且不能是 . / ..),其余一律退回 job id ——
|
|
295
|
+
// 不让一个自定义 request_id 把文件写到登记目录外面去。
|
|
296
|
+
func safeFileNameID(id string) bool {
|
|
297
|
+
if id == "" || id == "." || id == ".." || len(id) > 96 {
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
for _, r := range id {
|
|
301
|
+
switch {
|
|
302
|
+
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
|
|
303
|
+
default:
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return true
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// syncQueuedSessionLocked 让会话登记表与作业状态一致(调用方须持 q.mu)。
|
|
311
|
+
//
|
|
312
|
+
// 只在「排队中 + 新会话」时写占位:追问(j.Session 非空)本来就是已有会话的一条
|
|
313
|
+
// 排队消息,不该再冒出一行新会话;开跑 / 终结后占位必须抹掉,否则列表里会同时有
|
|
314
|
+
// 占位行和 core 的真记录。
|
|
315
|
+
func (q *jobQueue) syncQueuedSessionLocked(j *Job) {
|
|
316
|
+
if q == nil || j == nil {
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
id := queuedSessionID(j)
|
|
320
|
+
if id == "" {
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if j.State != JobQueued || strings.TrimSpace(j.Session) != "" {
|
|
324
|
+
session.RemoveQueued(id)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
client := ""
|
|
328
|
+
if q.svc != nil {
|
|
329
|
+
client = q.svc.opts.Profile.AppID()
|
|
330
|
+
}
|
|
331
|
+
cwd, _ := os.Getwd()
|
|
332
|
+
rec := session.Record{
|
|
333
|
+
RunID: id,
|
|
334
|
+
Engine: j.Engine,
|
|
335
|
+
Model: j.Model,
|
|
336
|
+
Source: j.Owner.Source,
|
|
337
|
+
Client: client,
|
|
338
|
+
Cwd: cwd,
|
|
339
|
+
PromptHead: j.Prompt,
|
|
340
|
+
StartedAt: j.CreatedAt,
|
|
341
|
+
}
|
|
342
|
+
/* workspace 的口径与 core 的 Begin 一致:只有**显式选了空间**才算 Workspace;
|
|
343
|
+
没选时列表按空 workspace 归到「任务」段(core 那时用 cwd)。 */
|
|
344
|
+
if j.ExplicitWorkspace {
|
|
345
|
+
rec.Workspace = j.Workspace
|
|
346
|
+
}
|
|
347
|
+
if j.Team != nil {
|
|
348
|
+
rec.Mode = session.ModeTeam
|
|
349
|
+
}
|
|
350
|
+
_ = session.RegisterQueued(rec) // 登记失败不该影响作业本身:最多这条列表晚一点出现
|
|
351
|
+
}
|
|
352
|
+
|
|
215
353
|
func (q *jobQueue) removeFile(id string) {
|
|
216
354
|
if q == nil || q.dir == "" {
|
|
217
355
|
return
|
|
@@ -243,6 +381,21 @@ func (q *jobQueue) load() {
|
|
|
243
381
|
if err := json.Unmarshal(b, &j); err != nil || strings.TrimSpace(j.ID) == "" {
|
|
244
382
|
continue
|
|
245
383
|
}
|
|
384
|
+
/* 自动判定没跑完就重启:**不重判**(用户 2026-10-26 明确「不需要排队 60 秒后
|
|
385
|
+
重新判定」),按并行放行 —— 判定是保护不是闸门,重启后让它继续排队才是事故。 */
|
|
386
|
+
if j.State == JobJudging {
|
|
387
|
+
j.State = JobQueued
|
|
388
|
+
j.Serial = false
|
|
389
|
+
if j.Auto == nil {
|
|
390
|
+
j.Auto = &JobAuto{}
|
|
391
|
+
}
|
|
392
|
+
j.Auto.Conflict = false
|
|
393
|
+
j.Auto.Level = "none"
|
|
394
|
+
j.Auto.By = "undecided"
|
|
395
|
+
j.Auto.Reason = "服务重启,未完成的判定按并行放行"
|
|
396
|
+
j.Auto.At = now
|
|
397
|
+
j.UpdatedAt = now
|
|
398
|
+
}
|
|
246
399
|
switch j.State {
|
|
247
400
|
case JobRunning:
|
|
248
401
|
/* 服务重启时还在 running:core 是 NoCancel 起的,可能仍在跑。
|
|
@@ -270,6 +423,9 @@ func (q *jobQueue) load() {
|
|
|
270
423
|
}
|
|
271
424
|
case JobDone, JobFailed, JobCancelled, JobExpired:
|
|
272
425
|
if !j.UpdatedAt.IsZero() && now.Sub(j.UpdatedAt) > jobKeepTerminal {
|
|
426
|
+
/* 作业文件都要清了,排队占位(如果上次崩在「终态已落盘、占位还没删」
|
|
427
|
+
的窗口里)也一并清掉,别留一条永远排队的幽灵行。 */
|
|
428
|
+
session.RemoveQueued(queuedSessionID(&j))
|
|
273
429
|
_ = os.Remove(filepath.Join(q.dir, e.Name()))
|
|
274
430
|
continue
|
|
275
431
|
}
|
|
@@ -279,6 +435,13 @@ func (q *jobQueue) load() {
|
|
|
279
435
|
j.UpdatedAt = now
|
|
280
436
|
}
|
|
281
437
|
q.jobs[j.ID] = &j
|
|
438
|
+
/* 与登记表对账:queued 的占位必须存在(受理时写过,这里幂等补一次);
|
|
439
|
+
其余状态一律不留占位 —— 真开跑后由 core 自己的记录接手。 */
|
|
440
|
+
if j.State == JobQueued {
|
|
441
|
+
q.syncQueuedSessionLocked(&j)
|
|
442
|
+
} else {
|
|
443
|
+
session.RemoveQueued(queuedSessionID(&j))
|
|
444
|
+
}
|
|
282
445
|
if j.RequestID != "" {
|
|
283
446
|
q.byReq[j.RequestID] = j.ID
|
|
284
447
|
}
|
|
@@ -286,6 +449,68 @@ func (q *jobQueue) load() {
|
|
|
286
449
|
q.byAsk[j.AskID] = j.ID
|
|
287
450
|
}
|
|
288
451
|
}
|
|
452
|
+
q.pruneOrphanPlaceholdersLocked()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/*
|
|
456
|
+
orphanPlaceholderGrace 占位写入与作业文件落盘之间有一个极小窗口(persistLocked 先写登记、
|
|
457
|
+
|
|
458
|
+
再写作业文件,见其注释),启动对账时给新占位留一点余地,免得把「另一个进程刚提交」的
|
|
459
|
+
那一条当成陈迹删掉。
|
|
460
|
+
*/
|
|
461
|
+
var orphanPlaceholderGrace = time.Minute
|
|
462
|
+
|
|
463
|
+
// pruneOrphanPlaceholdersLocked 清掉**作业已经不存在**的排队占位(幽灵「等待中」行)。
|
|
464
|
+
//
|
|
465
|
+
// 何时会出现(2026-10-24 用户:「清掉」):占位是受理那一刻写的(core 的 RegisterQueued),
|
|
466
|
+
// 正常由 syncQueuedSessionLocked 在开跑 / 终结时删掉。但作业文件被 prune、服务崩在
|
|
467
|
+
// 「占位已写、作业还没落盘」之后、或换过作业目录(MAGIC_CLIENT_JOBS_DIR)时,占位就
|
|
468
|
+
// 没人认领了 —— 它 PID=0、state 永远 queued,列表上一直挂着「等待中」,点也点不开。
|
|
469
|
+
//
|
|
470
|
+
// 口径:只看**本客户端**写的占位(Record.Client == 本 profile 的 AppID)—— 别的 magic-*
|
|
471
|
+
// 应用有自己的作业目录,本进程无从判断它们是否还排着,不能替它们删。RunID 命中任一
|
|
472
|
+
// queued 作业(queuedSessionID)即保留。调用方须持 q.mu。
|
|
473
|
+
func (q *jobQueue) pruneOrphanPlaceholdersLocked() {
|
|
474
|
+
if q == nil || q.dir == "" || q.svc == nil {
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
/* runID → 作业状态。**不能只记 queued**:占位对应的作业一旦开跑 / 收尾,
|
|
478
|
+
占位就必须让位(正常由 syncQueuedSessionLocked 在状态切换时删;但多实例共用
|
|
479
|
+
一个作业目录、或那次落盘失败时,这步会漏 —— 现场就是 job 已 done、占位还在)。 */
|
|
480
|
+
known := map[string]string{}
|
|
481
|
+
for _, j := range q.jobs {
|
|
482
|
+
if j == nil {
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if id := queuedSessionID(j); id != "" {
|
|
486
|
+
known[id] = j.State
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
recs, err := session.List()
|
|
490
|
+
if err != nil {
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
ours := q.svc.opts.Profile.AppID()
|
|
494
|
+
cutoff := time.Now().Add(-orphanPlaceholderGrace)
|
|
495
|
+
for _, r := range recs {
|
|
496
|
+
if r.State != session.StateQueued || r.Client != ours {
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
if st, ok := known[r.RunID]; ok {
|
|
500
|
+
if st == JobQueued {
|
|
501
|
+
continue // 还排着:占位该在
|
|
502
|
+
}
|
|
503
|
+
/* 已开跑 / 已终结:占位是陈迹,立刻清掉(不套宽限 —— 它是明确的错状态)。 */
|
|
504
|
+
session.RemoveQueued(r.RunID)
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
/* 认不出对应作业(作业文件被 prune / 另一个实例的目录 / 换过作业目录):
|
|
508
|
+
给「占位已写、作业还没落盘」那个窗口留宽限,之后才清。 */
|
|
509
|
+
if r.UpdatedAt.After(cutoff) || r.StartedAt.After(cutoff) {
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
session.RemoveQueued(r.RunID)
|
|
513
|
+
}
|
|
289
514
|
}
|
|
290
515
|
|
|
291
516
|
/* ── 提交 / 查询 / 取消 ── */
|
|
@@ -293,7 +518,7 @@ func (q *jobQueue) load() {
|
|
|
293
518
|
func normalizeWorkspace(ws string) string {
|
|
294
519
|
ws = strings.TrimSpace(ws)
|
|
295
520
|
if ws == "" {
|
|
296
|
-
ws
|
|
521
|
+
ws = defaultWorkspace()
|
|
297
522
|
}
|
|
298
523
|
if abs, err := filepath.Abs(ws); err == nil {
|
|
299
524
|
ws = abs
|
|
@@ -301,11 +526,100 @@ func normalizeWorkspace(ws string) string {
|
|
|
301
526
|
return filepath.Clean(ws)
|
|
302
527
|
}
|
|
303
528
|
|
|
529
|
+
/* defaultWorkspace 请求没带 workspace 时,这一轮算在哪个目录。
|
|
530
|
+
*
|
|
531
|
+
* 为什么不能直接 os.Getwd()(2026-10-26 实测事故):
|
|
532
|
+
* 插件的 cwd 由**启动它的人**决定,不一定是用户的项目。当天那份实例是 `cd /` 起来的,
|
|
533
|
+
* 于是所有不带 -w 的消息都被归到工作空间 `/`:
|
|
534
|
+
* · /desk/health 的 defaultWorkspace 报 `/`,移动端新建会话拿到的工作区就是根目录;
|
|
535
|
+
* · 作业文件里写着 "workspace": "/",而 core 自己登记的是真实 cwd —— 两边对不上;
|
|
536
|
+
* · 更麻烦的是 mode=auto 的同空间冲突判定和 core 的工作空间锁都按这个 key 算,
|
|
537
|
+
* `/` 变成一个「所有会话都算同一空间」的万能桶,串行/并行全判错。
|
|
538
|
+
*
|
|
539
|
+
* 正确顺序:插件 cwd 有意义就用它;cwd 是文件系统根(或者拿不到)时,退回**上一次
|
|
540
|
+
* 真正用过的工作区**(prefs 里的 lastWorkspace,由客户端每次建会话时写回)。
|
|
541
|
+
* 两者都没有就只能是 cwd —— 不编一个假路径出来。
|
|
542
|
+
*/
|
|
543
|
+
func defaultWorkspace() string {
|
|
544
|
+
wd, err := os.Getwd()
|
|
545
|
+
if err == nil && !isRootDir(wd) {
|
|
546
|
+
return wd
|
|
547
|
+
}
|
|
548
|
+
if last := lastWorkspacePref(); last != "" && !isRootDir(last) {
|
|
549
|
+
return last
|
|
550
|
+
}
|
|
551
|
+
return wd
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// isRootDir 判断是不是文件系统根(`/`,Windows 上是 `C:\`)。
|
|
555
|
+
//
|
|
556
|
+
// 根目录不是一个用户项目:没有任何 README、任何 agent 在它下面干活都是错的,
|
|
557
|
+
// 而且它会把「同空间」这个判据退化成「全机所有会话同空间」。
|
|
558
|
+
func isRootDir(p string) bool {
|
|
559
|
+
p = strings.TrimSpace(p)
|
|
560
|
+
if p == "" {
|
|
561
|
+
return false
|
|
562
|
+
}
|
|
563
|
+
return filepath.Dir(filepath.Clean(p)) == filepath.Clean(p)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* lastWorkspacePref 读「上一次真正用过的工作区」(prefs 的 lastWorkspace 字段)。
|
|
567
|
+
*
|
|
568
|
+
* 字段由移动端在每次建会话时写进 /desk/prefs(与其它偏好同源,多端共享一份)。
|
|
569
|
+
* 读不到 / 值不是绝对路径 / 目录已不存在 → 空串,让调用方走兜底。
|
|
570
|
+
* 这里只做**只读**:默认工作区不该因为在判一个默认值就反过来改用户的配置。
|
|
571
|
+
*/
|
|
572
|
+
func lastWorkspacePref() string {
|
|
573
|
+
p := mobilePrefsPath()
|
|
574
|
+
if p == "" {
|
|
575
|
+
return ""
|
|
576
|
+
}
|
|
577
|
+
b, err := os.ReadFile(p)
|
|
578
|
+
if err != nil || len(b) == 0 {
|
|
579
|
+
return ""
|
|
580
|
+
}
|
|
581
|
+
var v struct {
|
|
582
|
+
LastWorkspace string `json:"lastWorkspace"`
|
|
583
|
+
}
|
|
584
|
+
if err := json.Unmarshal(b, &v); err != nil {
|
|
585
|
+
return ""
|
|
586
|
+
}
|
|
587
|
+
ws := strings.TrimSpace(v.LastWorkspace)
|
|
588
|
+
if ws == "" || !filepath.IsAbs(ws) {
|
|
589
|
+
return ""
|
|
590
|
+
}
|
|
591
|
+
if st, err := os.Stat(ws); err != nil || !st.IsDir() {
|
|
592
|
+
return ""
|
|
593
|
+
}
|
|
594
|
+
return ws
|
|
595
|
+
}
|
|
596
|
+
|
|
304
597
|
// submitAsk 入队(或按 request_id 接回原作业)。created=false 表示命中幂等。
|
|
305
598
|
// ⚠️ 返回的是**副本**:调度器随时会把状态改成 running,直接把内部指针交出去
|
|
306
599
|
// 会让 HTTP 响应一边 marshal 一边被改(数据竞争,且客户端可能先看到 running)。
|
|
307
600
|
func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool, error) {
|
|
308
601
|
reqID := strings.TrimSpace(req.RequestID)
|
|
602
|
+
/* 自动模式要读登记表 / 锁文件判「同空间有没有人在跑」。这份快照必须**锁外**抓:
|
|
603
|
+
里面要 stat + PID 探活 + 开文件,占着 q.mu 会把整个调度器卡住。快照过期几毫秒的
|
|
604
|
+
代价是「多判一次忙 / 漏判一次」,漏判方向由锁内「本队列同空间 running」兜住。 */
|
|
605
|
+
/* 任务模式(见 CLIENT-CONTRACT.md「任务模式」):
|
|
606
|
+
auto → 判冲突,冲突串行、否则并行(下面 autoMode 分支)
|
|
607
|
+
parallel → 一律并行,**不判定**(用户显式选择,不要拿 AI 再猜一遍)
|
|
608
|
+
serial → 同空间单槽串行,不判定
|
|
609
|
+
空 → 老客户端:沿用 serial 布尔,语义与之前完全一致
|
|
610
|
+
模式字段说了算,serial 只当缺省回退 —— 否则客户端只发 mode:"parallel"、
|
|
611
|
+
不发 serial 时会被当串行(两种来源打架时以用户选的那个为准)。 */
|
|
612
|
+
selMode := strings.ToLower(strings.TrimSpace(req.Mode))
|
|
613
|
+
switch selMode {
|
|
614
|
+
case "auto", "parallel", "serial":
|
|
615
|
+
default:
|
|
616
|
+
selMode = "" // 不认识的值按老口径走,不拿它去改行为
|
|
617
|
+
}
|
|
618
|
+
autoMode := selMode == "auto"
|
|
619
|
+
var snap busySnapshot
|
|
620
|
+
if autoMode {
|
|
621
|
+
snap = q.captureBusy(req.Workspace)
|
|
622
|
+
}
|
|
309
623
|
q.mu.Lock()
|
|
310
624
|
if reqID != "" {
|
|
311
625
|
if id, ok := q.byReq[reqID]; ok {
|
|
@@ -335,6 +649,36 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
|
|
|
335
649
|
if req.Serial != nil {
|
|
336
650
|
serial = *req.Serial
|
|
337
651
|
}
|
|
652
|
+
switch selMode {
|
|
653
|
+
case "parallel":
|
|
654
|
+
serial = false
|
|
655
|
+
case "serial":
|
|
656
|
+
serial = true
|
|
657
|
+
}
|
|
658
|
+
mode := selMode
|
|
659
|
+
state := JobQueued
|
|
660
|
+
var auto *JobAuto
|
|
661
|
+
var peers []queuedCand
|
|
662
|
+
if autoMode {
|
|
663
|
+
/* 同空间还在排队的作业也要参与比较:否则用户连发两条「改底部栏」时,
|
|
664
|
+
两条都走「空间空闲」快速通道,各判各的,最后同时去改同一个文件。 */
|
|
665
|
+
peers = q.queuedPeersLocked(ws, "")
|
|
666
|
+
rule := q.autoRuleLocked(ws, req, snap, peers)
|
|
667
|
+
if rule.decided {
|
|
668
|
+
serial = rule.serial
|
|
669
|
+
level := "none"
|
|
670
|
+
if serial {
|
|
671
|
+
level = "hard"
|
|
672
|
+
}
|
|
673
|
+
auto = &JobAuto{Conflict: serial, Level: level, Reason: rule.reason, By: rule.by, At: time.Now()}
|
|
674
|
+
} else {
|
|
675
|
+
/* 规则判不出来:先挂 judging(不是调度候选,不会被抢跑),
|
|
676
|
+
异步去读日志 / 调建议引擎;结果写回后统一回到 queued。 */
|
|
677
|
+
serial = false
|
|
678
|
+
state = JobJudging
|
|
679
|
+
auto = &JobAuto{Conflict: false, Level: "none", Reason: "正在判断是否与运行中的任务冲突", By: "pending", At: time.Now()}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
338
682
|
now := time.Now()
|
|
339
683
|
j := &Job{
|
|
340
684
|
ID: q.nextJobIDLocked(),
|
|
@@ -350,10 +694,14 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
|
|
|
350
694
|
Idle: req.Idle,
|
|
351
695
|
NoThinking: req.NoThinking,
|
|
352
696
|
Hosted: req.Hosted,
|
|
697
|
+
FallbackEngine: strings.TrimSpace(req.FallbackEngine),
|
|
698
|
+
FallbackModel: strings.TrimSpace(req.FallbackModel),
|
|
353
699
|
Attachments: append([]string(nil), req.Attachments...),
|
|
354
700
|
Team: req.Team,
|
|
355
701
|
Serial: serial,
|
|
356
|
-
|
|
702
|
+
Mode: mode,
|
|
703
|
+
Auto: auto,
|
|
704
|
+
State: state,
|
|
357
705
|
Owner: JobOwner{Client: strings.TrimSpace(req.Client), Source: source},
|
|
358
706
|
CreatedAt: now,
|
|
359
707
|
UpdatedAt: now,
|
|
@@ -368,6 +716,11 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
|
|
|
368
716
|
cp.Position = q.positionLocked(j)
|
|
369
717
|
q.persistLocked(j)
|
|
370
718
|
q.mu.Unlock()
|
|
719
|
+
/* 规则没判出来的,交给异步语义判定;它完成后自己把作业送回 queued。 */
|
|
720
|
+
if autoMode && state == JobJudging {
|
|
721
|
+
q.startAutoJudge(j.ID, ws, req, snap, peers)
|
|
722
|
+
return &cp, true, nil
|
|
723
|
+
}
|
|
371
724
|
q.signal()
|
|
372
725
|
return &cp, true, nil
|
|
373
726
|
}
|
|
@@ -492,7 +845,7 @@ func (q *jobQueue) waitStarted(ctx contextLike, job *Job, onQueued func(pos int)
|
|
|
492
845
|
if run != nil {
|
|
493
846
|
return run, true
|
|
494
847
|
}
|
|
495
|
-
if state != JobQueued && state != JobRunning {
|
|
848
|
+
if state != JobQueued && state != JobRunning && state != JobJudging {
|
|
496
849
|
return nil, false
|
|
497
850
|
}
|
|
498
851
|
/* ⚠️ 空队列直通时**不能**先发 queued:老客户端按「第一帧是 open」解析,
|
|
@@ -522,6 +875,11 @@ type contextLike interface {
|
|
|
522
875
|
func (q *jobQueue) loop() {
|
|
523
876
|
tick := time.NewTicker(time.Second)
|
|
524
877
|
defer tick.Stop()
|
|
878
|
+
/* 幽灵占位的周期回收:只在启动时做一次的话,服务不重启就永远清不掉 ——
|
|
879
|
+
现场症状就是「作业早就 done,列表上那条还用紫色挂着『排队中』」
|
|
880
|
+
(2026-10-26 用户:「还是在排队」,占位文件的 mtime 停在受理那一秒)。
|
|
881
|
+
30 秒一次足够:这不是关键路径,多扫一遍目录也不该拖慢调度。 */
|
|
882
|
+
var sweeps int
|
|
525
883
|
for {
|
|
526
884
|
select {
|
|
527
885
|
case <-q.stop:
|
|
@@ -532,9 +890,17 @@ func (q *jobQueue) loop() {
|
|
|
532
890
|
q.leaseOwned = q.ensureLease()
|
|
533
891
|
q.reloadNew()
|
|
534
892
|
q.dispatch()
|
|
893
|
+
if sweeps++; sweeps%jobSweepEvery == 0 {
|
|
894
|
+
q.mu.Lock()
|
|
895
|
+
q.pruneOrphanPlaceholdersLocked()
|
|
896
|
+
q.mu.Unlock()
|
|
897
|
+
}
|
|
535
898
|
}
|
|
536
899
|
}
|
|
537
900
|
|
|
901
|
+
// jobSweepEvery 每多少个调度拍(1s/拍)扫一次幽灵占位,见 loop。
|
|
902
|
+
const jobSweepEvery = 30
|
|
903
|
+
|
|
538
904
|
/*
|
|
539
905
|
── 多实例租约 ──
|
|
540
906
|
|
|
@@ -681,20 +1047,41 @@ func (q *jobQueue) dispatch() {
|
|
|
681
1047
|
}
|
|
682
1048
|
return cands[i].CreatedAt.Before(cands[k].CreatedAt)
|
|
683
1049
|
})
|
|
1050
|
+
/* 自动模式的同空间占位(2026-10-26):判为串行的 auto 作业,必须等同空间**所有**
|
|
1051
|
+
在跑的作业收尾 —— 包括判为并行的那些(它们不占 q.active 的槽)。
|
|
1052
|
+
不计这一条时的现场:第二条判串行、第一条判并行正在跑 → 第二条照样放行,
|
|
1053
|
+
自动模式等于没生效。老语义(serial 不等并行作业)保持不动,只对 auto 生效。 */
|
|
1054
|
+
busyAuto := map[string]int{}
|
|
1055
|
+
for _, j := range q.jobs {
|
|
1056
|
+
if j.State == JobRunning {
|
|
1057
|
+
busyAuto[j.Workspace]++
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
684
1060
|
var admitted []*Job
|
|
685
1061
|
for _, j := range cands {
|
|
686
|
-
|
|
687
|
-
|
|
1062
|
+
limit := q.max
|
|
1063
|
+
if !j.Serial {
|
|
1064
|
+
// 并行作业(交互式轻活)满员后仍有余量可借:交互不该被长任务压死。
|
|
1065
|
+
// 用 continue 而不是 break —— FIFO 里排在串行作业后面的并行作业不陪绑。
|
|
1066
|
+
limit += parallelHeadroom
|
|
688
1067
|
}
|
|
1068
|
+
if q.running >= limit {
|
|
1069
|
+
continue
|
|
1070
|
+
}
|
|
1071
|
+
/* 串行作业占住工作空间单槽;并行作业不占槽、也不让路 ——
|
|
1072
|
+
客户端把模式从串行切到并行后,同空间的排队作业要能立刻跟着已经在跑的
|
|
1073
|
+
串行作业一起放出去(真正的互斥由 core 侧工作空间锁只对串行注入来保证,
|
|
1074
|
+
见 startAsk)。 */
|
|
689
1075
|
if j.Serial {
|
|
690
1076
|
if q.active[j.Workspace] != "" {
|
|
691
1077
|
continue
|
|
692
1078
|
}
|
|
1079
|
+
if j.Mode == "auto" && busyAuto[j.Workspace] > 0 {
|
|
1080
|
+
continue
|
|
1081
|
+
}
|
|
693
1082
|
q.active[j.Workspace] = j.ID
|
|
694
|
-
} else if q.active[j.Workspace] != "" {
|
|
695
|
-
// 并行作业不占槽,但不该和同空间的串行作业抢跑。
|
|
696
|
-
continue
|
|
697
1083
|
}
|
|
1084
|
+
busyAuto[j.Workspace]++
|
|
698
1085
|
j.State = JobRunning
|
|
699
1086
|
j.StartedAt = now
|
|
700
1087
|
j.UpdatedAt = now
|
|
@@ -751,8 +1138,14 @@ func (q *jobQueue) externalBusy(ws string, selfJob string) bool {
|
|
|
751
1138
|
}
|
|
752
1139
|
q.mu.Lock()
|
|
753
1140
|
selfPID := 0
|
|
1141
|
+
ignoreIdle := false
|
|
754
1142
|
if j := q.jobs[selfJob]; j != nil {
|
|
755
1143
|
selfPID = j.PID
|
|
1144
|
+
/* 自动模式判出来的「串行」,唯一理由是**与运行中的任务冲突** —— 冲突对象只可能是
|
|
1145
|
+
running / waiting_approval。idle 的常驻会话本轮已经结束、不写文件,还把它当占用,
|
|
1146
|
+
一条自动串行作业会被同空间一个闲聊着的会话永久挡住(这正是老 externalBusy 的毛病)。
|
|
1147
|
+
明确的「串行」模式保持原样(那里 idle 也算占用的保守口径不变)。 */
|
|
1148
|
+
ignoreIdle = j.Mode == "auto"
|
|
756
1149
|
}
|
|
757
1150
|
q.mu.Unlock()
|
|
758
1151
|
target := normalizeWorkspace(ws)
|
|
@@ -763,6 +1156,9 @@ func (q *jobQueue) externalBusy(ws string, selfJob string) bool {
|
|
|
763
1156
|
if r.State != session.StateRunning && r.State != session.StateWaitingApproval && r.State != session.StateIdle {
|
|
764
1157
|
continue
|
|
765
1158
|
}
|
|
1159
|
+
if ignoreIdle && r.State == session.StateIdle {
|
|
1160
|
+
continue
|
|
1161
|
+
}
|
|
766
1162
|
w := strings.TrimSpace(r.Workspace)
|
|
767
1163
|
if w == "" {
|
|
768
1164
|
w = strings.TrimSpace(r.Cwd)
|
|
@@ -874,6 +1270,12 @@ func (q *jobQueue) runEnded(run *askRun) {
|
|
|
874
1270
|
if rid != "" {
|
|
875
1271
|
j.RunID = rid
|
|
876
1272
|
}
|
|
1273
|
+
// 主备结论落作业记录(2026-10-26):配了什么上面有,用没用上在这里。
|
|
1274
|
+
if fbe, fbm := run.fallbackState(); fbe != "" {
|
|
1275
|
+
j.FallbackUsed = true
|
|
1276
|
+
j.FallbackUsedEngine = fbe
|
|
1277
|
+
j.FallbackUsedModel = fbm
|
|
1278
|
+
}
|
|
877
1279
|
j.UpdatedAt = time.Now()
|
|
878
1280
|
// 先释放(running-- / 清 active),再算位置;否则自己会被当成「还在跑」。
|
|
879
1281
|
if j.Serial && q.active[j.Workspace] == j.ID {
|
|
@@ -946,6 +1348,49 @@ func (q *jobQueue) cancelByRequest(requestID, reason string) bool {
|
|
|
946
1348
|
return q.cancel(id, reason)
|
|
947
1349
|
}
|
|
948
1350
|
|
|
1351
|
+
// updateSerial 更新作业的串行/并行标志:客户端把任务模式切了之后,已排队但还没开跑的
|
|
1352
|
+
// 作业要立刻按新模式裁决,而不是沿用提交那一刻的快照 —— 不然「串行切并行」要等整条
|
|
1353
|
+
// 串行队消化完才生效。只认排队中的作业:running 的模式在拉起 core 时(工作空间锁注入
|
|
1354
|
+
// 与否)已经定死,改了也不生效,所以原样返回 updated=false。
|
|
1355
|
+
func (q *jobQueue) updateSerial(id string, serial bool) (*Job, bool) {
|
|
1356
|
+
q.mu.Lock()
|
|
1357
|
+
j := q.jobs[id]
|
|
1358
|
+
if j == nil {
|
|
1359
|
+
q.mu.Unlock()
|
|
1360
|
+
return nil, false
|
|
1361
|
+
}
|
|
1362
|
+
if j.State != JobQueued {
|
|
1363
|
+
cp := *j
|
|
1364
|
+
cp.Position = q.positionLocked(j)
|
|
1365
|
+
q.mu.Unlock()
|
|
1366
|
+
return &cp, false
|
|
1367
|
+
}
|
|
1368
|
+
j.Serial = serial
|
|
1369
|
+
j.UpdatedAt = time.Now()
|
|
1370
|
+
q.recomputePositionsLocked()
|
|
1371
|
+
q.persistLocked(j)
|
|
1372
|
+
cp := *j
|
|
1373
|
+
cp.Position = q.positionLocked(j)
|
|
1374
|
+
q.mu.Unlock()
|
|
1375
|
+
q.signal() // 唤醒调度循环:并行化的排队作业可能现在就能放行
|
|
1376
|
+
return &cp, true
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// updateSerialByRequest 按客户端幂等键更新(客户端还没拿到 job id 时用)。
|
|
1380
|
+
func (q *jobQueue) updateSerialByRequest(requestID string, serial bool) (*Job, bool) {
|
|
1381
|
+
requestID = strings.TrimSpace(requestID)
|
|
1382
|
+
if requestID == "" {
|
|
1383
|
+
return nil, false
|
|
1384
|
+
}
|
|
1385
|
+
q.mu.Lock()
|
|
1386
|
+
id := q.byReq[requestID]
|
|
1387
|
+
q.mu.Unlock()
|
|
1388
|
+
if id == "" {
|
|
1389
|
+
return nil, false
|
|
1390
|
+
}
|
|
1391
|
+
return q.updateSerial(id, serial)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
949
1394
|
/* ── HTTP ── */
|
|
950
1395
|
|
|
951
1396
|
// handleJobs /desk/jobs:GET 列表 / POST 提交。
|
|
@@ -997,7 +1442,7 @@ func (s *Service) handleJobs(w http.ResponseWriter, r *http.Request) {
|
|
|
997
1442
|
}
|
|
998
1443
|
}
|
|
999
1444
|
|
|
1000
|
-
// handleJobsSub /desk/jobs/{id}、/{id}/events、/{id}/cancel、/events(全局流)。
|
|
1445
|
+
// handleJobsSub /desk/jobs/{id}、/{id}/events、/{id}/cancel、/{id}/update、/events(全局流)。
|
|
1001
1446
|
func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
|
|
1002
1447
|
if s.jobs == nil {
|
|
1003
1448
|
writeErr(w, http.StatusServiceUnavailable, "作业队列未初始化")
|
|
@@ -1030,10 +1475,37 @@ func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
|
|
|
1030
1475
|
writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
|
|
1031
1476
|
return
|
|
1032
1477
|
}
|
|
1478
|
+
/* POST /desk/jobs/update {request_id, serial}:按幂等键改排队作业的串行/并行
|
|
1479
|
+
(客户端切任务模式时同步旧排队条目;只对排队中的作业生效)。 */
|
|
1480
|
+
if rest == "update" {
|
|
1481
|
+
if r.Method != http.MethodPost {
|
|
1482
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
1483
|
+
return
|
|
1484
|
+
}
|
|
1485
|
+
var body struct {
|
|
1486
|
+
RequestID string `json:"request_id"`
|
|
1487
|
+
Serial *bool `json:"serial"`
|
|
1488
|
+
}
|
|
1489
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
|
1490
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
|
|
1491
|
+
return
|
|
1492
|
+
}
|
|
1493
|
+
if body.Serial == nil {
|
|
1494
|
+
writeErr(w, http.StatusBadRequest, "缺少 serial 字段")
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
job, updated := s.jobs.updateSerialByRequest(body.RequestID, *body.Serial)
|
|
1498
|
+
if job == nil {
|
|
1499
|
+
writeErr(w, http.StatusNotFound, "没有这个 request_id 对应的作业:"+body.RequestID)
|
|
1500
|
+
return
|
|
1501
|
+
}
|
|
1502
|
+
writeJSON(w, http.StatusOK, map[string]any{"job": job, "updated": updated})
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1033
1505
|
parts := strings.Split(rest, "/")
|
|
1034
1506
|
id := parts[0]
|
|
1035
1507
|
if id == "" {
|
|
1036
|
-
writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel")
|
|
1508
|
+
writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel|update")
|
|
1037
1509
|
return
|
|
1038
1510
|
}
|
|
1039
1511
|
action := ""
|
|
@@ -1065,8 +1537,30 @@ func (s *Service) handleJobsSub(w http.ResponseWriter, r *http.Request) {
|
|
|
1065
1537
|
}
|
|
1066
1538
|
ok := s.jobs.cancel(id, "客户端取消")
|
|
1067
1539
|
writeJSON(w, http.StatusOK, map[string]any{"cancelled": ok, "job": s.jobs.get(id)})
|
|
1540
|
+
case "update":
|
|
1541
|
+
if r.Method != http.MethodPost {
|
|
1542
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
1543
|
+
return
|
|
1544
|
+
}
|
|
1545
|
+
var body struct {
|
|
1546
|
+
Serial *bool `json:"serial"`
|
|
1547
|
+
}
|
|
1548
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
|
1549
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
if body.Serial == nil {
|
|
1553
|
+
writeErr(w, http.StatusBadRequest, "缺少 serial 字段")
|
|
1554
|
+
return
|
|
1555
|
+
}
|
|
1556
|
+
job, updated := s.jobs.updateSerial(id, *body.Serial)
|
|
1557
|
+
if job == nil {
|
|
1558
|
+
writeErr(w, http.StatusNotFound, "没有这个作业:"+id)
|
|
1559
|
+
return
|
|
1560
|
+
}
|
|
1561
|
+
writeJSON(w, http.StatusOK, map[string]any{"job": job, "updated": updated})
|
|
1068
1562
|
default:
|
|
1069
|
-
writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel")
|
|
1563
|
+
writeErr(w, http.StatusNotFound, "用法:GET /desk/jobs/{id}[?after=],POST /desk/jobs/{id}/cancel|update")
|
|
1070
1564
|
}
|
|
1071
1565
|
}
|
|
1072
1566
|
|