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.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  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 +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,571 @@
1
+ package client
2
+
3
+ // jobs_auto.go - 「自动」任务模式:开跑前判断本轮与同工作空间运行中任务是否冲突。
4
+ //
5
+ // 用户口径(2026-10-26):「同一工作空间需要根据会话历史总结 + 本轮提示词判断跟运行中
6
+ // 的任务干的事是否冲突,冲突就串行等待」;「先规则再走建议引擎快速判断,默认并行」;
7
+ // 「不需要排队 60 秒后重新判定」。
8
+ //
9
+ // ── 为什么判定不能放进队列锁 ──
10
+ // submitAsk 全程持 q.mu(幂等、上限、位置都在里面算)。模型调用要 1-2s,塞进锁里会把
11
+ // **整个调度器**卡停 —— 所以判定分两段:
12
+ // · 锁内只做**规则**(纯内存 + 一份外部快照):同会话必串、同空间无候选直接并行;
13
+ // · 规则判不出来 → 作业挂 JobJudging,goroutine 去读日志 / 调模型,完了再回锁里
14
+ // 写结论并改回 JobQueued,交给 dispatch 正常准入。
15
+ //
16
+ // JobJudging **不是 dispatch 的候选**(dispatch 只认 JobQueued),所以判定期间不会被抢跑。
17
+ //
18
+ // ── 为什么默认并行 ──
19
+ // 判定是保护不是闸门:建议引擎没配、超时、报错、输出解析不了,一律按并行放行并标
20
+ // undecided。宁可偶尔撞上(用户可切「串行」兜底),也不能让一次模型抽风把任务卡死。
21
+ //
22
+ // ── 为什么不重判 ──
23
+ // 判为串行的作业进 queued,等主任务收尾后由 dispatch 按「同空间空闲」自然放行
24
+ // (jobs.go 的 active 槽)—— 冲突对象已经消失,不需要再问一次模型。
25
+
26
+ import (
27
+ "bytes"
28
+ "context"
29
+ "encoding/json"
30
+ "os"
31
+ "path/filepath"
32
+ "runtime"
33
+ "sort"
34
+ "strings"
35
+ "time"
36
+
37
+ "github.com/darren/magic-agent/internal/agent"
38
+ "github.com/darren/magic-agent/internal/judge"
39
+ "github.com/darren/magic-agent/internal/session"
40
+ )
41
+
42
+ const (
43
+ /* autoJudgeBudget 一次语义判定的总预算(读日志 + 模型)。到点即按并行放行。
44
+ 为什么从 2.5s 放到 4.5s(2026-10-26 实测):判定是**异步**的,提交那一刻就返回了
45
+ judging,用户根本不等它 —— 预算紧只会让冷启动(引擎 CLI 起进程 + 首包)超时被杀,
46
+ 结果全是 undecided 并行,自动模式等于没开。实测 2.5s 下 dsh/llm 偶发 signal: killed,
47
+ 4.5s 留足冷启动余量;核心内的 3.5s 是内层上限,这边是外层兜底。 */
48
+ autoJudgeBudget = 4500 * time.Millisecond
49
+ // autoDigestEvents 每个候选最多回看多少条日志事件(抽文件 / 近况)。
50
+ autoDigestEvents = 400
51
+ // autoDigestFiles 每个候选最多带多少个已改文件。
52
+ autoDigestFiles = 60
53
+ // autoRecentRunes 候选「近况」长度上限。
54
+ autoRecentRunes = 800
55
+ )
56
+
57
+ // autoJudgeSlots 语义判定的并发闸:2 = 允许两条提交同时判。
58
+ // 拿不到闸(已经在判别的)立即按「并行 + undecided」放行 —— 判定绝不能变成排队。
59
+ var autoJudgeSlots = make(chan struct{}, 2)
60
+
61
+ // busyCand 一个「正在运行、可能与本轮冲突」的会话快照。
62
+ type busyCand struct {
63
+ ID string // session_id 优先,回落 run_id(读日志/展示用)
64
+ SessionID string // 会话 id(追问同一条时用来判「同一会话」)
65
+ RunID string // 运行 id
66
+ Title string // core 归纳的标题;空则用首问
67
+ Workspace string // 原始工作空间(空 = 默认空间)
68
+ State string // running | waiting_approval
69
+ StartedAt time.Time // 越早越先跑,冲突提示里优先展示
70
+ }
71
+
72
+ // busySnapshot 提交那一刻的「外部占用」快照。
73
+ //
74
+ // 为什么在锁外抓:读登记表要 stat + PID 探活,读锁文件要开文件,不该占着 q.mu。
75
+ // 快照过期几毫秒的代价是「多判一次忙」或「漏判一次」—— 漏判方向由锁内那条
76
+ // 「本队列同空间 running」兜住,不会真并发出两条。
77
+ type busySnapshot struct {
78
+ cands []busyCand // 同空间的运行中会话(running / waiting_approval)
79
+ lockHeld bool // 同空间的工作空间锁被活进程持有(core Begin 后、登记表刷新前的窗口)
80
+ }
81
+
82
+ // autoRule 锁内规则判定的结论。
83
+ type autoRule struct {
84
+ decided bool // true = 规则已经能定;false = 需要语义判定
85
+ serial bool // decided 时有效
86
+ reason string // 中文理由(界面直接显示)
87
+ by string // rule
88
+ }
89
+
90
+ // spaceKeyOf 工作空间的比较键(与 core 的锁键同口径 + 两条团队目录归一)。
91
+ //
92
+ // 为什么复用 session.LockKey 而不是自己 Abs/Clean:core 抢锁用的就是它,
93
+ // 两边各写一份迟早在「尾斜杠算不算同一个」上分叉;它的注释也明说导出就是给客户端判同目录用的。
94
+ func spaceKeyOf(p string) string {
95
+ key, _ := session.LockKey(p)
96
+ return normalizeTeamSpace(key)
97
+ }
98
+
99
+ // normalizeTeamSpace 团队运行目录归到父空间。
100
+ //
101
+ // 团队子任务的会话把 workspace 记成 <ws>/.magic/team/<run>/<task>(internal/team/execute.go),
102
+ // 字面上不是父空间。不归一会导致「团队正在跑」被当成别的空间 —— 自动模式判空闲、并行开跑,
103
+ // 而它们其实在同一份代码上动。默认空间(~/.magic-agent/team/<run>)同理,归到该目录。
104
+ func normalizeTeamSpace(key string) string {
105
+ if key == "" {
106
+ return ""
107
+ }
108
+ sep := string(filepath.Separator)
109
+ if i := strings.Index(key, sep+".magic"+sep+"team"+sep); i > 0 {
110
+ return key[:i]
111
+ }
112
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
113
+ base := filepath.Join(home, ".magic-agent", "team")
114
+ if key == base || strings.HasPrefix(key, base+sep) {
115
+ return base
116
+ }
117
+ }
118
+ return key
119
+ }
120
+
121
+ // sameSpace 两个比较键是否同一个空间。Windows 上盘符大小写不敏感(d:\ws 与 D:\ws 是同一处),
122
+ // 其余平台保持精确比较(Linux 上 Foo 与 foo 是两个目录)。
123
+ func sameSpace(a, b string) bool {
124
+ if a == b {
125
+ return true
126
+ }
127
+ if runtime.GOOS == "windows" {
128
+ return strings.EqualFold(a, b)
129
+ }
130
+ return false
131
+ }
132
+
133
+ // captureBusy 抓同空间的外部占用(锁 + 登记表)。锁外调用。
134
+ func (q *jobQueue) captureBusy(ws string) busySnapshot {
135
+ var snap busySnapshot
136
+ key := spaceKeyOf(ws)
137
+ if key == "" {
138
+ return snap
139
+ }
140
+ /* ① 工作空间锁:core 是「先抢锁、再写 running」,Finish 才释放 ——
141
+ 被活进程持有 = 此刻**确有一轮在写**。这是最精确的信号,也天然排除 idle
142
+ (常驻会话本轮结束就放锁了)。 */
143
+ if h, ok := session.LockHolderFor(ws); ok && h.PID > 0 && agent.PIDAlive(h.PID) {
144
+ snap.lockHeld = true
145
+ }
146
+ /* ② 登记表:CLI 直接跑的、别的客户端的、团队主/子任务。
147
+ List() 读侧已把死进程归成 gone,所以这里看到的 running 一定是活的。
148
+ idle 不算:本轮已结束、只是进程挂着等追加,不占文件。 */
149
+ recs, err := session.List()
150
+ if err != nil {
151
+ return snap
152
+ }
153
+ for _, r := range recs {
154
+ if r.State != session.StateRunning && r.State != session.StateWaitingApproval {
155
+ continue
156
+ }
157
+ w := strings.TrimSpace(r.Workspace)
158
+ if w == "" {
159
+ w = strings.TrimSpace(r.Cwd)
160
+ }
161
+ if w == "" {
162
+ continue
163
+ }
164
+ if !sameSpace(spaceKeyOf(w), key) {
165
+ continue
166
+ }
167
+ id := strings.TrimSpace(r.SessionID)
168
+ if id == "" {
169
+ id = strings.TrimSpace(r.RunID)
170
+ }
171
+ if id == "" {
172
+ continue
173
+ }
174
+ /* 标题:core 归纳过的那份优先(与列表页同一口径),没有就回落首问。 */
175
+ title := strings.TrimSpace(session.TitleFor(id))
176
+ if title == "" {
177
+ title = strings.TrimSpace(r.PromptHead)
178
+ }
179
+ snap.cands = append(snap.cands, busyCand{
180
+ ID: id,
181
+ SessionID: strings.TrimSpace(r.SessionID),
182
+ RunID: strings.TrimSpace(r.RunID),
183
+ Title: title,
184
+ Workspace: w,
185
+ State: r.State,
186
+ StartedAt: r.StartedAt,
187
+ })
188
+ }
189
+ sort.Slice(snap.cands, func(i, j int) bool { return snap.cands[i].StartedAt.Before(snap.cands[j].StartedAt) })
190
+ if len(snap.cands) > judge.MaxCandidates {
191
+ snap.cands = snap.cands[:judge.MaxCandidates]
192
+ }
193
+ return snap
194
+ }
195
+
196
+ // queuedCand 同空间**已受理但还没开跑**的作业(queued / judging)。
197
+ //
198
+ // 为什么要把它也算进冲突比较(2026-10-26):用户连发两条「改底部栏」时,第一条走
199
+ // 「空间空闲」快速通道判并行、第二条也走同一条通道 —— 两条都不看对方,就会同时去改
200
+ // 同一个文件。它们没有会话日志(还没跑),但**提示词本身就是意图**,足够模型判断。
201
+ type queuedCand struct {
202
+ ID string
203
+ Prompt string
204
+ Session string
205
+ }
206
+
207
+ // queuedPeersLocked 抓同空间还在排队的作业(调用方持 q.mu)。
208
+ func (q *jobQueue) queuedPeersLocked(ws, selfID string) []queuedCand {
209
+ key := spaceKeyOf(ws)
210
+ var out []queuedCand
211
+ for _, j := range q.jobs {
212
+ if j == nil || j.ID == selfID || j.Workspace == "" {
213
+ continue
214
+ }
215
+ if j.State != JobQueued && j.State != JobJudging {
216
+ continue
217
+ }
218
+ if !sameSpace(spaceKeyOf(j.Workspace), key) {
219
+ continue
220
+ }
221
+ out = append(out, queuedCand{ID: j.ID, Prompt: strings.TrimSpace(j.Prompt), Session: strings.TrimSpace(j.Session)})
222
+ if len(out) >= judge.MaxCandidates {
223
+ break
224
+ }
225
+ }
226
+ return out
227
+ }
228
+
229
+ /*
230
+ autoRuleLocked 锁内规则判定。只碰内存(q.jobs)与传进来的快照,不做任何 I/O。
231
+
232
+ 规则优先级(命中即定):
233
+ ① 同一会话还在跑 → 必串(两个进程会同时写同一份会话日志,与「事」无关,是硬约束);
234
+ ② 同空间没有任何运行中会话、队列里也没有在跑的作业 → 并行(零成本快速通道,
235
+ 覆盖绝大多数提交:空间空闲时根本不需要问模型);
236
+ ③ 否则规则判不出来 → 交给语义判定。
237
+ */
238
+ func (q *jobQueue) autoRuleLocked(ws string, req askRequest, snap busySnapshot, peers []queuedCand) autoRule {
239
+ key := spaceKeyOf(ws)
240
+ sid := strings.TrimSpace(req.Session)
241
+ queueRunning := false
242
+ for _, j := range q.jobs {
243
+ if j == nil || j.Workspace == "" || !sameSpace(spaceKeyOf(j.Workspace), key) {
244
+ continue
245
+ }
246
+ if sid != "" && strings.TrimSpace(j.Session) == sid && (j.State == JobQueued || j.State == JobRunning || j.State == JobJudging) {
247
+ return autoRule{decided: true, serial: true, by: "rule",
248
+ reason: "同一条会话还有一轮没结束"}
249
+ }
250
+ if j.State == JobRunning {
251
+ queueRunning = true
252
+ }
253
+ }
254
+ if len(snap.cands) == 0 && len(peers) == 0 && !snap.lockHeld && !queueRunning {
255
+ return autoRule{decided: true, serial: false, by: "rule",
256
+ reason: "同空间没有正在运行的任务"}
257
+ }
258
+ return autoRule{}
259
+ }
260
+
261
+ // autoVerdict 异步判定的结果。
262
+ type autoVerdict struct {
263
+ serial bool
264
+ reason string
265
+ level string
266
+ overlaps []string
267
+ by string // rule | model | undecided
268
+ }
269
+
270
+ // startAutoJudge 起一个 goroutine 做语义判定;无论成败都会把作业从 judging 送到 queued。
271
+ func (q *jobQueue) startAutoJudge(jobID, ws string, req askRequest, snap busySnapshot, peers []queuedCand) {
272
+ go func() {
273
+ v := autoVerdict{serial: false, reason: "未判定,按并行放行", level: "none", by: "undecided"}
274
+ select {
275
+ case autoJudgeSlots <- struct{}{}:
276
+ defer func() { <-autoJudgeSlots }()
277
+ v = q.judgeNow(ws, req, snap, peers)
278
+ default:
279
+ // 已经有两条在判:不排队等(判定绝不能拖慢提交),直接按并行放行。
280
+ }
281
+ q.finishJudge(jobID, v)
282
+ }()
283
+ }
284
+
285
+ // finishJudge 把判定结论写回作业并放回 queued(由 dispatch 正常准入)。
286
+ //
287
+ // ⚠️ 只动**还在 judging** 的作业:判定期间用户可能已经取消它(state=cancelled),
288
+ // 那种情况绝不能改回 queued —— 否则取消掉的任务会复活。
289
+ func (q *jobQueue) finishJudge(jobID string, v autoVerdict) {
290
+ q.mu.Lock()
291
+ j := q.jobs[jobID]
292
+ if j == nil || j.State != JobJudging {
293
+ q.mu.Unlock()
294
+ return
295
+ }
296
+ now := time.Now()
297
+ j.Serial = v.serial
298
+ j.Auto = &JobAuto{
299
+ Conflict: v.serial,
300
+ Level: v.level,
301
+ Reason: strings.TrimSpace(v.reason),
302
+ Overlaps: v.overlaps,
303
+ By: v.by,
304
+ At: now,
305
+ }
306
+ j.State = JobQueued
307
+ j.UpdatedAt = now
308
+ q.persistLocked(j)
309
+ q.recomputePositionsLocked()
310
+ q.mu.Unlock()
311
+ q.signal()
312
+ }
313
+
314
+ // judgeNow 真正干活:抽候选摘要 → 文件交集规则 → 建议引擎快速判定。
315
+ func (q *jobQueue) judgeNow(ws string, req askRequest, snap busySnapshot, peers []queuedCand) autoVerdict {
316
+ deadline := time.Now().Add(autoJudgeBudget)
317
+ dir, _ := session.DefaultLogDir()
318
+
319
+ cands := make([]judge.Candidate, 0, len(snap.cands)+len(peers))
320
+ for _, c := range snap.cands {
321
+ if time.Now().After(deadline) {
322
+ break
323
+ }
324
+ title, recent, files := digestOf(dir, c)
325
+ cands = append(cands, judge.Candidate{ID: c.ID, Title: title, Recent: recent, Files: files})
326
+ }
327
+ /* 还没开跑的排队作业:没有日志,但提示词就是它的意图(见 queuedCand 的注释)。 */
328
+ for _, p := range peers {
329
+ if strings.TrimSpace(p.Prompt) == "" {
330
+ continue
331
+ }
332
+ cands = append(cands, judge.Candidate{ID: p.ID, Title: clipText(p.Prompt, 40), Recent: clipText(p.Prompt, 200)})
333
+ }
334
+
335
+ /* 规则第二段:两边的**已改文件**有交集 → 直接判冲突,不花模型的钱。
336
+ 「本轮要改什么文件」还没发生,只能从提示词里认出显式写出来的路径
337
+ (用户常写「改 client-ui-mobile/index.html 的底部栏」)。 */
338
+ if reason, overlaps := promptFileOverlap(req.Prompt, cands); len(overlaps) > 0 {
339
+ return autoVerdict{serial: true, reason: reason, level: "hard", overlaps: overlaps, by: "rule"}
340
+ }
341
+
342
+ if q.svc == nil || strings.TrimSpace(q.svc.opts.MagicAgent) == "" {
343
+ return autoVerdict{serial: false, reason: "没有可用的 core,按并行放行", by: "undecided"}
344
+ }
345
+ ctx, cancel := context.WithTimeout(context.Background(), autoJudgeBudget)
346
+ defer cancel()
347
+ payload, _ := json.Marshal(judge.RequestBody{
348
+ Prompt: strings.TrimSpace(req.Prompt),
349
+ Candidates: cands,
350
+ Engine: strings.TrimSpace(req.JudgeEngine),
351
+ Model: strings.TrimSpace(req.JudgeModel),
352
+ })
353
+ cmd := newCoreCmd(ctx, q.svc.opts.MagicAgent, "--judge-conflict", "-")
354
+ cmd.Stdin = bytes.NewReader(payload)
355
+ var stdout, stderr bytes.Buffer
356
+ cmd.Stdout = &stdout
357
+ cmd.Stderr = &stderr
358
+ if err := cmd.Run(); err != nil {
359
+ /* 超时单独说清楚:内核报的是 "signal: killed",界面上显示这个等于没说。 */
360
+ if ctx.Err() == context.DeadlineExceeded {
361
+ return autoVerdict{serial: false, reason: "判定超时,按并行放行", by: "undecided"}
362
+ }
363
+ why := strings.TrimSpace(stderr.String())
364
+ if why == "" {
365
+ why = err.Error()
366
+ }
367
+ return autoVerdict{serial: false, reason: "判定失败,按并行放行:" + clipText(why, 80), by: "undecided"}
368
+ }
369
+ var res judge.Result
370
+ if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
371
+ return autoVerdict{serial: false, reason: "判定结果解析失败,按并行放行", by: "undecided"}
372
+ }
373
+ reason := strings.TrimSpace(res.Reason)
374
+ if reason == "" {
375
+ reason = "模型未给出理由"
376
+ }
377
+ level := res.Level
378
+ if level == "" {
379
+ level = "none"
380
+ }
381
+ return autoVerdict{serial: res.Conflict, reason: reason, level: level, overlaps: res.Overlaps, by: "model"}
382
+ }
383
+
384
+ // digestOf 从一个运行中会话的日志里抽「标题 / 近况 / 已改文件」。
385
+ //
386
+ // 只读尾部 400 条(Lean 模式):判定要的是「最近在干什么」,不是完整历史。
387
+ // 读不到日志不是错误(会话可能刚起还没落盘):返回空摘要,让模型按标题判。
388
+ func digestOf(dir string, c busyCand) (title, recent string, files []string) {
389
+ title = strings.TrimSpace(c.Title)
390
+ if dir == "" {
391
+ return title, "", nil
392
+ }
393
+ /* 隔离环境(测试 / 多实例)下登记表走 MAGIC_AGENT_SESSIONS、日志却仍按 HOME 落 ——
394
+ 两边不同目录时这里找不到该会话,摘要会读成空(判定只剩标题可用)。
395
+ 回退到登记表目录:core 写日志与写登记用的是同一个 id,放在一起就能读到。 */
396
+ if session.ResolveLogID(dir, c.ID) == "" {
397
+ if alt := session.Dir(); alt != "" && alt != dir && session.ResolveLogID(alt, c.ID) != "" {
398
+ dir = alt
399
+ }
400
+ }
401
+ logID := session.ResolveLogID(dir, c.ID)
402
+ if logID == "" {
403
+ logID = c.ID
404
+ }
405
+ /* alignTo 传 nil:对齐会把页首切到最后一条正文,**把更早的文件改动丢掉** ——
406
+ 而文件清单正是判定最硬的证据。这里要连续的一段历史,不是「一轮的起点」。 */
407
+ evs, _, _, err := session.ReadSessionLogBeforeLean(dir, logID, 0, autoDigestEvents, nil, sessionEventMapFromJSON)
408
+ if err != nil || len(evs) == 0 {
409
+ return title, "", nil
410
+ }
411
+ var texts []string
412
+ seen := map[string]bool{}
413
+ for _, ev := range evs {
414
+ switch eventType(ev) {
415
+ case "text":
416
+ if t := strings.TrimSpace(strField(ev, "text")); t != "" {
417
+ texts = append(texts, t)
418
+ }
419
+ case "tool_use":
420
+ if fileKey, ok := ev["tool_kind"]; !ok || strOf(fileKey) != "file_change" {
421
+ continue
422
+ }
423
+ for _, p := range pathsInToolInput(strField(ev, "text")) {
424
+ if seen[p] {
425
+ continue
426
+ }
427
+ seen[p] = true
428
+ files = append(files, p)
429
+ }
430
+ }
431
+ }
432
+ /* 近况取最后两段正文(越靠后越新),压成一行;文件取最后 N 个(最近的改动)。 */
433
+ if n := len(texts); n > 0 {
434
+ tail := texts
435
+ if n > 2 {
436
+ tail = texts[n-2:]
437
+ }
438
+ recent = clipText(strings.Join(tail, " "), autoRecentRunes)
439
+ }
440
+ if len(files) > autoDigestFiles {
441
+ files = files[len(files)-autoDigestFiles:]
442
+ }
443
+ return title, recent, files
444
+ }
445
+
446
+ // promptFileOverlap 「本轮提示词里显式写出的路径」∩「候选已改文件」。
447
+ //
448
+ // 只是提示词里的路径,不是真正的改动计划 —— 所以只在**精确命中**(文件名或路径后缀一致)
449
+ // 时判冲突,避免把「提到 index.html」硬说成冲突。
450
+ func promptFileOverlap(prompt string, cands []judge.Candidate) (string, []string) {
451
+ if strings.TrimSpace(prompt) == "" {
452
+ return "", nil
453
+ }
454
+ p := strings.ReplaceAll(prompt, "\\", "/")
455
+ var overlaps []string
456
+ for _, c := range cands {
457
+ for _, f := range c.Files {
458
+ ff := strings.TrimSpace(strings.ReplaceAll(f, "\\", "/"))
459
+ if ff == "" {
460
+ continue
461
+ }
462
+ // 命中的两种形态:完整路径出现在提示词里;或文件名(末段)出现且带扩展名。
463
+ hit := strings.Contains(p, ff)
464
+ if !hit {
465
+ base := filepath.Base(ff)
466
+ if strings.Contains(base, ".") && len([]rune(base)) >= 6 && strings.Contains(p, base) {
467
+ hit = true
468
+ }
469
+ }
470
+ if hit {
471
+ overlaps = append(overlaps, ff)
472
+ if len(overlaps) >= 6 {
473
+ break
474
+ }
475
+ }
476
+ }
477
+ if len(overlaps) >= 6 {
478
+ break
479
+ }
480
+ }
481
+ if len(overlaps) == 0 {
482
+ return "", nil
483
+ }
484
+ return "本轮和运行中的任务都要改 " + strings.Join(overlaps, "、"), overlaps
485
+ }
486
+
487
+ // pathsInToolInput 从工具参数 JSON 里抽文件路径(各引擎的键名不一致)。
488
+ func pathsInToolInput(text string) []string {
489
+ text = strings.TrimSpace(text)
490
+ if text == "" {
491
+ return nil
492
+ }
493
+ var m map[string]any
494
+ if err := json.Unmarshal([]byte(text), &m); err != nil {
495
+ return nil
496
+ }
497
+ var out []string
498
+ add := func(v any) {
499
+ s := strings.TrimSpace(strOf(v))
500
+ if s == "" || seenIn(out, s) {
501
+ return
502
+ }
503
+ out = append(out, s)
504
+ }
505
+ for _, k := range []string{"file_path", "path", "notebook_path", "filename", "file"} {
506
+ if v, ok := m[k]; ok {
507
+ add(v)
508
+ }
509
+ }
510
+ if len(out) == 0 {
511
+ /* 认不出键名时扫一遍值:看起来像路径的才收(含分隔符或以盘符开头),
512
+ 避免把「一句话」当成文件名塞进提示词。 */
513
+ for _, v := range m {
514
+ s := strings.TrimSpace(strOf(v))
515
+ if s == "" {
516
+ continue
517
+ }
518
+ /* 带空白的多半是一句描述("*** Update File: /a/b.md"),不是路径 ——
519
+ 收进来只会把整句话塞进提示词,反而干扰判定。 */
520
+ if strings.ContainsAny(s, " \t\r\n") {
521
+ continue
522
+ }
523
+ if strings.ContainsAny(s, "/\\") || filepath.IsAbs(s) {
524
+ add(s)
525
+ }
526
+ }
527
+ }
528
+ return out
529
+ }
530
+
531
+ func seenIn(list []string, want string) bool {
532
+ for _, v := range list {
533
+ if v == want {
534
+ return true
535
+ }
536
+ }
537
+ return false
538
+ }
539
+
540
+ func eventType(m map[string]any) string { return strings.TrimSpace(strOf(m["type"])) }
541
+
542
+ func strField(m map[string]any, k string) string {
543
+ if v, ok := m[k]; ok {
544
+ return strOf(v)
545
+ }
546
+ return ""
547
+ }
548
+
549
+ func strOf(v any) string {
550
+ switch t := v.(type) {
551
+ case string:
552
+ return t
553
+ case nil:
554
+ return ""
555
+ default:
556
+ b, err := json.Marshal(t)
557
+ if err != nil {
558
+ return ""
559
+ }
560
+ return string(b)
561
+ }
562
+ }
563
+
564
+ // clipText 按 rune 截断(中文不能按字节切)。
565
+ func clipText(s string, max int) string {
566
+ r := []rune(strings.TrimSpace(s))
567
+ if max <= 0 || len(r) <= max {
568
+ return string(r)
569
+ }
570
+ return string(r[:max])
571
+ }