mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 10:32:36 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2688f18aa4 | ||
|
|
78d668a2f2 | ||
|
|
e2103a240d |
@@ -159,14 +159,14 @@ Agentic coding CLI using the ACP protocol over stdio (shares the transport with
|
||||
|
||||
### Antigravity (Google)
|
||||
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Multica launches it with `agy -p`, the daemon-compatible non-interactive mode; current Antigravity CLI releases can execute tools from that mode, while `agy -i` requires an attached TTY. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `agy` |
|
||||
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
|
||||
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
|
||||
|
||||
## After installing
|
||||
|
||||
|
||||
@@ -159,14 +159,14 @@ ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用,MCP 配置
|
||||
|
||||
### Antigravity(Google)
|
||||
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动它,这是适合 daemon 后台任务的一次性非交互模式;当前 Antigravity CLI 在这个模式下仍可执行工具,而 `agy -i` 需要连接 TTY,不适合 daemon 驱动。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `agy` |
|
||||
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
|
||||
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 过程和最终回复都会作为 text 消息送回 Multica,目前无法展示 Antigravity 的逐工具 telemetry。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|
||||
### Antigravity
|
||||
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
### Claude Code
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flag(agy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug;而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动 Antigravity,因为这是适合 daemon 后台任务的一次性非交互模式;`agy -i` 需要连接 TTY,不适合后台执行。当前 Antigravity CLI 在 `agy -p` 下仍可执行工具,但 stdout 是纯文本而非结构化事件流,所以 Multica 会把 transcript 作为 text 转发,暂时无法展示逐工具 telemetry。**会话恢复真用**——通过 `--conversation <id>`,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flag(agy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug;而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
### Claude Code
|
||||
|
||||
|
||||
@@ -1833,15 +1833,23 @@ func (s *TaskService) HandleFailedTasks(ctx context.Context, tasks []db.AgentTas
|
||||
"error", checkErr,
|
||||
)
|
||||
} else if !hasActive {
|
||||
if _, updateErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
updatedIssue, updateErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: t.IssueID,
|
||||
Status: "todo",
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
}); updateErr != nil {
|
||||
})
|
||||
if updateErr != nil {
|
||||
slog.Warn("handle failed tasks: reset stuck issue failed",
|
||||
"issue_id", issueKey,
|
||||
"error", updateErr,
|
||||
)
|
||||
} else {
|
||||
// This direct reset bypasses the HTTP UpdateIssue
|
||||
// handler that normally emits issue:updated, so emit
|
||||
// it here too. Without it the board / status-filter
|
||||
// caches keep showing the issue as in_progress until
|
||||
// the next write touches it (#4648 / MUL-3782).
|
||||
s.broadcastIssueUpdated(updatedIssue, issue.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2261,14 +2269,32 @@ func (s *TaskService) broadcastChatDone(ctx context.Context, task db.AgentTaskQu
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TaskService) broadcastIssueUpdated(issue db.Issue) {
|
||||
// broadcastIssueUpdated publishes the issue:updated event the frontend's
|
||||
// realtime reconcile (onIssueUpdated) relies on to move an issue between status
|
||||
// columns / status filters and reconcile their bucket counts. prevStatus is the
|
||||
// issue's status before the write so the client can gate that reconcile on
|
||||
// status_changed.
|
||||
//
|
||||
// The `issue` payload is a map (issueToMap), which the workspace WS fanout
|
||||
// (listeners.go SubscribeAll) marshals and broadcasts as-is — that is what
|
||||
// drives the UI reconcile. Note this does NOT cover the full HTTP UpdateIssue
|
||||
// side effects: the activity-log and inbox listeners type-assert `issue` to a
|
||||
// handler.IssueResponse and skip a map, so a background status reset does not
|
||||
// emit status-change activity / notifications. That is intentional for the
|
||||
// realtime-staleness fix (#4648 / MUL-3782); folding those side effects in
|
||||
// would mean unifying the payload type and is left as a follow-up.
|
||||
func (s *TaskService) broadcastIssueUpdated(issue db.Issue, prevStatus string) {
|
||||
prefix := s.getIssuePrefix(issue.WorkspaceID)
|
||||
s.Bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueUpdated,
|
||||
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
|
||||
ActorType: "system",
|
||||
ActorID: "",
|
||||
Payload: map[string]any{"issue": issueToMap(issue, prefix)},
|
||||
Payload: map[string]any{
|
||||
"issue": issueToMap(issue, prefix),
|
||||
"status_changed": prevStatus != issue.Status,
|
||||
"prev_status": prevStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
119
server/internal/service/task_issue_broadcast_test.go
Normal file
119
server/internal/service/task_issue_broadcast_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// noRowsDBTX makes every read return pgx.ErrNoRows so getIssuePrefix's
|
||||
// GetWorkspace lookup falls back to an empty prefix without needing a DB. The
|
||||
// helper under test still publishes regardless of the prefix result.
|
||||
type noRowsDBTX struct{}
|
||||
|
||||
func (noRowsDBTX) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
|
||||
return pgconn.NewCommandTag(""), nil
|
||||
}
|
||||
func (noRowsDBTX) Query(context.Context, string, ...any) (pgx.Rows, error) {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
func (noRowsDBTX) QueryRow(context.Context, string, ...any) pgx.Row { return noRow{} }
|
||||
|
||||
type noRow struct{}
|
||||
|
||||
func (noRow) Scan(...any) error { return pgx.ErrNoRows }
|
||||
|
||||
// TestBroadcastIssueUpdated_EmitsStatusChange pins the realtime contract behind
|
||||
// #4648 / MUL-3782: when a background path resets an issue's status (e.g. the
|
||||
// failed-task handler flipping a stuck in_progress issue back to todo), it must
|
||||
// publish issue:updated with status_changed=true and the new status so the
|
||||
// frontend's onIssueUpdated reconcile moves the card between status columns /
|
||||
// filters instead of leaving it stale until the next unrelated write.
|
||||
func TestBroadcastIssueUpdated_EmitsStatusChange(t *testing.T) {
|
||||
bus := events.New()
|
||||
var got []events.Event
|
||||
bus.SubscribeAll(func(e events.Event) { got = append(got, e) })
|
||||
|
||||
svc := &TaskService{
|
||||
Queries: db.New(noRowsDBTX{}),
|
||||
Bus: bus,
|
||||
}
|
||||
|
||||
issue := db.Issue{
|
||||
ID: testUUID(1),
|
||||
WorkspaceID: testUUID(2),
|
||||
Number: 7,
|
||||
Status: "todo",
|
||||
}
|
||||
svc.broadcastIssueUpdated(issue, "in_progress")
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly 1 published event, got %d", len(got))
|
||||
}
|
||||
e := got[0]
|
||||
if e.Type != protocol.EventIssueUpdated {
|
||||
t.Fatalf("expected event type %q, got %q", protocol.EventIssueUpdated, e.Type)
|
||||
}
|
||||
if e.WorkspaceID != util.UUIDToString(issue.WorkspaceID) {
|
||||
t.Fatalf("workspace mismatch: got %q want %q", e.WorkspaceID, util.UUIDToString(issue.WorkspaceID))
|
||||
}
|
||||
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload is not map[string]any: %T", e.Payload)
|
||||
}
|
||||
if payload["status_changed"] != true {
|
||||
t.Errorf("expected status_changed=true, got %v", payload["status_changed"])
|
||||
}
|
||||
if payload["prev_status"] != "in_progress" {
|
||||
t.Errorf("expected prev_status=in_progress, got %v", payload["prev_status"])
|
||||
}
|
||||
issueMap, ok := payload["issue"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("issue payload is not map[string]any: %T", payload["issue"])
|
||||
}
|
||||
if issueMap["status"] != "todo" {
|
||||
t.Errorf("expected issue.status=todo, got %v", issueMap["status"])
|
||||
}
|
||||
if issueMap["id"] != util.UUIDToString(issue.ID) {
|
||||
t.Errorf("issue.id mismatch: got %v want %q", issueMap["id"], util.UUIDToString(issue.ID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastIssueUpdated_NoStatusChange guards the gate: a same-status
|
||||
// broadcast reports status_changed=false so the client skips the status-bucket
|
||||
// reconcile for non-status field updates.
|
||||
func TestBroadcastIssueUpdated_NoStatusChange(t *testing.T) {
|
||||
bus := events.New()
|
||||
var got []events.Event
|
||||
bus.SubscribeAll(func(e events.Event) { got = append(got, e) })
|
||||
|
||||
svc := &TaskService{
|
||||
Queries: db.New(noRowsDBTX{}),
|
||||
Bus: bus,
|
||||
}
|
||||
|
||||
issue := db.Issue{
|
||||
ID: testUUID(1),
|
||||
WorkspaceID: testUUID(2),
|
||||
Status: "todo",
|
||||
}
|
||||
svc.broadcastIssueUpdated(issue, "todo")
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly 1 published event, got %d", len(got))
|
||||
}
|
||||
payload, ok := got[0].Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload is not map[string]any: %T", got[0].Payload)
|
||||
}
|
||||
if payload["status_changed"] != false {
|
||||
t.Errorf("expected status_changed=false, got %v", payload["status_changed"])
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
||||
// environment variables are deliberately omitted so the string is a hint
|
||||
// about *what* users are extending, not a dump of the full command line.
|
||||
var launchHeaders = map[string]string{
|
||||
"antigravity": "agy -p (print mode)",
|
||||
"antigravity": "agy -p (non-interactive)",
|
||||
"claude": "claude (stream-json)",
|
||||
"codebuddy": "codebuddy (stream-json)",
|
||||
"codex": "codex app-server",
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -115,6 +116,18 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderAntigravityAvoidsTextOnlyPrintModeLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
header := LaunchHeader("antigravity")
|
||||
if header != "agy -p (non-interactive)" {
|
||||
t.Fatalf("unexpected Antigravity launch header: %q", header)
|
||||
}
|
||||
if strings.Contains(header, "print mode") {
|
||||
t.Fatalf("Antigravity launch header must not imply a text-only mode: %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
if header := LaunchHeader("made-up-agent"); header != "" {
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
)
|
||||
|
||||
// antigravityBackend implements Backend by spawning Google's Antigravity CLI
|
||||
// (`agy -p <prompt>`) in non-interactive print mode. Unlike Claude / Codex /
|
||||
// Cursor / Gemini, the Antigravity CLI does not expose a structured event
|
||||
// stream — stdout is plain assistant text (intermediate "I will run X" lines
|
||||
// and the final reply, all interleaved). The backend therefore streams stdout
|
||||
// line-by-line as `MessageText` events and accumulates the same text as the
|
||||
// final `Result.Output`.
|
||||
// with a one-shot prompt (`agy -p <prompt>`). Despite the upstream flag name,
|
||||
// current agy print mode is still capable of running Antigravity tools; it is
|
||||
// the daemon-compatible mode because `agy -i` requires an attached TTY. Unlike
|
||||
// Claude / Codex / Cursor / Gemini, the Antigravity CLI does not expose a
|
||||
// structured event stream — stdout is plain assistant text (intermediate "I
|
||||
// will run X" lines and the final reply, all interleaved). The backend
|
||||
// therefore streams stdout line-by-line as `MessageText` events and accumulates
|
||||
// the same text as the final `Result.Output`.
|
||||
//
|
||||
// Session resumption uses `--conversation <id>`. The conversation id is not
|
||||
// emitted on stdout; we capture it by routing `--log-file` to a temp file and
|
||||
@@ -154,7 +156,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex
|
||||
// success the user can't distinguish from a finished task (MUL-3570).
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf(
|
||||
"agy print mode timed out after %s waiting for the agent response; a long-running command likely outlived --print-timeout",
|
||||
"agy --print-timeout elapsed after %s waiting for the agent response; a long-running command likely outlived the print timeout",
|
||||
antigravityPrintTimeout(timeout),
|
||||
)
|
||||
} else if providerErr := antigravityProviderError(logPath); finalStatus == "completed" && providerErr != "" {
|
||||
@@ -270,7 +272,7 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
|
||||
"-p": blockedWithValue,
|
||||
"--print": blockedWithValue,
|
||||
"--prompt": blockedWithValue,
|
||||
"-i": blockedStandalone, // interactive mode would block the daemon
|
||||
"-i": blockedStandalone, // interactive mode requires a TTY and cannot run under the daemon
|
||||
"--prompt-interactive": blockedStandalone,
|
||||
"-c": blockedStandalone, // resume via --conversation, not --continue
|
||||
"--continue": blockedStandalone,
|
||||
@@ -281,7 +283,8 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
|
||||
"--log-file": blockedWithValue, // daemon needs it for session capture
|
||||
}
|
||||
|
||||
// buildAntigravityArgs assembles the argv for a one-shot agy invocation.
|
||||
// buildAntigravityArgs assembles the argv for a daemon-compatible one-shot agy
|
||||
// invocation.
|
||||
//
|
||||
// agy -p <prompt> --dangerously-skip-permissions [--model <display name>]
|
||||
// --print-timeout <duration> --log-file <tmp>
|
||||
|
||||
@@ -219,6 +219,8 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
|
||||
// resume-aware operation.
|
||||
CustomArgs: []string{
|
||||
"-p", "hijacked-prompt",
|
||||
"-i",
|
||||
"--prompt-interactive",
|
||||
"--continue",
|
||||
"-c",
|
||||
"--conversation", "bad-id",
|
||||
@@ -247,6 +249,9 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
|
||||
if strings.Contains(joined, "hijacked-prompt") {
|
||||
t.Errorf("custom -p value leaked through filter: %v", args)
|
||||
}
|
||||
if strings.Contains(joined, "-i") || strings.Contains(joined, "--prompt-interactive") {
|
||||
t.Errorf("interactive-mode flags leaked through filter: %v", args)
|
||||
}
|
||||
if strings.Contains(joined, "bad-id") {
|
||||
t.Errorf("custom --conversation value leaked through filter: %v", args)
|
||||
}
|
||||
@@ -389,8 +394,8 @@ func TestAntigravityBackendPrintTimeoutSurfacesAsTimeout(t *testing.T) {
|
||||
if result.Status != "timeout" {
|
||||
t.Fatalf("expected status=timeout, got %q (error=%q)", result.Status, result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "print mode timed out") {
|
||||
t.Errorf("expected error to explain the print-mode timeout, got %q", result.Error)
|
||||
if !strings.Contains(result.Error, "agy --print-timeout elapsed") {
|
||||
t.Errorf("expected error to explain the agy print timeout, got %q", result.Error)
|
||||
}
|
||||
// Narration streamed before the cut-off must still reach the result so
|
||||
// the user sees how far the turn got.
|
||||
|
||||
@@ -804,8 +804,31 @@ func writeMcpConfigToTemp(raw json.RawMessage) (string, error) {
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// versionDetectTimeout bounds how long a single `<cli> --version` probe may
|
||||
// run. A healthy CLI answers in well under a second; a much longer wait means
|
||||
// the binary is wedged — e.g. a Homebrew-installed `claude` whose bun shim
|
||||
// never returns (MUL-3812). The bound matters because runtime registration
|
||||
// probes every configured agent sequentially (registerRuntimesForWorkspace),
|
||||
// and that loop runs on the daemon's startup critical path before /health
|
||||
// flips from "starting" to "running". Without a bound, one wedged CLI blocks
|
||||
// the loop forever: no other runtime gets registered, the daemon never reports
|
||||
// ready, and the desktop is stuck on "starting". With the bound, the wedged
|
||||
// probe returns a deadline error, the loop skips just that runtime, and every
|
||||
// healthy runtime still comes online.
|
||||
//
|
||||
// A var (not const) so tests can shorten it; nothing else mutates it.
|
||||
var versionDetectTimeout = 10 * time.Second
|
||||
|
||||
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, versionDetectTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, execPath, "--version")
|
||||
// A wedged CLI may fork a child (e.g. the bun runtime behind `claude`) that
|
||||
// inherits and keeps the stdout pipe open. Killing the CLI on context
|
||||
// cancellation would not unblock cmd.Output(), which waits for that pipe to
|
||||
// close. WaitDelay force-closes the pipes shortly after the context fires so
|
||||
// the probe always returns instead of hanging on the surviving grandchild.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
hideAgentWindow(cmd)
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseSemver(t *testing.T) {
|
||||
@@ -138,6 +143,65 @@ func TestExtractVersionLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCLIVersionHealthy(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script fixture is POSIX-only")
|
||||
}
|
||||
script := filepath.Join(t.TempDir(), "fakecli")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\necho '2.1.5 (Claude Code)'\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := detectCLIVersion(context.Background(), script)
|
||||
if err != nil {
|
||||
t.Fatalf("detectCLIVersion() error = %v", err)
|
||||
}
|
||||
if want := "2.1.5 (Claude Code)"; got != want {
|
||||
t.Errorf("detectCLIVersion() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCLIVersionTimesOutOnWedgedCLI reproduces MUL-3812: a `--version`
|
||||
// probe that never returns must not block the caller. Even with an unbounded
|
||||
// parent context (which is what registerRuntimesForWorkspace passes), the probe
|
||||
// bounds itself and returns an error, so the sequential runtime-registration
|
||||
// loop can skip the wedged CLI and bring every healthy runtime online instead
|
||||
// of leaving the desktop stuck on "starting".
|
||||
func TestDetectCLIVersionTimesOutOnWedgedCLI(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script fixture is POSIX-only")
|
||||
}
|
||||
script := filepath.Join(t.TempDir(), "wedgedcli")
|
||||
// Sleeps far longer than the probe timeout and never prints a version —
|
||||
// models a Homebrew/bun `claude` whose `--version` hangs. The backgrounded
|
||||
// `sleep` also inherits the stdout pipe and outlives a kill of the shell,
|
||||
// exercising the WaitDelay path that force-closes the pipe.
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orig := versionDetectTimeout
|
||||
versionDetectTimeout = 200 * time.Millisecond
|
||||
defer func() { versionDetectTimeout = orig }()
|
||||
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() {
|
||||
_, err := detectCLIVersion(context.Background(), script)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("detectCLIVersion() returned nil for a wedged CLI; want a timeout error")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||
t.Errorf("detectCLIVersion() took %v; expected to bound near versionDetectTimeout", elapsed)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("detectCLIVersion() did not return; a wedged CLI blocked the probe (regression of MUL-3812)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMinVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
agentType string
|
||||
|
||||
Reference in New Issue
Block a user