feat(deploy): 画布 Agent - 独立 yingce-agent 容器并修复运行中断

生产会话不再因 stdout 管道提前关闭被误判失败。设置 YINGCE_AGENT_URL 后只走独立容器,失败不回退到内嵌进程。
This commit is contained in:
ddcat666
2026-09-29 21:41:36 +08:00
parent 3fb363aad2
commit cb146d27a1
25 changed files with 821 additions and 101 deletions

View File

@@ -0,0 +1,83 @@
package runtime
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeRuntime 用一个假的 agent-runtime.mjs 替换真实运行时,只验证 Go 侧的进程与输出处理。
func fakeRuntime(t *testing.T, script string) {
t.Helper()
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node is not installed")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "agent-runtime.mjs"), []byte(script), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CANVAS_PI_RUNTIME_DIR", dir)
}
func noopBridge() Bridge {
handler := func(context.Context, map[string]json.RawMessage) (any, error) { return map[string]any{"ok": true}, nil }
return Bridge{Model: handler, Tool: handler, Event: handler}
}
// 回归:Wait 早于读完 stdout 时,大量输出后正常退出会被判成
// "read |0: file already closed"。
func TestRunDrainsStdoutBeforeWait(t *testing.T) {
fakeRuntime(t, `
const line = JSON.stringify({ event: "progress", pad: "x".repeat(2048) }) + "\n";
for (let i = 0; i < 2000; i++) process.stdout.write(line);
process.stdout.write(JSON.stringify({ event: "settled" }) + "\n");
`)
for i := 0; i < 5; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := Run(ctx, ProcessRequest{Model: map[string]any{"id": "m"}}, noopBridge())
cancel()
if err != nil {
t.Fatalf("attempt %d: normal exit reported as failure: %v", i, err)
}
}
}
// runtime_error 之后仍要读到 EOF,并把该错误作为运行结果返回。
func TestRunReportsRuntimeErrorAfterDrainingOutput(t *testing.T) {
fakeRuntime(t, `
process.stdout.write(JSON.stringify({ event: "runtime_error", message: "boom" }) + "\n");
const line = JSON.stringify({ event: "progress", pad: "x".repeat(2048) }) + "\n";
for (let i = 0; i < 500; i++) process.stdout.write(line);
process.exitCode = 1;
`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := Run(ctx, ProcessRequest{Model: map[string]any{"id": "m"}}, noopBridge())
if err == nil || err.Error() != "boom" {
t.Fatalf("want runtime_error message, got %v", err)
}
}
// 子进程只能拿到白名单环境变量,拿不到数据库连接等密钥。
func TestRunDoesNotLeakBackendEnvironment(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://secret")
fakeRuntime(t, `
if (process.env.DATABASE_URL) {
process.stdout.write(JSON.stringify({ event: "runtime_error", message: "leaked" }) + "\n");
process.exitCode = 1;
}
`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := Run(ctx, ProcessRequest{Model: map[string]any{"id": "m"}}, noopBridge()); err != nil {
if strings.Contains(err.Error(), "leaked") {
t.Fatal("backend environment leaked into the Agent runtime")
}
t.Fatal(err)
}
}