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
|
@@ -142,3 +142,31 @@ func TestDeleteDropsStatuses(t *testing.T) {
|
|
|
142
142
|
t.Fatalf("删除后状态该被清掉,实得 %q", got)
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
// TestStatusOfMatchesStatusForAny 快照读取必须与逐条读取**逐字一致**。
|
|
147
|
+
//
|
|
148
|
+
// 为什么单独立一条(2026-10-26):列表页从 StatusForAny 换成了 StatusSnapshot + StatusOf
|
|
149
|
+
// (逐行调前者会让底层把整张表深拷贝一遍,407 行 = 407 次全表拷贝)。这类「为了性能换一条
|
|
150
|
+
// 取数路径」的改动最容易悄悄改变语义 —— 键优先级(session 优先 / run 兜底)只要漏一处,
|
|
151
|
+
// 列表上的状态就会一部分对、一部分错,而且极难看出来。这里把两者钉成同一个答案。
|
|
152
|
+
func TestStatusOfMatchesStatusForAny(t *testing.T) {
|
|
153
|
+
withStatusHome(t)
|
|
154
|
+
now := time.Now()
|
|
155
|
+
setStatus(Record{RunID: "run-a", SessionID: "sess-a", State: StateIdle, StartedAt: now})
|
|
156
|
+
setStatus(Record{RunID: "run-b", State: StateRunning, StartedAt: now})
|
|
157
|
+
snap := StatusSnapshot()
|
|
158
|
+
|
|
159
|
+
cases := [][2]string{
|
|
160
|
+
{"sess-a", "run-a"}, // 两个键都有:取 session
|
|
161
|
+
{"", "run-b"}, // 只有 run:取 run
|
|
162
|
+
{"sess-a", "run-b"}, // 撞车:session 优先(与 StatusForAny 同口径)
|
|
163
|
+
{"nope", "nope2"}, // 都没有:空
|
|
164
|
+
{"", ""}, // 空 id:空
|
|
165
|
+
}
|
|
166
|
+
for _, c := range cases {
|
|
167
|
+
want := StatusForAny(c[0], c[1])
|
|
168
|
+
if got := StatusOf(snap, c[0], c[1]); got != want {
|
|
169
|
+
t.Fatalf("StatusOf(%q,%q) = %q,StatusForAny 却是 %q", c[0], c[1], got, want)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -133,6 +133,22 @@ func TitleForAny(sessionID, runID string) string {
|
|
|
133
133
|
return m[strings.TrimSpace(runID)]
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// TitleSnapshot 取整张标题表的**只读快照**,供批量读取(列表页)用。
|
|
137
|
+
//
|
|
138
|
+
// 与 StatusSnapshot 同一个理由(2026-10-26):TitleForAny 每次都会让 loadTitles()
|
|
139
|
+
// 把整张表深拷贝一份(缓存不能交给调用方乱改)。列表页逐行调它,407 行就是 407 次
|
|
140
|
+
// 全表拷贝 —— 而它要的只是一个 O(1) 的查表。这里拷一次,调用方自己按 id 取。
|
|
141
|
+
func TitleSnapshot() map[string]string { return Titles() }
|
|
142
|
+
|
|
143
|
+
// TitleOf 从快照里取一条会话的标题:**session_id 优先,其次 run_id** ——
|
|
144
|
+
// 与 TitleForAny 同口径,只是不再重复加载表。
|
|
145
|
+
func TitleOf(snapshot map[string]string, sessionID, runID string) string {
|
|
146
|
+
if t := snapshot[strings.TrimSpace(sessionID)]; t != "" {
|
|
147
|
+
return t
|
|
148
|
+
}
|
|
149
|
+
return snapshot[strings.TrimSpace(runID)]
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
// AdoptTitle 把标题从 run_id 迁到 session_id(幂等),并**清掉那个 run_id 键**。
|
|
137
153
|
//
|
|
138
154
|
// 只在新键**为空**时搬:用户可能已经手动重命名过(那就是权威值),不能被旧键覆盖回去。
|
|
@@ -296,3 +296,29 @@ func TestSummarizeCachedAcrossKeys(t *testing.T) {
|
|
|
296
296
|
t.Fatal("命中时应把标题迁到 session_id(否则下一轮又要重算)")
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
|
+
|
|
300
|
+
// TestTitleOfMatchesTitleForAny 快照读取必须与逐条读取**逐字一致**(理由见 status_test.go
|
|
301
|
+
// 的 TestStatusOfMatchesStatusForAny:列表页为省掉逐行全表拷贝换了取数路径)。
|
|
302
|
+
func TestTitleOfMatchesTitleForAny(t *testing.T) {
|
|
303
|
+
withTitleHome(t)
|
|
304
|
+
if err := SetTitle("sess-a", "会话标题"); err != nil {
|
|
305
|
+
t.Fatal(err)
|
|
306
|
+
}
|
|
307
|
+
if err := SetTitle("run-b", "只按 run 存的"); err != nil {
|
|
308
|
+
t.Fatal(err)
|
|
309
|
+
}
|
|
310
|
+
snap := TitleSnapshot()
|
|
311
|
+
|
|
312
|
+
cases := [][2]string{
|
|
313
|
+
{"sess-a", "run-b"}, // 撞车:session 优先
|
|
314
|
+
{"", "run-b"}, // 只按 run
|
|
315
|
+
{"nope", "nope"}, // 都没有
|
|
316
|
+
{"", ""}, // 空 id
|
|
317
|
+
}
|
|
318
|
+
for _, c := range cases {
|
|
319
|
+
want := TitleForAny(c[0], c[1])
|
|
320
|
+
if got := TitleOf(snap, c[0], c[1]); got != want {
|
|
321
|
+
t.Fatalf("TitleOf(%q,%q) = %q,TitleForAny 却是 %q", c[0], c[1], got, want)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -3,6 +3,7 @@ package session
|
|
|
3
3
|
import (
|
|
4
4
|
"crypto/sha1"
|
|
5
5
|
"encoding/hex"
|
|
6
|
+
"encoding/json"
|
|
6
7
|
"fmt"
|
|
7
8
|
"os"
|
|
8
9
|
"path/filepath"
|
|
@@ -13,25 +14,51 @@ import (
|
|
|
13
14
|
"github.com/darren/magic-agent/internal/agent"
|
|
14
15
|
)
|
|
15
16
|
|
|
16
|
-
/* ── 工作空间串行锁(2026-10-23
|
|
17
|
+
/* ── 工作空间串行锁(2026-10-23 起;2026-10-26 改成「多客户端默认生效」)──
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
为什么会存在这件事:**同一个工作空间不能同时跑两个引擎**。
|
|
20
|
+
两个 agent 同时改同一批文件,冲突是必然的;更隐蔽的是同一 session_id 被两端
|
|
21
|
+
同时 resume 时,两边的 sessionWriter 各算各的 seq,同一份 jsonl 里出现成对重号,
|
|
22
|
+
读侧按 `--after seq` 增量续读会把另一条 lane 的事件全滤掉 —— 一端「已完成」、
|
|
23
|
+
另一端永远等不到终态(2026-10-21 用户报障的正是这个)。
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
为什么锁必须落在 core 而不是只靠某个客户端的调度器:
|
|
26
|
+
· 能起轮次的地方**不止一个客户端**:桌面壳直接 exec 本 CLI、HTTP 插件排自己的队列、
|
|
27
|
+
手机连同一个插件、还有人手工 `magic-agent -p` / CI / 别的 magic-* 应用;
|
|
28
|
+
· 任何进程内的队列都只管得住自己那一份。只有落在**文件系统**上的锁,
|
|
29
|
+
才能让「谁先跑」对所有进程一致。
|
|
30
|
+
这就是「magic-agent 本身要支持多客户端」的字面含义:**仲裁在 core,不在各宿主**。
|
|
31
|
+
宿主可以自己再排一层(为了显示排队位次、让路给别的客户端),但那是**视图与礼貌**,
|
|
32
|
+
不是正确性的来源 —— 少一个宿主忘了实现,文件就已经被两个进程改了。
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
── 2026-10-26 改了什么(原先只在插件起 core 时选入)──
|
|
35
|
+
|
|
36
|
+
改前:`MAGIC_AGENT_WORKSPACE_LOCK=1` 才生效,且只有 magic-client 注入。
|
|
37
|
+
于是「壳直接 exec core」这条路**完全绕过**串行 —— 用户实测:手机在跑时,
|
|
38
|
+
PC 能在同一目录立刻起第二个引擎,两边同时改文件。补在每个宿主里是不收敛的
|
|
39
|
+
(N 个宿主 = N 份实现,且新宿主天然漏掉)。
|
|
40
|
+
|
|
41
|
+
改后:**默认生效**,只有显式 `MAGIC_AGENT_WORKSPACE_LOCK=0` 才关。
|
|
42
|
+
保留关闭开关的理由:那些「本来就允许并发」的用法不该被悄悄改语义 ——
|
|
43
|
+
跑批、测试、团队模式子任务(它们与主任务同空间,见 Begin 里的 ParentRunID 判断)。
|
|
44
|
+
|
|
45
|
+
── 等待方要能看见「在等谁」──
|
|
46
|
+
|
|
47
|
+
锁文件里除 pid 外还记 `client` / `source` / `run_id` / `session_id` / 起始时间。
|
|
48
|
+
原先是 `pid\n时间戳`,等待方只能打一句「有人在跑」——用户看到的是「卡住了」,
|
|
49
|
+
既不知道是手机上那条、也不知道该去停谁。现在 `LockHolder()` 能把持有者读出来,
|
|
50
|
+
`--sessions` 与客户端都能据此显示「等谁」。
|
|
51
|
+
|
|
52
|
+
锁的形态:`<sessions dir>/locks/<sha1(workspace)>.lock`,JSON 内容(兼容旧的纯文本)。
|
|
53
|
+
· `O_CREATE|O_EXCL` 原子创建;
|
|
54
|
+
· 持有进程已不在 / 文件超过 staleAfter → 陈旧,回收;
|
|
55
|
+
· 进程崩溃没删也不会永久堵住(下一个持有者按 pid 判死回收)。
|
|
32
56
|
*/
|
|
33
57
|
|
|
34
|
-
// EnvWorkspaceLock
|
|
58
|
+
// EnvWorkspaceLock 开关「工作空间串行锁」的环境变量。
|
|
59
|
+
//
|
|
60
|
+
// 取值语义(2026-10-26 起):未设置 = **启用**;`0` / `false` / `off` / `no` = 关闭;
|
|
61
|
+
// 其余非空值 = 启用。改前是「必须为 1 才启用」,见文件头「改了什么」。
|
|
35
62
|
const EnvWorkspaceLock = "MAGIC_AGENT_WORKSPACE_LOCK"
|
|
36
63
|
|
|
37
64
|
// EnvWorkspaceLockWait 等锁上限(Go duration;默认 10m)。
|
|
@@ -42,14 +69,33 @@ const (
|
|
|
42
69
|
workspaceLockStale = 6 * time.Hour
|
|
43
70
|
)
|
|
44
71
|
|
|
72
|
+
// LockHolder 锁的持有者信息(等待方 / 列表页用来回答「我在等谁」)。
|
|
73
|
+
type LockHolder struct {
|
|
74
|
+
PID int `json:"pid"`
|
|
75
|
+
Client string `json:"client,omitempty"` // 发起方应用(如 magic-test / com.magic.test)
|
|
76
|
+
Source string `json:"source,omitempty"` // 设备形态(mobile | pc)
|
|
77
|
+
RunID string `json:"run_id,omitempty"` // 占用者那一轮的 run_id
|
|
78
|
+
SessionID string `json:"session_id,omitempty"`
|
|
79
|
+
Engine string `json:"engine,omitempty"`
|
|
80
|
+
Since time.Time `json:"since"` // 拿到锁的时刻
|
|
81
|
+
// Workspace 归一路径(只是为了回报时不用调用方再算一次)。
|
|
82
|
+
Workspace string `json:"workspace,omitempty"`
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
// WorkspaceLock 一把已持有的工作空间锁;Release 幂等。
|
|
46
86
|
type WorkspaceLock struct {
|
|
47
87
|
path string
|
|
48
88
|
held bool
|
|
49
89
|
}
|
|
50
90
|
|
|
91
|
+
// workspaceLockEnabled 锁是否启用(默认启用;见 EnvWorkspaceLock 的取值语义)。
|
|
51
92
|
func workspaceLockEnabled() bool {
|
|
52
|
-
|
|
93
|
+
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvWorkspaceLock))) {
|
|
94
|
+
case "0", "false", "off", "no":
|
|
95
|
+
return false
|
|
96
|
+
default:
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
func workspaceLockWait() time.Duration {
|
|
@@ -61,7 +107,12 @@ func workspaceLockWait() time.Duration {
|
|
|
61
107
|
return 10 * time.Minute
|
|
62
108
|
}
|
|
63
109
|
|
|
64
|
-
//
|
|
110
|
+
// LockKey 归一工作空间:空 = 进程 cwd;返回绝对路径 + 哈希文件名。
|
|
111
|
+
//
|
|
112
|
+
// 导出是因为客户端要自己判断「我这条和它那条是不是同一个目录」——
|
|
113
|
+
// 让它们各写一份归一逻辑,迟早在「尾斜杠算不算同一个」上分叉。
|
|
114
|
+
func LockKey(workspace string) (key, file string) { return workspaceLockKey(workspace) }
|
|
115
|
+
|
|
65
116
|
func workspaceLockKey(workspace string) (key, file string) {
|
|
66
117
|
ws := strings.TrimSpace(workspace)
|
|
67
118
|
if ws == "" {
|
|
@@ -75,13 +126,117 @@ func workspaceLockKey(workspace string) (key, file string) {
|
|
|
75
126
|
return ws, hex.EncodeToString(sum[:])
|
|
76
127
|
}
|
|
77
128
|
|
|
129
|
+
// lockDirPath 锁目录(与登记表同目录,见 Dir)。
|
|
130
|
+
func lockDirPath() string {
|
|
131
|
+
dir := Dir()
|
|
132
|
+
if dir == "" {
|
|
133
|
+
return ""
|
|
134
|
+
}
|
|
135
|
+
return filepath.Join(dir, "locks")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// lockFilePath 某个 workspace 的锁文件路径(空 = 环境不可用)。
|
|
139
|
+
func lockFilePath(workspace string) string {
|
|
140
|
+
ld := lockDirPath()
|
|
141
|
+
if ld == "" {
|
|
142
|
+
return ""
|
|
143
|
+
}
|
|
144
|
+
_, name := workspaceLockKey(workspace)
|
|
145
|
+
return filepath.Join(ld, name+".lock")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ── 锁文件的内容编解码 ────────────────────────────────────────────
|
|
149
|
+
* 新格式是 JSON(能带持有者身份)。旧格式是两行纯文本 `pid\n纳秒时间`。
|
|
150
|
+
* 读的时候两种都认 —— 升级途中会有上一个版本留下的锁文件,
|
|
151
|
+
* 不认它就会把「有人在跑」误判成「锁文件坏了」,进而当成陈旧锁抢过来(两个进程同时跑)。
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
// encodeLock 锁文件的正文。
|
|
155
|
+
func encodeLock(h LockHolder) []byte {
|
|
156
|
+
if h.Since.IsZero() {
|
|
157
|
+
h.Since = time.Now()
|
|
158
|
+
}
|
|
159
|
+
b, err := json.Marshal(h)
|
|
160
|
+
if err != nil { // 理论上不可达(字段全是可序列化的基本类型);退化成旧格式也别写坏文件
|
|
161
|
+
return []byte(fmt.Sprintf("%d\n%d\n", h.PID, h.Since.UnixNano()))
|
|
162
|
+
}
|
|
163
|
+
return append(b, '\n')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// decodeLock 解析锁文件正文;两种格式都认。
|
|
167
|
+
func decodeLock(b []byte) LockHolder {
|
|
168
|
+
trimmed := strings.TrimSpace(string(b))
|
|
169
|
+
if trimmed == "" {
|
|
170
|
+
return LockHolder{}
|
|
171
|
+
}
|
|
172
|
+
// JSON 形状:以 { 开头。旧格式第一行是纯数字,不会撞。
|
|
173
|
+
if strings.HasPrefix(trimmed, "{") {
|
|
174
|
+
var h LockHolder
|
|
175
|
+
if err := json.Unmarshal([]byte(trimmed), &h); err == nil {
|
|
176
|
+
return h
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
lines := strings.Split(trimmed, "\n")
|
|
180
|
+
var h LockHolder
|
|
181
|
+
if len(lines) > 0 {
|
|
182
|
+
if pid, err := strconv.Atoi(strings.TrimSpace(lines[0])); err == nil {
|
|
183
|
+
h.PID = pid
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if len(lines) > 1 {
|
|
187
|
+
if ns, err := strconv.ParseInt(strings.TrimSpace(lines[1]), 10, 64); err == nil && ns > 0 {
|
|
188
|
+
h.Since = time.Unix(0, ns)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return h
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ── 持有者查询(等待方 / 列表页用)────────────────────────────────
|
|
195
|
+
* 与 acquire 是**只读**的一对:这里绝不创建、绝不回收 —— 回收只在真正要取锁时做
|
|
196
|
+
* (见 reapStaleWorkspaceLock)。原因:查询方可能只是个列表页,
|
|
197
|
+
* 它凭什么替别人判定「那把锁已经死了」然后删掉?判死要取锁方才做的决定。
|
|
198
|
+
*/
|
|
199
|
+
|
|
200
|
+
// LockHolderFor 读某个 workspace 上当前锁的持有者(没有锁 / 环境不可用 → 0 值)。
|
|
201
|
+
//
|
|
202
|
+
// ⚠️ 不判活、不回收:pid 可能已经死了但文件还在。要不要清理是取锁方的事。
|
|
203
|
+
// 调用方若要「这锁还算不算数」,请自己看 PIDAlive(agent.PIDAlive 与这里同口径)。
|
|
204
|
+
func LockHolderFor(workspace string) (LockHolder, bool) {
|
|
205
|
+
p := lockFilePath(workspace)
|
|
206
|
+
if p == "" {
|
|
207
|
+
return LockHolder{}, false
|
|
208
|
+
}
|
|
209
|
+
b, err := os.ReadFile(p)
|
|
210
|
+
if err != nil {
|
|
211
|
+
return LockHolder{}, false
|
|
212
|
+
}
|
|
213
|
+
h := decodeLock(b)
|
|
214
|
+
if h.PID <= 0 {
|
|
215
|
+
return LockHolder{}, false
|
|
216
|
+
}
|
|
217
|
+
if ws, _ := workspaceLockKey(workspace); h.Workspace == "" {
|
|
218
|
+
h.Workspace = ws
|
|
219
|
+
}
|
|
220
|
+
if h.Since.IsZero() {
|
|
221
|
+
// 旧格式没带时间就退回文件 mtime —— 至少能报「占多久了」。
|
|
222
|
+
if st, err := os.Stat(p); err == nil {
|
|
223
|
+
h.Since = st.ModTime()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return h, true
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ── 取锁 ─────────────────────────────────────────────────────────── */
|
|
230
|
+
|
|
78
231
|
/*
|
|
79
|
-
|
|
232
|
+
acquireWorkspaceLock 取工作空间锁(阻塞,直到拿到或超时)。
|
|
80
233
|
|
|
81
234
|
o.NoPersist 没关系:锁在 sessions 目录,不依赖会话记录落盘。
|
|
82
235
|
返回 nil 表示未启用(调用方无需处理)。
|
|
236
|
+
who 是持有者身份(pid / client / run_id…),写进锁文件供等待方读取;
|
|
237
|
+
传零值时按本进程 pid 兜底,绝不写一个 pid<=0 的锁(那会被当成陈旧锁立刻回收)。
|
|
83
238
|
*/
|
|
84
|
-
func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
|
|
239
|
+
func acquireWorkspaceLock(workspace string, who LockHolder) (*WorkspaceLock, error) {
|
|
85
240
|
if !workspaceLockEnabled() {
|
|
86
241
|
return nil, nil
|
|
87
242
|
}
|
|
@@ -95,12 +250,22 @@ func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
|
|
|
95
250
|
}
|
|
96
251
|
ws, name := workspaceLockKey(workspace)
|
|
97
252
|
path := filepath.Join(lockDir, name+".lock")
|
|
253
|
+
|
|
254
|
+
if who.PID <= 0 {
|
|
255
|
+
who.PID = os.Getpid()
|
|
256
|
+
}
|
|
257
|
+
who.Workspace = ws
|
|
258
|
+
if who.Since.IsZero() {
|
|
259
|
+
who.Since = time.Now()
|
|
260
|
+
}
|
|
261
|
+
body := encodeLock(who)
|
|
262
|
+
|
|
98
263
|
deadline := time.Now().Add(workspaceLockWait())
|
|
99
264
|
announced := false
|
|
100
265
|
for {
|
|
101
266
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
102
267
|
if err == nil {
|
|
103
|
-
_, _ =
|
|
268
|
+
_, _ = f.Write(body)
|
|
104
269
|
_ = f.Close()
|
|
105
270
|
return &WorkspaceLock{path: path, held: true}, nil
|
|
106
271
|
}
|
|
@@ -111,16 +276,63 @@ func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
|
|
|
111
276
|
continue
|
|
112
277
|
}
|
|
113
278
|
if time.Now().After(deadline) {
|
|
279
|
+
/* 超时的报错里带上「谁占着」—— 原来的文案只有工作空间路径,
|
|
280
|
+
用户看到的是「等超时了」却不知道该去停谁(手机上那条?还是另一个窗口?)。 */
|
|
281
|
+
if h, ok := LockHolderFor(workspace); ok {
|
|
282
|
+
return nil, fmt.Errorf("等待工作空间 %s 上的上一个任务超时(%s,占用者 pid=%d%s%s),可用 %s 调整",
|
|
283
|
+
ws, workspaceLockWait(), h.PID, lockWhoText(h), lockSinceText(h), EnvWorkspaceLockWait)
|
|
284
|
+
}
|
|
114
285
|
return nil, fmt.Errorf("等待工作空间 %s 上的上一个任务超时(%s),可用 %s 调整", ws, workspaceLockWait(), EnvWorkspaceLockWait)
|
|
115
286
|
}
|
|
116
287
|
if !announced {
|
|
117
288
|
announced = true
|
|
118
|
-
|
|
289
|
+
/* 同理:等待时第一句就交代「在等谁」。这一行是 stderr,
|
|
290
|
+
客户端会转给界面(magic-client 的 SSE / 壳的 notice)。 */
|
|
291
|
+
if h, ok := LockHolderFor(workspace); ok {
|
|
292
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s;占用者 pid=%d%s)…\n",
|
|
293
|
+
ws, h.PID, lockWhoText(h))
|
|
294
|
+
} else {
|
|
295
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s)…\n", ws)
|
|
296
|
+
}
|
|
119
297
|
}
|
|
120
298
|
time.Sleep(workspaceLockPoll)
|
|
121
299
|
}
|
|
122
300
|
}
|
|
123
301
|
|
|
302
|
+
// lockWhoText 「谁在占着」的人话后缀:` / 客户端 com.magic.test / 手机`。
|
|
303
|
+
// 没有任何身份信息时返回空串(旧锁文件),调用方据此不加多余的分隔符。
|
|
304
|
+
func lockWhoText(h LockHolder) string {
|
|
305
|
+
var parts []string
|
|
306
|
+
if c := strings.TrimSpace(h.Client); c != "" {
|
|
307
|
+
parts = append(parts, "客户端 "+c)
|
|
308
|
+
}
|
|
309
|
+
switch h.Source {
|
|
310
|
+
case SourceMobile:
|
|
311
|
+
parts = append(parts, "手机")
|
|
312
|
+
case SourcePC:
|
|
313
|
+
parts = append(parts, "电脑")
|
|
314
|
+
}
|
|
315
|
+
if len(parts) == 0 {
|
|
316
|
+
return ""
|
|
317
|
+
}
|
|
318
|
+
return " / " + strings.Join(parts, " / ")
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// lockSinceText 「占了多久」的人话后缀(读不出时间就给空串)。
|
|
322
|
+
func lockSinceText(h LockHolder) string {
|
|
323
|
+
if h.Since.IsZero() {
|
|
324
|
+
return ""
|
|
325
|
+
}
|
|
326
|
+
d := time.Since(h.Since)
|
|
327
|
+
if d < 0 {
|
|
328
|
+
d = 0
|
|
329
|
+
}
|
|
330
|
+
if d < time.Minute {
|
|
331
|
+
return fmt.Sprintf(",已占用 %d 秒", int(d.Seconds()))
|
|
332
|
+
}
|
|
333
|
+
return fmt.Sprintf(",已占用 %d 分", int(d.Minutes()))
|
|
334
|
+
}
|
|
335
|
+
|
|
124
336
|
// reapStaleWorkspaceLock 回收陈旧锁(pid 已不在 / 文件太老);返回是否已删。
|
|
125
337
|
func reapStaleWorkspaceLock(path string) bool {
|
|
126
338
|
st, err := os.Stat(path)
|
|
@@ -130,12 +342,12 @@ func reapStaleWorkspaceLock(path string) bool {
|
|
|
130
342
|
stale := time.Since(st.ModTime()) > workspaceLockStale
|
|
131
343
|
if !stale {
|
|
132
344
|
if b, err := os.ReadFile(path); err == nil {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
345
|
+
/* 两种格式都认(见 decodeLock):只按旧格式解析的话,
|
|
346
|
+
新格式的 JSON 首行是 `{"pid":123,...}`,Atoi 失败 → 判不出死进程 →
|
|
347
|
+
一个崩溃留下的锁要等到 6 小时 stale 才回收。 */
|
|
348
|
+
if h := decodeLock(b); h.PID > 0 {
|
|
349
|
+
if !agent.PIDAlive(h.PID) {
|
|
350
|
+
stale = true
|
|
139
351
|
}
|
|
140
352
|
}
|
|
141
353
|
}
|
|
@@ -147,6 +359,32 @@ func reapStaleWorkspaceLock(path string) bool {
|
|
|
147
359
|
return true
|
|
148
360
|
}
|
|
149
361
|
|
|
362
|
+
// noteRun 拿到锁之后再补写一次持有者身份(run_id 在取锁时还没生成)。
|
|
363
|
+
//
|
|
364
|
+
// 为什么值得多写一次文件:等待方拿到的线索里,run_id 是**唯一能直接对上
|
|
365
|
+
// `--sessions` 那一行**的键。只有 pid 的话,界面上只能显示一个数字,
|
|
366
|
+
// 用户没法点进去看「到底是谁在跑」,也没法据此决定要不要停掉它。
|
|
367
|
+
// 失败不影响持有(锁已经拿到了,身份信息只是附加价值)。
|
|
368
|
+
func (l *WorkspaceLock) noteRun(runID string) {
|
|
369
|
+
if l == nil || !l.held || strings.TrimSpace(runID) == "" {
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
cur, ok := LockHolderFor("")
|
|
373
|
+
b, err := os.ReadFile(l.path)
|
|
374
|
+
if err != nil {
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
h := decodeLock(b)
|
|
378
|
+
if h.PID != os.Getpid() {
|
|
379
|
+
return // 已不是自己的锁(被别人回收后重取了),不要覆盖
|
|
380
|
+
}
|
|
381
|
+
h.RunID = strings.TrimSpace(runID)
|
|
382
|
+
if ok && cur.RunID == h.RunID {
|
|
383
|
+
return // 没变化,不重复写
|
|
384
|
+
}
|
|
385
|
+
_ = os.WriteFile(l.path, encodeLock(h), 0o600)
|
|
386
|
+
}
|
|
387
|
+
|
|
150
388
|
// Release 释放锁;只删自己持有的那把(内容里的 pid 对得上)。
|
|
151
389
|
func (l *WorkspaceLock) Release() {
|
|
152
390
|
if l == nil || !l.held {
|
|
@@ -157,8 +395,7 @@ func (l *WorkspaceLock) Release() {
|
|
|
157
395
|
if err != nil {
|
|
158
396
|
return
|
|
159
397
|
}
|
|
160
|
-
|
|
161
|
-
if first != strconv.Itoa(os.Getpid()) {
|
|
398
|
+
if decodeLock(b).PID != os.Getpid() {
|
|
162
399
|
return
|
|
163
400
|
}
|
|
164
401
|
_ = os.Remove(l.path)
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// 去重、形态校验,挡掉「AI 的回答」冒充「用户的话」。
|
|
14
14
|
//
|
|
15
15
|
// 与 `--summarize-title`(internal/session/title.go::Summarize)的分工:那条把会话首问压成
|
|
16
|
-
//
|
|
16
|
+
// 标题、结果落进标题表;这条只产出一组**临时**建议,core 侧不落盘 —— 同一轮的重复请求
|
|
17
|
+
// 由 magic-client 插件层缓存(internal/client/suggest.go),下一轮问答换了内容自然重算。
|
|
17
18
|
package suggest
|
|
18
19
|
|
|
19
20
|
import (
|
|
@@ -51,6 +52,12 @@ type Request struct {
|
|
|
51
52
|
Engine string
|
|
52
53
|
// Model 模型标识(空 = 引擎默认)。
|
|
53
54
|
Model string
|
|
55
|
+
// FallbackEngine / FallbackModel 用户配的备用引擎(2026-10-24)。
|
|
56
|
+
//
|
|
57
|
+
// 主引擎失败时**优先**试它,再落到内置的 llm 兜底;空 = 直接用内置兜底。
|
|
58
|
+
// 这样界面上「建议引擎」也能配主 / 备,与默认引擎、团队模式同一套语义。
|
|
59
|
+
FallbackEngine string
|
|
60
|
+
FallbackModel string
|
|
54
61
|
// Count 最多几条(<=0 = DefaultCount)。
|
|
55
62
|
Count int
|
|
56
63
|
// Timeout 单次调用超时(<=0 = DefaultTimeout)。
|
|
@@ -205,6 +212,21 @@ func Generate(ctx context.Context, in Request) (Result, error) {
|
|
|
205
212
|
if err == nil {
|
|
206
213
|
return res, nil
|
|
207
214
|
}
|
|
215
|
+
/* 用户配的备用引擎:优先于内置 llm 兜底(2026-10-24)。它更贴近用户的意图
|
|
216
|
+
(例如「建议用 codebuddy,额度不够就换 dsh」),llm 只是最后的保底。 */
|
|
217
|
+
if fb := strings.TrimSpace(in.FallbackEngine); fb != "" && !strings.EqualFold(fb, name) {
|
|
218
|
+
if agent.Lookup(fb) != nil {
|
|
219
|
+
remain := timeout - time.Since(start)
|
|
220
|
+
if remain < 2*time.Second {
|
|
221
|
+
remain = 2 * time.Second
|
|
222
|
+
}
|
|
223
|
+
fbIn := in
|
|
224
|
+
fbIn.Model = strings.TrimSpace(in.FallbackModel)
|
|
225
|
+
if fbRes, ferr := tryGenerate(cctx, fbIn, fb, remain, start); ferr == nil {
|
|
226
|
+
return fbRes, nil
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
208
230
|
if name == DefaultEngine {
|
|
209
231
|
return Result{}, err
|
|
210
232
|
}
|
package/internal/team/event.go
CHANGED
|
@@ -6,7 +6,9 @@ package team
|
|
|
6
6
|
// · 计划 → 一条 notice(一行提示 + 计划文档路径),任务本身在画布上表现为
|
|
7
7
|
// 后续的委派卡,不需要一张额外的「计划卡」;
|
|
8
8
|
// · 每个子任务 → tool_use(name=Task) 开卡 + 带 Parent 的子事件 + tool_result 收卡;
|
|
9
|
-
// ·
|
|
9
|
+
// · 降级 → `fallback` 帧(2026-10-26):谁换成了谁,客户端据此把子任务标成
|
|
10
|
+
// 「备用作答」(以前只有一行 notice,主备到底生效没有无从判断);
|
|
11
|
+
// · 汇总 → notice。
|
|
10
12
|
//
|
|
11
13
|
// 于是客户端(三端共用同一份渲染内核)**零改动**就能画出团队模式 —— 它看起来
|
|
12
14
|
// 就是主代理连续委派了几次。要加「计划总览卡」时再补新 kind,那是一次独立的
|
package/internal/team/execute.go
CHANGED
|
@@ -153,7 +153,11 @@ func runTask(ctx context.Context, opts Options, plan Plan, t Task, runDir string
|
|
|
153
153
|
got.ID, got.Kind, got.Title = t.ID, t.EffectiveKind(), t.Title
|
|
154
154
|
got.Role = t.Role
|
|
155
155
|
got.OutputPath = OutputPath(runDir, t.ID)
|
|
156
|
-
|
|
156
|
+
// 引擎标签以**尝试自己记的**为准:runAttempt 在备用接上时已改成备用那一路,
|
|
157
|
+
// 这里若再盖回主引擎,用户就看不到「这一行是备用给的」。
|
|
158
|
+
if got.Engine == "" {
|
|
159
|
+
got.Engine, got.Model = eng.Name(), route.Model
|
|
160
|
+
}
|
|
157
161
|
got.Attempts = attempt
|
|
158
162
|
r = got
|
|
159
163
|
|
|
@@ -235,7 +239,7 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
|
|
|
235
239
|
board.setSession(t.ID, id, "")
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
res, err :=
|
|
242
|
+
res, fbInfo, err := agent.StreamWithFallback(tctx, eng, agent.Request{
|
|
239
243
|
Engine: eng.Name(),
|
|
240
244
|
Model: route.Model,
|
|
241
245
|
// 角色片段追加在主系统提示词之后(见 role.go);未设角色时与原来一致。
|
|
@@ -249,7 +253,11 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
|
|
|
249
253
|
Permission: permissionFor(opts, t),
|
|
250
254
|
PermissionOptions: opts.PermissionOptions,
|
|
251
255
|
OnSessionID: setSID,
|
|
252
|
-
|
|
256
|
+
// 主引擎只有过程、没有正文时降级到这一路的备用(额度用尽 / 单模型故障)。
|
|
257
|
+
// 降级发生时 agent 层已经发过 fallback 帧(下面原样交给子会话)。
|
|
258
|
+
FallbackEngine: route.FallbackEngine,
|
|
259
|
+
FallbackModel: route.FallbackModel,
|
|
260
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
253
261
|
}, func(ev agent.StreamEvent) {
|
|
254
262
|
if ev.SessionID != "" {
|
|
255
263
|
setSID(ev.SessionID)
|
|
@@ -270,6 +278,12 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
|
|
|
270
278
|
}
|
|
271
279
|
board.touch(t.ID, ev)
|
|
272
280
|
})
|
|
281
|
+
/* 备用接上了:任务结果如实记成「谁给出的答案」(主备没生效时用户只能看到一个
|
|
282
|
+
失败的子任务,分不清是没配还是配了没试)。只改展示字段,State 由下面的
|
|
283
|
+
err 决定 —— 备用成功时 err 必然为 nil。 */
|
|
284
|
+
if fbInfo.Used {
|
|
285
|
+
r.Engine, r.Model = fbInfo.Engine, fbInfo.Model
|
|
286
|
+
}
|
|
273
287
|
|
|
274
288
|
if err != nil {
|
|
275
289
|
r.State = TaskFailed
|
|
@@ -396,8 +410,7 @@ func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Rou
|
|
|
396
410
|
r.Engine, r.Model = eng.Name(), route.Model
|
|
397
411
|
rctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
|
|
398
412
|
defer cancel()
|
|
399
|
-
|
|
400
|
-
resp, err := runner.Run(rctx, agent.Request{
|
|
413
|
+
resp, _, err := agent.RunWithFallback(rctx, eng, agent.Request{
|
|
401
414
|
Engine: eng.Name(),
|
|
402
415
|
Model: route.Model,
|
|
403
416
|
SystemPrompt: systemPromptFor(opts, t),
|
|
@@ -406,8 +419,10 @@ func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Rou
|
|
|
406
419
|
Reasoning: opts.Reasoning,
|
|
407
420
|
Permission: permissionFor(opts, t),
|
|
408
421
|
PermissionOptions: opts.PermissionOptions,
|
|
422
|
+
FallbackEngine: route.FallbackEngine,
|
|
423
|
+
FallbackModel: route.FallbackModel,
|
|
409
424
|
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
410
|
-
})
|
|
425
|
+
}, agent.RunOptions{Timeout: taskTimeout(opts)}, nil)
|
|
411
426
|
r.SessionID = resp.SessionID
|
|
412
427
|
r.Text = resp.Text
|
|
413
428
|
collectDeliverables(TaskDir(runDir, t.ID), t.Deliverables, &r)
|