magic-agent 2.6.0 → 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 +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -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/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/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 +209 -18
- 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 +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- 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/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -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/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/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -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/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- 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 +8 -3
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"os"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
/* /desk/health 必须交代「我这一代进程是谁」(2026-10-27)。
|
|
13
|
+
*
|
|
14
|
+
* 为什么值得一条测试:重启换的是**进程**,会话 / 任务 / 设置全在盘上,
|
|
15
|
+
* 界面重启前后长得一模一样。用户点了「重启服务」看见列表没变,只会说「没效果」
|
|
16
|
+
* —— 这正是当天真机反馈的原话。而 `instanceId` 帮不上忙:它是**同一台桌面**的稳定
|
|
17
|
+
* 身份(多入口归一靠它),跨重启必须不变,所以不能用它判断新旧代。
|
|
18
|
+
*
|
|
19
|
+
* 判据只能是 `pid` + `startedAt` 这一对。这里钉住三件事:
|
|
20
|
+
* 1. 两个字段都得在(客户端靠它们证明换代);
|
|
21
|
+
* 2. startedAt 是可解析的 RFC3339(客户端做字符串比较,格式漂了就会误判);
|
|
22
|
+
* 3. pid 就是本进程(不是某个常量、也不是状态文件里的旧值)。
|
|
23
|
+
*/
|
|
24
|
+
func TestHealthReportsProcessIdentity(t *testing.T) {
|
|
25
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
26
|
+
srv := httptest.NewServer(svc.Handler())
|
|
27
|
+
defer srv.Close()
|
|
28
|
+
|
|
29
|
+
read := func() map[string]any {
|
|
30
|
+
t.Helper()
|
|
31
|
+
req, err := http.NewRequest(http.MethodGet, srv.URL+"/desk/health", nil)
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
req.Header.Set("X-Magic-Token", "tok")
|
|
36
|
+
res, err := http.DefaultClient.Do(req)
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
defer res.Body.Close()
|
|
41
|
+
if res.StatusCode != http.StatusOK {
|
|
42
|
+
t.Fatalf("期望 200,得到 %d", res.StatusCode)
|
|
43
|
+
}
|
|
44
|
+
var got map[string]any
|
|
45
|
+
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
return got
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
got := read()
|
|
52
|
+
|
|
53
|
+
pid, ok := got["pid"].(float64) // JSON 数字统一解成 float64
|
|
54
|
+
if !ok {
|
|
55
|
+
t.Fatalf("health 必须带 pid(客户端靠它证明换代),实际没有或类型不对:%#v", got["pid"])
|
|
56
|
+
}
|
|
57
|
+
if int(pid) != os.Getpid() {
|
|
58
|
+
t.Fatalf("pid 必须是本进程 %d,得到 %v", os.Getpid(), pid)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
raw, ok := got["startedAt"].(string)
|
|
62
|
+
if !ok || raw == "" {
|
|
63
|
+
t.Fatalf("health 必须带 startedAt(RFC3339),实际 %#v", got["startedAt"])
|
|
64
|
+
}
|
|
65
|
+
ts, err := time.Parse(time.RFC3339, raw)
|
|
66
|
+
if err != nil {
|
|
67
|
+
t.Fatalf("startedAt 必须是可解析的 RFC3339(客户端做字符串比较),得到 %q:%v", raw, err)
|
|
68
|
+
}
|
|
69
|
+
/* 启动时刻不该在将来,也不该早得离谱 —— 后者通常意味着拿了包级的零值/编译常量。 */
|
|
70
|
+
if time.Since(ts) < 0 {
|
|
71
|
+
t.Fatalf("startedAt 落在将来:%v", ts)
|
|
72
|
+
}
|
|
73
|
+
if time.Since(ts) > time.Hour {
|
|
74
|
+
t.Fatalf("startedAt 早得不合理(%v 之前):像是没在 New() 里赋值", ts)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* 同一个进程连着问两次,pid 与 startedAt 都必须一模一样 ——
|
|
78
|
+
否则客户端会把「还没重启」误判成「换代了」。 */
|
|
79
|
+
again := read()
|
|
80
|
+
if again["pid"] != got["pid"] || again["startedAt"] != got["startedAt"] {
|
|
81
|
+
t.Fatalf("同一进程两次应答的身份必须一致:%v/%v vs %v/%v",
|
|
82
|
+
got["pid"], got["startedAt"], again["pid"], again["startedAt"])
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// hostenv.go - 给 core 子进程的准备环境。
|
|
4
|
+
//
|
|
5
|
+
// 为什么在客户端做而不是 core 里做:core 是**被客户端 exec 起来的**,它继承什么
|
|
6
|
+
// 完全由这里决定。宿主 IDE(WorkBuddy)的终端会给客户端塞三样东西:
|
|
7
|
+
//
|
|
8
|
+
// NODE_OPTIONS=--require=".../app.asar.unpacked/.../node-language-shim.cjs"
|
|
9
|
+
// ELECTRON_RUN_AS_NODE=1
|
|
10
|
+
// PATH=<...>/shim/brokered-bin:<...> ← 假 cat/cp/dd,require 时去连宿主 broker
|
|
11
|
+
//
|
|
12
|
+
// 后果(2026-10-26 真机实测):引擎的 node 子进程加载宿主 shim → 连不上 broker →
|
|
13
|
+
// `connect failed: Connection refused` → 每条任务 failed,错误看着像引擎坏了。
|
|
14
|
+
// 在 core 一启动就把这三样摘干净,比让每个引擎各自防御可靠。
|
|
15
|
+
//
|
|
16
|
+
// 判据只认 `.asar` / `workbuddy` / `brokered-bin` 这几个特征:用户自己设的
|
|
17
|
+
// NODE_OPTIONS(--max-old-space-size 之类)与正常 PATH 段原样保留。
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"os"
|
|
21
|
+
"strings"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// hostShimValue 判断一个值是否指向宿主 Electron 应用的垫片。
|
|
25
|
+
func hostShimValue(v string) bool {
|
|
26
|
+
l := strings.ToLower(v)
|
|
27
|
+
return strings.Contains(l, ".asar") || strings.Contains(l, "workbuddy") || strings.Contains(l, "brokered-bin")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// cleanHostEnv 返回一份可以交给 core 的环境:摘掉宿主垫片变量与假命令目录。
|
|
31
|
+
func cleanHostEnv(env []string) []string {
|
|
32
|
+
out := make([]string, 0, len(env))
|
|
33
|
+
for _, kv := range env {
|
|
34
|
+
name, val, ok := strings.Cut(kv, "=")
|
|
35
|
+
if !ok {
|
|
36
|
+
out = append(out, kv)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
switch name {
|
|
40
|
+
case "ELECTRON_RUN_AS_NODE":
|
|
41
|
+
continue
|
|
42
|
+
case "NODE_OPTIONS":
|
|
43
|
+
if hostShimValue(val) {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
case "PATH":
|
|
47
|
+
if cleaned := stripHostShimPath(val); cleaned != val {
|
|
48
|
+
out = append(out, "PATH="+cleaned)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
out = append(out, kv)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// stripHostShimPath 从 PATH 值里摘掉宿主垫片目录,其余段与顺序不动。
|
|
58
|
+
func stripHostShimPath(val string) string {
|
|
59
|
+
parts := strings.Split(val, string(os.PathListSeparator))
|
|
60
|
+
kept := parts[:0]
|
|
61
|
+
for _, p := range parts {
|
|
62
|
+
if p == "" || hostShimValue(p) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
kept = append(kept, p)
|
|
66
|
+
}
|
|
67
|
+
return strings.Join(kept, string(os.PathListSeparator))
|
|
68
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// 2026-10-26 真机:宿主 IDE 的终端把这三个变量塞进客户端,引擎子进程全崩在 require。
|
|
9
|
+
func TestCleanHostEnvDropsShimVars(t *testing.T) {
|
|
10
|
+
in := []string{
|
|
11
|
+
`NODE_OPTIONS=--require="/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/vendor/shim/node-language-shim.cjs"`,
|
|
12
|
+
"ELECTRON_RUN_AS_NODE=1",
|
|
13
|
+
"PATH=/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/vendor/shim/brokered-bin:/opt/homebrew/bin:/usr/bin",
|
|
14
|
+
"HOME=/Users/x",
|
|
15
|
+
}
|
|
16
|
+
out := cleanHostEnv(in)
|
|
17
|
+
joined := strings.Join(out, "\n")
|
|
18
|
+
for _, bad := range []string{"ELECTRON_RUN_AS_NODE", ".asar", "brokered-bin"} {
|
|
19
|
+
if strings.Contains(joined, bad) {
|
|
20
|
+
t.Fatalf("还留着 %s:\n%s", bad, joined)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if !strings.Contains(joined, "PATH=/opt/homebrew/bin:/usr/bin") {
|
|
24
|
+
t.Fatalf("PATH 被误改:\n%s", joined)
|
|
25
|
+
}
|
|
26
|
+
if !strings.Contains(joined, "HOME=/Users/x") {
|
|
27
|
+
t.Fatal("无关变量被删了")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 用户自己设的 NODE_OPTIONS 要留着。
|
|
32
|
+
func TestCleanHostEnvKeepsUserNodeOptions(t *testing.T) {
|
|
33
|
+
out := cleanHostEnv([]string{"NODE_OPTIONS=--max-old-space-size=4096"})
|
|
34
|
+
if len(out) != 1 || out[0] != "NODE_OPTIONS=--max-old-space-size=4096" {
|
|
35
|
+
t.Fatalf("误删:%v", out)
|
|
36
|
+
}
|
|
37
|
+
}
|
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
|
|
@@ -86,6 +90,12 @@ type Job struct {
|
|
|
86
90
|
|
|
87
91
|
// Serial 是否参与 workspace 单槽串行(默认 true)。
|
|
88
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"`
|
|
89
99
|
|
|
90
100
|
State string `json:"state"`
|
|
91
101
|
Position int `json:"position,omitempty"`
|
|
@@ -120,6 +130,25 @@ func (j *Job) terminal() bool {
|
|
|
120
130
|
return false
|
|
121
131
|
}
|
|
122
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
|
+
|
|
123
152
|
// askRequest 把作业还原成 core 启动参数需要的请求(不带上 RequestID:那是队列自己的事)。
|
|
124
153
|
func (j *Job) askRequest() askRequest {
|
|
125
154
|
req := askRequest{
|
|
@@ -139,9 +168,10 @@ func (j *Job) askRequest() askRequest {
|
|
|
139
168
|
FallbackEngine: j.FallbackEngine,
|
|
140
169
|
FallbackModel: j.FallbackModel,
|
|
141
170
|
}
|
|
142
|
-
/* ⚠️ 必须回填 Serial:startAsk
|
|
143
|
-
(见 service.go 的 EnvWorkspaceLock
|
|
144
|
-
并行作业在 core 的 Begin
|
|
171
|
+
/* ⚠️ 必须回填 Serial:startAsk 按它决定要不要给 core **关掉**工作空间锁
|
|
172
|
+
(见 service.go 的 EnvWorkspaceLock;2026-10-26 起锁默认生效)。漏掉 →
|
|
173
|
+
req.Serial == nil → 按「串行」处理 → 并行作业在 core 的 Begin 处又重新串起来,
|
|
174
|
+
把「并行」架空(切并行后看着没生效)。 */
|
|
145
175
|
serial := j.Serial
|
|
146
176
|
req.Serial = &serial
|
|
147
177
|
if j.ExplicitWorkspace {
|
|
@@ -351,6 +381,21 @@ func (q *jobQueue) load() {
|
|
|
351
381
|
if err := json.Unmarshal(b, &j); err != nil || strings.TrimSpace(j.ID) == "" {
|
|
352
382
|
continue
|
|
353
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
|
+
}
|
|
354
399
|
switch j.State {
|
|
355
400
|
case JobRunning:
|
|
356
401
|
/* 服务重启时还在 running:core 是 NoCancel 起的,可能仍在跑。
|
|
@@ -429,12 +474,16 @@ func (q *jobQueue) pruneOrphanPlaceholdersLocked() {
|
|
|
429
474
|
if q == nil || q.dir == "" || q.svc == nil {
|
|
430
475
|
return
|
|
431
476
|
}
|
|
432
|
-
|
|
477
|
+
/* runID → 作业状态。**不能只记 queued**:占位对应的作业一旦开跑 / 收尾,
|
|
478
|
+
占位就必须让位(正常由 syncQueuedSessionLocked 在状态切换时删;但多实例共用
|
|
479
|
+
一个作业目录、或那次落盘失败时,这步会漏 —— 现场就是 job 已 done、占位还在)。 */
|
|
480
|
+
known := map[string]string{}
|
|
433
481
|
for _, j := range q.jobs {
|
|
434
|
-
if j
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
482
|
+
if j == nil {
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if id := queuedSessionID(j); id != "" {
|
|
486
|
+
known[id] = j.State
|
|
438
487
|
}
|
|
439
488
|
}
|
|
440
489
|
recs, err := session.List()
|
|
@@ -444,9 +493,19 @@ func (q *jobQueue) pruneOrphanPlaceholdersLocked() {
|
|
|
444
493
|
ours := q.svc.opts.Profile.AppID()
|
|
445
494
|
cutoff := time.Now().Add(-orphanPlaceholderGrace)
|
|
446
495
|
for _, r := range recs {
|
|
447
|
-
if r.State != session.StateQueued || r.Client != ours
|
|
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)
|
|
448
505
|
continue
|
|
449
506
|
}
|
|
507
|
+
/* 认不出对应作业(作业文件被 prune / 另一个实例的目录 / 换过作业目录):
|
|
508
|
+
给「占位已写、作业还没落盘」那个窗口留宽限,之后才清。 */
|
|
450
509
|
if r.UpdatedAt.After(cutoff) || r.StartedAt.After(cutoff) {
|
|
451
510
|
continue
|
|
452
511
|
}
|
|
@@ -459,7 +518,7 @@ func (q *jobQueue) pruneOrphanPlaceholdersLocked() {
|
|
|
459
518
|
func normalizeWorkspace(ws string) string {
|
|
460
519
|
ws = strings.TrimSpace(ws)
|
|
461
520
|
if ws == "" {
|
|
462
|
-
ws
|
|
521
|
+
ws = defaultWorkspace()
|
|
463
522
|
}
|
|
464
523
|
if abs, err := filepath.Abs(ws); err == nil {
|
|
465
524
|
ws = abs
|
|
@@ -467,11 +526,100 @@ func normalizeWorkspace(ws string) string {
|
|
|
467
526
|
return filepath.Clean(ws)
|
|
468
527
|
}
|
|
469
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
|
+
|
|
470
597
|
// submitAsk 入队(或按 request_id 接回原作业)。created=false 表示命中幂等。
|
|
471
598
|
// ⚠️ 返回的是**副本**:调度器随时会把状态改成 running,直接把内部指针交出去
|
|
472
599
|
// 会让 HTTP 响应一边 marshal 一边被改(数据竞争,且客户端可能先看到 running)。
|
|
473
600
|
func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool, error) {
|
|
474
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
|
+
}
|
|
475
623
|
q.mu.Lock()
|
|
476
624
|
if reqID != "" {
|
|
477
625
|
if id, ok := q.byReq[reqID]; ok {
|
|
@@ -501,6 +649,36 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
|
|
|
501
649
|
if req.Serial != nil {
|
|
502
650
|
serial = *req.Serial
|
|
503
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
|
+
}
|
|
504
682
|
now := time.Now()
|
|
505
683
|
j := &Job{
|
|
506
684
|
ID: q.nextJobIDLocked(),
|
|
@@ -521,7 +699,9 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
|
|
|
521
699
|
Attachments: append([]string(nil), req.Attachments...),
|
|
522
700
|
Team: req.Team,
|
|
523
701
|
Serial: serial,
|
|
524
|
-
|
|
702
|
+
Mode: mode,
|
|
703
|
+
Auto: auto,
|
|
704
|
+
State: state,
|
|
525
705
|
Owner: JobOwner{Client: strings.TrimSpace(req.Client), Source: source},
|
|
526
706
|
CreatedAt: now,
|
|
527
707
|
UpdatedAt: now,
|
|
@@ -536,6 +716,11 @@ func (q *jobQueue) submitAsk(req askRequest, engine, source string) (*Job, bool,
|
|
|
536
716
|
cp.Position = q.positionLocked(j)
|
|
537
717
|
q.persistLocked(j)
|
|
538
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
|
+
}
|
|
539
724
|
q.signal()
|
|
540
725
|
return &cp, true, nil
|
|
541
726
|
}
|
|
@@ -660,7 +845,7 @@ func (q *jobQueue) waitStarted(ctx contextLike, job *Job, onQueued func(pos int)
|
|
|
660
845
|
if run != nil {
|
|
661
846
|
return run, true
|
|
662
847
|
}
|
|
663
|
-
if state != JobQueued && state != JobRunning {
|
|
848
|
+
if state != JobQueued && state != JobRunning && state != JobJudging {
|
|
664
849
|
return nil, false
|
|
665
850
|
}
|
|
666
851
|
/* ⚠️ 空队列直通时**不能**先发 queued:老客户端按「第一帧是 open」解析,
|
|
@@ -690,6 +875,11 @@ type contextLike interface {
|
|
|
690
875
|
func (q *jobQueue) loop() {
|
|
691
876
|
tick := time.NewTicker(time.Second)
|
|
692
877
|
defer tick.Stop()
|
|
878
|
+
/* 幽灵占位的周期回收:只在启动时做一次的话,服务不重启就永远清不掉 ——
|
|
879
|
+
现场症状就是「作业早就 done,列表上那条还用紫色挂着『排队中』」
|
|
880
|
+
(2026-10-26 用户:「还是在排队」,占位文件的 mtime 停在受理那一秒)。
|
|
881
|
+
30 秒一次足够:这不是关键路径,多扫一遍目录也不该拖慢调度。 */
|
|
882
|
+
var sweeps int
|
|
693
883
|
for {
|
|
694
884
|
select {
|
|
695
885
|
case <-q.stop:
|
|
@@ -700,9 +890,17 @@ func (q *jobQueue) loop() {
|
|
|
700
890
|
q.leaseOwned = q.ensureLease()
|
|
701
891
|
q.reloadNew()
|
|
702
892
|
q.dispatch()
|
|
893
|
+
if sweeps++; sweeps%jobSweepEvery == 0 {
|
|
894
|
+
q.mu.Lock()
|
|
895
|
+
q.pruneOrphanPlaceholdersLocked()
|
|
896
|
+
q.mu.Unlock()
|
|
897
|
+
}
|
|
703
898
|
}
|
|
704
899
|
}
|
|
705
900
|
|
|
901
|
+
// jobSweepEvery 每多少个调度拍(1s/拍)扫一次幽灵占位,见 loop。
|
|
902
|
+
const jobSweepEvery = 30
|
|
903
|
+
|
|
706
904
|
/*
|
|
707
905
|
── 多实例租约 ──
|
|
708
906
|
|
|
@@ -849,6 +1047,16 @@ func (q *jobQueue) dispatch() {
|
|
|
849
1047
|
}
|
|
850
1048
|
return cands[i].CreatedAt.Before(cands[k].CreatedAt)
|
|
851
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
|
+
}
|
|
852
1060
|
var admitted []*Job
|
|
853
1061
|
for _, j := range cands {
|
|
854
1062
|
limit := q.max
|
|
@@ -868,8 +1076,12 @@ func (q *jobQueue) dispatch() {
|
|
|
868
1076
|
if q.active[j.Workspace] != "" {
|
|
869
1077
|
continue
|
|
870
1078
|
}
|
|
1079
|
+
if j.Mode == "auto" && busyAuto[j.Workspace] > 0 {
|
|
1080
|
+
continue
|
|
1081
|
+
}
|
|
871
1082
|
q.active[j.Workspace] = j.ID
|
|
872
1083
|
}
|
|
1084
|
+
busyAuto[j.Workspace]++
|
|
873
1085
|
j.State = JobRunning
|
|
874
1086
|
j.StartedAt = now
|
|
875
1087
|
j.UpdatedAt = now
|
|
@@ -926,8 +1138,14 @@ func (q *jobQueue) externalBusy(ws string, selfJob string) bool {
|
|
|
926
1138
|
}
|
|
927
1139
|
q.mu.Lock()
|
|
928
1140
|
selfPID := 0
|
|
1141
|
+
ignoreIdle := false
|
|
929
1142
|
if j := q.jobs[selfJob]; j != nil {
|
|
930
1143
|
selfPID = j.PID
|
|
1144
|
+
/* 自动模式判出来的「串行」,唯一理由是**与运行中的任务冲突** —— 冲突对象只可能是
|
|
1145
|
+
running / waiting_approval。idle 的常驻会话本轮已经结束、不写文件,还把它当占用,
|
|
1146
|
+
一条自动串行作业会被同空间一个闲聊着的会话永久挡住(这正是老 externalBusy 的毛病)。
|
|
1147
|
+
明确的「串行」模式保持原样(那里 idle 也算占用的保守口径不变)。 */
|
|
1148
|
+
ignoreIdle = j.Mode == "auto"
|
|
931
1149
|
}
|
|
932
1150
|
q.mu.Unlock()
|
|
933
1151
|
target := normalizeWorkspace(ws)
|
|
@@ -938,6 +1156,9 @@ func (q *jobQueue) externalBusy(ws string, selfJob string) bool {
|
|
|
938
1156
|
if r.State != session.StateRunning && r.State != session.StateWaitingApproval && r.State != session.StateIdle {
|
|
939
1157
|
continue
|
|
940
1158
|
}
|
|
1159
|
+
if ignoreIdle && r.State == session.StateIdle {
|
|
1160
|
+
continue
|
|
1161
|
+
}
|
|
941
1162
|
w := strings.TrimSpace(r.Workspace)
|
|
942
1163
|
if w == "" {
|
|
943
1164
|
w = strings.TrimSpace(r.Cwd)
|