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
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
/* 单实例互斥(2026-10-26 用户:「同时只启动1个」)。
|
|
4
|
+
*
|
|
5
|
+
* ── 为什么必须在这一层 ──
|
|
6
|
+
* 同机多份 magic-client 是**唯一一个会让用户看到「明明跑完了却没有输出」的状态**:
|
|
7
|
+
*
|
|
8
|
+
* · 它们共用同一个 ~/.magic-client/client.json 与固定端口名;
|
|
9
|
+
* · 但作业队列的事件回放缓冲(askRun)是**进程内存**里的;
|
|
10
|
+
* · 于是手机连 A 实例,任务被 B 实例执行 —— 作业文件两边都读得到(显示 done、
|
|
11
|
+
* 甚至能读出 session_id),正文却长在 B 的内存里,A 拿不到,界面只剩
|
|
12
|
+
* 「本轮没有收到任何输出(连接可能已断开)」。
|
|
13
|
+
*
|
|
14
|
+
* 更深一层的成因是「升级时二进制被替换」:老进程的可执行文件在磁盘上变成
|
|
15
|
+
* magic-client.stale(或直接被删),但它仍占着 9100/8977 与那条公网隧道。
|
|
16
|
+
* 用户以为自己在跑新版,实际连的是旧副本 —— 于是「版本已经处理了,还是不对」。
|
|
17
|
+
*
|
|
18
|
+
* ── 锁的粒度必须是「一份数据目录一把」,不是「一台机器一把」──
|
|
19
|
+
* 第一版写成了全机单锁,当场就把合法部署挡在门外:本机同时跑着
|
|
20
|
+
* ① magic-test 的移动端网关(8977,storage.dir=~/.magic-test,令牌独立);
|
|
21
|
+
* ② magic-agent 自己的实例(9100 或随机端口,DataDir=~/.magic-client/magic-agent)。
|
|
22
|
+
* 两者数据目录不同、作业目录不同、令牌不同,**互不干扰**,属于正常用法。
|
|
23
|
+
* 真正会互相踩的是「同一个 DataDir 起两份」—— 它们共享 jobs/ 与 client.json,
|
|
24
|
+
* 才会出现上面那个空回复。仓库里已有的多实例租约(jobs.go 的 scheduler.lock)
|
|
25
|
+
* 也是按 jobs 目录划的,粒度一致。
|
|
26
|
+
*
|
|
27
|
+
* ── 为什么用锁文件而不是「探测端口」──
|
|
28
|
+
* 端口只是**结果**:两份实例可以绑不同端口(--addr / 被占用后换端口),彼此
|
|
29
|
+
* 连探测都发现不了对方,但照样会各跑一套队列去抢同一个工作区。判据必须是
|
|
30
|
+
* 「这份数据目录上有没有另一个活着的 magic-client」,与端口无关。
|
|
31
|
+
*
|
|
32
|
+
* ── 语义 ──
|
|
33
|
+
* O_CREATE|O_EXCL 原子创建,内容写 pid + 启动时间 + 版本 + 端口(便于报错时指认)。
|
|
34
|
+
* 持有进程已死(pid 探活失败)或文件超过 staleAfter → 判定陈旧并回收。
|
|
35
|
+
* 拿不到锁时**不静默退出**:把占用者身份打清楚 —— 用户要立刻知道该去停谁。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import (
|
|
39
|
+
"encoding/json"
|
|
40
|
+
"fmt"
|
|
41
|
+
"os"
|
|
42
|
+
"path/filepath"
|
|
43
|
+
"strconv"
|
|
44
|
+
"strings"
|
|
45
|
+
"time"
|
|
46
|
+
|
|
47
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// EnvLockPath 覆盖单实例锁文件位置(多实例联调 / 测试用;线上不要设)。
|
|
51
|
+
const EnvLockPath = "MAGIC_CLIENT_LOCK"
|
|
52
|
+
|
|
53
|
+
// EnvAppIDLockDir 覆盖 AppID 占用登记的目录(测试用;线上不要设)。
|
|
54
|
+
const EnvAppIDLockDir = "MAGIC_CLIENT_APPID_LOCK_DIR"
|
|
55
|
+
|
|
56
|
+
const (
|
|
57
|
+
// singleInstanceStale 锁文件超过这么久就按陈旧处理。
|
|
58
|
+
// 与工作空间锁同一个量级:进程崩溃没能删锁时,不能让用户等太久才能重启。
|
|
59
|
+
singleInstanceStale = 6 * time.Hour
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// SingleInstanceHolder 锁文件的正文:占用者身份(报错 / 排障时指认用)。
|
|
63
|
+
type SingleInstanceHolder struct {
|
|
64
|
+
PID int `json:"pid"`
|
|
65
|
+
Port int `json:"port,omitempty"`
|
|
66
|
+
Version string `json:"version,omitempty"`
|
|
67
|
+
Exe string `json:"exe,omitempty"`
|
|
68
|
+
Cwd string `json:"cwd,omitempty"`
|
|
69
|
+
DataDir string `json:"dataDir,omitempty"`
|
|
70
|
+
AppID string `json:"appID,omitempty"`
|
|
71
|
+
Since time.Time `json:"since"`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// SingleInstanceLock 一把(或一组)已持有的单实例锁;Release 幂等。
|
|
75
|
+
//
|
|
76
|
+
// 允许一次持有多把:DataDir 锁 + AppID 登记是两道独立的闸门,任一道拿不到都不该起,
|
|
77
|
+
// 而拿到了就必须在退出时**一起**放掉 —— 只放一把会在下次启动留下「持有者已死」的
|
|
78
|
+
// 陈旧登记(虽然会被回收,但日志里会多一段困惑)。
|
|
79
|
+
type SingleInstanceLock struct {
|
|
80
|
+
paths []string
|
|
81
|
+
held bool
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// DefaultLockPath 单实例锁的默认位置:与**该 profile 的数据目录**放一起
|
|
85
|
+
// (<DataDir>/magic-client.lock)。
|
|
86
|
+
//
|
|
87
|
+
// 为什么跟 DataDir 走而不是 /tmp 或家目录根:/tmp 会被系统清理,锁文件凭空消失等于
|
|
88
|
+
// 互斥失效;而「同一份数据目录」正是互斥的真正边界 —— 不同 AppID(magic-test 与
|
|
89
|
+
// magic-agent)各自一把锁,能同时跑,互不误伤(这是 2026-10-26 第一版全机单锁的教训)。
|
|
90
|
+
func DefaultLockPath(dataDir string) string {
|
|
91
|
+
if p := strings.TrimSpace(os.Getenv(EnvLockPath)); p != "" {
|
|
92
|
+
return expandHome(p)
|
|
93
|
+
}
|
|
94
|
+
dir := strings.TrimSpace(dataDir)
|
|
95
|
+
if dir == "" {
|
|
96
|
+
dir = filepath.Dir(DefaultStatePath())
|
|
97
|
+
}
|
|
98
|
+
return filepath.Join(dir, "magic-client.lock")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ── AppID 级占用登记(2026-10-26 补,治 DataDir 漂移)──
|
|
102
|
+
|
|
103
|
+
光有 DataDir 锁还不够,实测踩到过:
|
|
104
|
+
同一个 magic-test 应用,一次启动算出 ~/.magic-test(profile 里写了 storage.dir),
|
|
105
|
+
另一次算出 ~/.magic-client/magic-test(profile 没读到,回落到 AppID 推导)。
|
|
106
|
+
两条路径各自一把锁,**两份都放行**,但作业目录不同 —— 手机连着 A、
|
|
107
|
+
任务在 B 里跑完,事件回放照样对不上,还是那句「没有收到任何输出」。
|
|
108
|
+
|
|
109
|
+
所以互斥必须同时看两个维度:
|
|
110
|
+
· DataDir 锁 → 「这个目录上只能有一份」(同目录抢同一批作业文件)
|
|
111
|
+
· AppID 登记 → 「这个应用只能有一份」(同应用不许换着目录各跑一份)
|
|
112
|
+
|
|
113
|
+
登记放 ~/.magic-client/appid-locks/<appid>.lock,内容记 DataDir + pid,
|
|
114
|
+
所以冲突时能直接告诉用户「另一份在哪、用的哪个目录」。
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
// appIDLockPath 某 AppID 的占用登记文件。
|
|
118
|
+
//
|
|
119
|
+
// AppID 里有 `.`(bundleId 形如 com.magic.test)和可能的 `/`,一律换成 `_`:
|
|
120
|
+
// 前者本身无害,后者会变成路径分隔符、把文件写到别的目录里。
|
|
121
|
+
func appIDLockPath(appID string) string {
|
|
122
|
+
appID = strings.TrimSpace(appID)
|
|
123
|
+
if appID == "" {
|
|
124
|
+
return ""
|
|
125
|
+
}
|
|
126
|
+
if p := strings.TrimSpace(os.Getenv(EnvAppIDLockDir)); p != "" {
|
|
127
|
+
dir := expandHome(p)
|
|
128
|
+
return filepath.Join(dir, sanitizeAppID(appID)+".lock")
|
|
129
|
+
}
|
|
130
|
+
home, err := os.UserHomeDir()
|
|
131
|
+
if err != nil || home == "" {
|
|
132
|
+
return ""
|
|
133
|
+
}
|
|
134
|
+
return filepath.Join(home, ".magic-client", "appid-locks", sanitizeAppID(appID)+".lock")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// sanitizeAppID 把 AppID 变成安全的文件名。
|
|
138
|
+
//
|
|
139
|
+
// 规则:
|
|
140
|
+
// - 字母数字与 `.` `_` `-` 原样保留(bundleId 形如 com.magic.test 是常态);
|
|
141
|
+
// - `/` `\` 换成 `_`(不换会把登记写到别的目录里,这是安全问题不是洁癖);
|
|
142
|
+
// - 其它字符(含中文、空格、控制字符)**直接丢掉**而不是换成下划线 ——
|
|
143
|
+
// 换掉的话 "名字" 会变成 "__",既不认得出是谁,又可能和真实 AppID 撞名;
|
|
144
|
+
// 丢完变空就兜底 "unknown"。
|
|
145
|
+
func sanitizeAppID(appID string) string {
|
|
146
|
+
var b strings.Builder
|
|
147
|
+
for _, r := range appID {
|
|
148
|
+
switch {
|
|
149
|
+
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
|
150
|
+
r == '.', r == '_', r == '-':
|
|
151
|
+
b.WriteRune(r)
|
|
152
|
+
case r == '/' || r == '\\':
|
|
153
|
+
b.WriteByte('_')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
out := b.String()
|
|
157
|
+
// 全是 '.'/'..' 这类无意义名字:补兜底,别在目录里造出隐藏文件或上跳路径。
|
|
158
|
+
if out == "" || strings.Trim(out, ".") == "" {
|
|
159
|
+
return "unknown"
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// AcquireAppIDClaim 登记「这个 AppID 归我这份实例用」,并检查有没有别的 DataDir 在跑。
|
|
165
|
+
//
|
|
166
|
+
// 已被**活着的**进程占用、且它的 DataDir 与本次不同 → 返回 (nil, holder, nil):
|
|
167
|
+
// 这不是错误,是「同一应用另起了一份」,调用方负责报错退出(不要静默另开一套目录)。
|
|
168
|
+
// 同一个 DataDir → 由 DataDir 锁去判,这里放行(否则同一份实例会被自己挡两道)。
|
|
169
|
+
func AcquireAppIDClaim(appID, dataDir string) (*SingleInstanceLock, *SingleInstanceHolder, error) {
|
|
170
|
+
path := appIDLockPath(appID)
|
|
171
|
+
if path == "" {
|
|
172
|
+
// 定位不了就放行:登记只是加固,不能因为算不出路径就让用户起不来。
|
|
173
|
+
return nil, nil, nil
|
|
174
|
+
}
|
|
175
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
176
|
+
return nil, nil, fmt.Errorf("建目录 %s 失败:%w", filepath.Dir(path), err)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
myDataDir := strings.TrimSpace(dataDir)
|
|
180
|
+
for attempt := 0; attempt < 2; attempt++ {
|
|
181
|
+
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
182
|
+
if err == nil {
|
|
183
|
+
exe, _ := os.Executable()
|
|
184
|
+
wd, _ := os.Getwd()
|
|
185
|
+
h := SingleInstanceHolder{
|
|
186
|
+
PID: os.Getpid(), Version: clientVersion(),
|
|
187
|
+
Exe: exe, Cwd: wd, DataDir: myDataDir, AppID: appID, Since: time.Now(),
|
|
188
|
+
}
|
|
189
|
+
if b, merr := json.MarshalIndent(h, "", " "); merr == nil {
|
|
190
|
+
_, _ = f.Write(append(b, '\n'))
|
|
191
|
+
}
|
|
192
|
+
_ = f.Close()
|
|
193
|
+
return &SingleInstanceLock{paths: []string{path}, held: true}, nil, nil
|
|
194
|
+
}
|
|
195
|
+
if !os.IsExist(err) {
|
|
196
|
+
return nil, nil, fmt.Errorf("创建占用登记 %s 失败:%w", path, err)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
holder := ReadSingleInstanceHolder(path)
|
|
200
|
+
stale := holder == nil
|
|
201
|
+
if holder != nil {
|
|
202
|
+
switch {
|
|
203
|
+
case !lockPIDAlive(holder.PID):
|
|
204
|
+
stale = true
|
|
205
|
+
case !holder.Since.IsZero() && time.Since(holder.Since) > singleInstanceStale:
|
|
206
|
+
stale = true
|
|
207
|
+
case holder.PID == os.Getpid():
|
|
208
|
+
// 自己上一次已经登记过(同一进程重复调用):续期即可。
|
|
209
|
+
return &SingleInstanceLock{paths: []string{path}, held: true}, nil, nil
|
|
210
|
+
case samePath(holder.DataDir, myDataDir):
|
|
211
|
+
// 同一个数据目录:DataDir 锁会挡住它,这里不重复判。
|
|
212
|
+
return nil, nil, nil
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if !stale {
|
|
216
|
+
return nil, holder, nil
|
|
217
|
+
}
|
|
218
|
+
_ = os.Remove(path)
|
|
219
|
+
}
|
|
220
|
+
return nil, ReadSingleInstanceHolder(path), nil
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// samePath 两个目录是否同一份(展开 ~ 后比较;解析失败就按字符串比)。
|
|
224
|
+
func samePath(a, b string) bool {
|
|
225
|
+
a, b = strings.TrimSpace(a), strings.TrimSpace(b)
|
|
226
|
+
if a == "" || b == "" {
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
ca, cb := filepath.Clean(expandHome(a)), filepath.Clean(expandHome(b))
|
|
230
|
+
if ca == cb {
|
|
231
|
+
return true
|
|
232
|
+
}
|
|
233
|
+
ra, ea := filepath.EvalSymlinks(ca)
|
|
234
|
+
rb, eb := filepath.EvalSymlinks(cb)
|
|
235
|
+
return ea == nil && eb == nil && ra == rb
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// AppIDConflictMessage 同一 AppID 换了数据目录在跑时的说明。
|
|
239
|
+
//
|
|
240
|
+
// 要说清两件事:① 这是同一个应用的两个「家」,作业记录不互通;
|
|
241
|
+
// ② 该去停哪一份(pid + 数据目录)。
|
|
242
|
+
func AppIDConflictMessage(appID string, h *SingleInstanceHolder) string {
|
|
243
|
+
if h == nil {
|
|
244
|
+
return fmt.Sprintf("应用 %s 已有另一个实例在跑。先 `magic-client stop` 再启动。", appID)
|
|
245
|
+
}
|
|
246
|
+
var b strings.Builder
|
|
247
|
+
fmt.Fprintf(&b, "应用 %s 已有另一个实例在跑(pid=%d", appID, h.PID)
|
|
248
|
+
if h.Version != "" {
|
|
249
|
+
fmt.Fprintf(&b, ",v%s", h.Version)
|
|
250
|
+
}
|
|
251
|
+
if !h.Since.IsZero() {
|
|
252
|
+
fmt.Fprintf(&b, ",已运行 %s", humanDuration(time.Since(h.Since)))
|
|
253
|
+
}
|
|
254
|
+
b.WriteString(")。\n")
|
|
255
|
+
if h.DataDir != "" {
|
|
256
|
+
fmt.Fprintf(&b, "它用的数据目录:%s\n", h.DataDir)
|
|
257
|
+
}
|
|
258
|
+
b.WriteString("同一个应用只能跑一份,**换数据目录也不算两份不同应用** —— 两个目录的作业记录\n")
|
|
259
|
+
b.WriteString("不互通,手机连着 A 时任务在 B 里跑完,界面就只剩「没有收到任何输出」。\n")
|
|
260
|
+
if h.Exe != "" {
|
|
261
|
+
fmt.Fprintf(&b, "正在跑的是:%s\n", h.Exe)
|
|
262
|
+
}
|
|
263
|
+
b.WriteString("先 `magic-client stop` 停掉它,再启动这一份(这会让两份回到同一个数据目录)。")
|
|
264
|
+
return b.String()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// lockPIDAlive 进程是否还活着。
|
|
268
|
+
//
|
|
269
|
+
// 不自己发信号 0:os.Process.Signal 在 Windows 上只支持 Kill,传 Signal(0) 恒返回
|
|
270
|
+
// 错误 —— 那样写会让 Windows 上「持有者永远已死」,锁形同虚设(每次启动都回收别人
|
|
271
|
+
// 的锁,两份实例照样并存)。agent.PIDAlive 是平台分派好的:
|
|
272
|
+
// unix 走 kill(pid,0) 且把 EPERM 当活着,Windows 走 OpenProcess/GetExitCodeProcess。
|
|
273
|
+
func lockPIDAlive(pid int) bool {
|
|
274
|
+
return agent.PIDAlive(pid)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// clientVersion 本实例的版本串(写进锁文件,报错时能看出「你连的是哪一版」)。
|
|
278
|
+
//
|
|
279
|
+
// 不做 ldflags 注入:magic-client 的版本由 core 契约统一上报(/desk/health 的
|
|
280
|
+
// version 字段),这里只需要一个「大致是哪一版」的线索,取环境变量即可 ——
|
|
281
|
+
// 缺了就不写,不编一个假的。
|
|
282
|
+
func clientVersion() string {
|
|
283
|
+
return strings.TrimSpace(os.Getenv("MAGIC_CLIENT_VERSION"))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ReadSingleInstanceHolder 读锁文件(不判断死活)。文件不存在 / 内容坏 → nil。
|
|
287
|
+
func ReadSingleInstanceHolder(path string) *SingleInstanceHolder {
|
|
288
|
+
b, err := os.ReadFile(path)
|
|
289
|
+
if err != nil || len(b) == 0 {
|
|
290
|
+
return nil
|
|
291
|
+
}
|
|
292
|
+
var h SingleInstanceHolder
|
|
293
|
+
if err := json.Unmarshal(b, &h); err != nil {
|
|
294
|
+
// 老格式 / 半截写入:只认第一行的 pid,其余置零。
|
|
295
|
+
// 判死不靠这里,靠 lockPIDAlive —— 读不出身份不影响正确性。
|
|
296
|
+
line := strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0])
|
|
297
|
+
if pid, perr := strconv.Atoi(line); perr == nil && pid > 0 {
|
|
298
|
+
return &SingleInstanceHolder{PID: pid}
|
|
299
|
+
}
|
|
300
|
+
return nil
|
|
301
|
+
}
|
|
302
|
+
if h.PID <= 0 {
|
|
303
|
+
return nil
|
|
304
|
+
}
|
|
305
|
+
return &h
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// AcquireInstanceLocks 一次性取两道锁:AppID 登记 + DataDir 锁。
|
|
309
|
+
//
|
|
310
|
+
// 顺序有讲究:**先 AppID 后 DataDir**。反过来的话,同一应用换了目录在跑时,
|
|
311
|
+
// 第二份会先在自己那个(没人用的)目录上拿到锁,再被 AppID 挡下 —— 于是留下
|
|
312
|
+
// 一个「持有者已死」的锁文件在空目录里,下次启动还得靠回收逻辑兜。
|
|
313
|
+
//
|
|
314
|
+
// 返回:
|
|
315
|
+
//
|
|
316
|
+
// (lk, nil, nil) 两道都拿到,可以起
|
|
317
|
+
// (nil, holder, nil) 被挡住;which 说明是哪一道:
|
|
318
|
+
// "appid" → 同一应用换了数据目录在跑(AppIDConflictMessage)
|
|
319
|
+
// "datadir" → 同一数据目录上已有一份(SingleInstanceConflictMessage)
|
|
320
|
+
// (nil, nil, err) 真的 IO 故障
|
|
321
|
+
func AcquireInstanceLocks(appID, dataDir string, port int) (lk *SingleInstanceLock, holder *SingleInstanceHolder, which string, err error) {
|
|
322
|
+
claim, appHolder, err := AcquireAppIDClaim(appID, dataDir)
|
|
323
|
+
if err != nil {
|
|
324
|
+
return nil, nil, "", err
|
|
325
|
+
}
|
|
326
|
+
if claim == nil && appHolder != nil {
|
|
327
|
+
return nil, appHolder, "appid", nil
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
dirLock, dirHolder, err := AcquireSingleInstance(dataDir, port)
|
|
331
|
+
if err != nil {
|
|
332
|
+
if claim != nil {
|
|
333
|
+
claim.Release()
|
|
334
|
+
}
|
|
335
|
+
return nil, nil, "", err
|
|
336
|
+
}
|
|
337
|
+
if dirLock == nil {
|
|
338
|
+
if claim != nil {
|
|
339
|
+
claim.Release()
|
|
340
|
+
}
|
|
341
|
+
return nil, dirHolder, "datadir", nil
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 两道都拿到:合成一把,退出时一起放。
|
|
345
|
+
all := &SingleInstanceLock{held: true}
|
|
346
|
+
if claim != nil {
|
|
347
|
+
all.paths = append(all.paths, claim.paths...)
|
|
348
|
+
}
|
|
349
|
+
if dirLock != nil {
|
|
350
|
+
all.paths = append(all.paths, dirLock.paths...)
|
|
351
|
+
}
|
|
352
|
+
return all, nil, "", nil
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// AcquireSingleInstance 取单实例锁(dataDir = 该 profile 的数据目录)。
|
|
356
|
+
//
|
|
357
|
+
// 已被一个**活着的**进程持有 → 返回 (nil, holder, nil),调用方负责报错退出。
|
|
358
|
+
// 陈旧锁(进程没了 / 超过 staleAfter)→ 回收后重新创建。
|
|
359
|
+
// 其它 IO 错误照常返回 err(写不了锁文件不是「有人在跑」,别混为一谈)。
|
|
360
|
+
func AcquireSingleInstance(dataDir string, port int) (*SingleInstanceLock, *SingleInstanceHolder, error) {
|
|
361
|
+
path := DefaultLockPath(dataDir)
|
|
362
|
+
if path == "" {
|
|
363
|
+
return nil, nil, fmt.Errorf("定位单实例锁失败")
|
|
364
|
+
}
|
|
365
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
366
|
+
return nil, nil, fmt.Errorf("建目录 %s 失败:%w", filepath.Dir(path), err)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 最多两轮:第一轮 O_EXCL 成功即拿到;失败则判陈旧,回收后再来一次。
|
|
370
|
+
for attempt := 0; attempt < 2; attempt++ {
|
|
371
|
+
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
372
|
+
if err == nil {
|
|
373
|
+
exe, _ := os.Executable()
|
|
374
|
+
wd, _ := os.Getwd()
|
|
375
|
+
h := SingleInstanceHolder{
|
|
376
|
+
PID: os.Getpid(), Port: port, Version: clientVersion(),
|
|
377
|
+
Exe: exe, Cwd: wd, DataDir: dataDir, Since: time.Now(),
|
|
378
|
+
}
|
|
379
|
+
if b, merr := json.MarshalIndent(h, "", " "); merr == nil {
|
|
380
|
+
_, _ = f.Write(append(b, '\n'))
|
|
381
|
+
}
|
|
382
|
+
_ = f.Close()
|
|
383
|
+
return &SingleInstanceLock{paths: []string{path}, held: true}, nil, nil
|
|
384
|
+
}
|
|
385
|
+
if !os.IsExist(err) {
|
|
386
|
+
return nil, nil, fmt.Errorf("创建锁文件 %s 失败:%w", path, err)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 已存在:判死活。
|
|
390
|
+
holder := ReadSingleInstanceHolder(path)
|
|
391
|
+
stale := holder == nil
|
|
392
|
+
if holder != nil {
|
|
393
|
+
if !lockPIDAlive(holder.PID) {
|
|
394
|
+
stale = true
|
|
395
|
+
} else if !holder.Since.IsZero() && time.Since(holder.Since) > singleInstanceStale {
|
|
396
|
+
stale = true
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if !stale {
|
|
400
|
+
return nil, holder, nil
|
|
401
|
+
}
|
|
402
|
+
_ = os.Remove(path) // 陈旧:清掉再来一轮
|
|
403
|
+
}
|
|
404
|
+
// 两轮都撞上 → 有人正在以毫秒级频率抢占,按「已有实例」处理更安全。
|
|
405
|
+
return nil, ReadSingleInstanceHolder(path), nil
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Release 释放锁(幂等)。只在**自己还持有**时删文件:
|
|
409
|
+
// 被回收过又重新创建的情况下,删别人的锁会让互斥彻底失效 —— 所以核对 pid。
|
|
410
|
+
func (l *SingleInstanceLock) Release() {
|
|
411
|
+
if l == nil || !l.held {
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
l.held = false
|
|
415
|
+
for _, p := range l.paths {
|
|
416
|
+
// 只删自己的:被回收过又重新创建的情况下,删别人的锁会让互斥彻底失效。
|
|
417
|
+
if h := ReadSingleInstanceHolder(p); h != nil && h.PID != os.Getpid() {
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
_ = os.Remove(p)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// SingleInstanceConflictMessage 拿不到锁时给用户的说明(要能直接照着做)。
|
|
425
|
+
//
|
|
426
|
+
// 用户看到的不能只是「已在运行」——真正要传达的是:
|
|
427
|
+
// 这台机器上还有另一份在跑,**它才是你手机连着的那个**;不先停掉它,
|
|
428
|
+
// 新起的这份既占不到端口,也不会有任何请求进来。
|
|
429
|
+
func SingleInstanceConflictMessage(h *SingleInstanceHolder) string {
|
|
430
|
+
if h == nil {
|
|
431
|
+
return "这份数据目录上已有另一个 magic-client 在跑。先 `magic-client stop` 再启动。"
|
|
432
|
+
}
|
|
433
|
+
age := ""
|
|
434
|
+
if !h.Since.IsZero() {
|
|
435
|
+
age = ",已运行 " + humanDuration(time.Since(h.Since))
|
|
436
|
+
}
|
|
437
|
+
var b strings.Builder
|
|
438
|
+
fmt.Fprintf(&b, "这份数据目录上已有另一个 magic-client 在跑(pid=%d", h.PID)
|
|
439
|
+
if h.Version != "" {
|
|
440
|
+
fmt.Fprintf(&b, ",v%s", h.Version)
|
|
441
|
+
}
|
|
442
|
+
if h.Port > 0 {
|
|
443
|
+
fmt.Fprintf(&b, ",端口 %d", h.Port)
|
|
444
|
+
}
|
|
445
|
+
b.WriteString(age)
|
|
446
|
+
b.WriteString(")。\n")
|
|
447
|
+
if h.DataDir != "" {
|
|
448
|
+
fmt.Fprintf(&b, "数据目录:%s\n", h.DataDir)
|
|
449
|
+
}
|
|
450
|
+
b.WriteString("同一个数据目录只能跑一份 —— 两份共用同一批作业记录,任务会在其中一份里跑完,\n")
|
|
451
|
+
b.WriteString("而手机连着的那份拿不到输出(界面显示「没有收到任何输出」)。\n")
|
|
452
|
+
if h.Exe != "" {
|
|
453
|
+
fmt.Fprintf(&b, "正在跑的是:%s\n", h.Exe)
|
|
454
|
+
}
|
|
455
|
+
b.WriteString("先 `magic-client stop` 停掉它,再启动这一份。\n")
|
|
456
|
+
b.WriteString("(不同 AppID / 不同数据目录的实例互不影响,可以各自跑一份。)")
|
|
457
|
+
return b.String()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// humanDuration 粗粒度时长(报错文本用,不要精确到秒)。
|
|
461
|
+
func humanDuration(d time.Duration) string {
|
|
462
|
+
switch {
|
|
463
|
+
case d < time.Minute:
|
|
464
|
+
return fmt.Sprintf("%d 秒", int(d.Seconds()))
|
|
465
|
+
case d < time.Hour:
|
|
466
|
+
return fmt.Sprintf("%d 分钟", int(d.Minutes()))
|
|
467
|
+
default:
|
|
468
|
+
return fmt.Sprintf("%d 小时", int(d.Hours()))
|
|
469
|
+
}
|
|
470
|
+
}
|