You've already forked agentic-coding-workflow
启动项目逻辑
This commit is contained in:
@@ -99,6 +99,15 @@ const (
|
||||
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
||||
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
||||
CodeMcpRateLimited Code = "mcp.rate_limited"
|
||||
|
||||
// Run 域(workspace run profiles)
|
||||
CodeRunProfileNotFound Code = "run.profile_not_found"
|
||||
CodeRunProfileSlugTaken Code = "run.profile_slug_taken"
|
||||
CodeRunProfileAlreadyRunning Code = "run.profile_already_running"
|
||||
CodeRunProfileNotRunning Code = "run.profile_not_running"
|
||||
CodeRunProfileDisabled Code = "run.profile_disabled"
|
||||
CodeRunSpawnFailed Code = "run.spawn_failed"
|
||||
CodeRunCommandInvalid Code = "run.command_invalid"
|
||||
)
|
||||
|
||||
// AppError is the application's structured error type. It carries a stable
|
||||
@@ -239,6 +248,15 @@ func HTTPStatus(code Code) int {
|
||||
return http.StatusBadRequest
|
||||
case CodeMcpRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case CodeRunProfileNotFound:
|
||||
return http.StatusNotFound
|
||||
case CodeRunProfileSlugTaken, CodeRunProfileAlreadyRunning,
|
||||
CodeRunProfileNotRunning, CodeRunProfileDisabled:
|
||||
return http.StatusConflict
|
||||
case CodeRunCommandInvalid:
|
||||
return http.StatusBadRequest
|
||||
case CodeRunSpawnFailed:
|
||||
return http.StatusBadGateway
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||
}
|
||||
|
||||
type WorkspaceRunProfile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||
LastExitCode *int32 `json:"last_exit_code"`
|
||||
LastError string `json:"last_error"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package proc 提供跨平台的"进程组/进程树"终止抽象。一个 Group 把某个进程及其
|
||||
// 派生的所有子孙进程作为单一单元管理,使 stop/restart/crash 时能整树清理,避免
|
||||
// dev server(如 pnpm dev → node/vite/esbuild)留下占用端口的孤儿进程。
|
||||
//
|
||||
// 用法与 *exec.Cmd 生命周期对齐:
|
||||
//
|
||||
// g := proc.NewGroup()
|
||||
// g.Prepare(cmd) // cmd.Start() 之前:Unix 设置 SysProcAttr.Setpgid
|
||||
// cmd.Start()
|
||||
// g.Adopt(cmd) // cmd.Start() 之后:Windows 创建 Job 并 Assign
|
||||
// ...
|
||||
// g.TerminateGroup(done, grace) // 整树优雅终止
|
||||
// g.Close() // 释放平台句柄
|
||||
package proc
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Group 抽象"一个进程及其所有子孙进程"作为单一单元管理。
|
||||
// 非并发安全:调用方需保证 Prepare/Adopt/TerminateGroup/Close 的串行使用
|
||||
// (RunSupervisor / ACP Supervisor 已用各自的锁与 done channel 保证)。
|
||||
type Group interface {
|
||||
// Prepare 在 cmd.Start() 之前设置平台属性(Unix: SysProcAttr.Setpgid)。
|
||||
Prepare(cmd *exec.Cmd)
|
||||
// Adopt 在 cmd.Start() 之后接管已启动的进程(Windows: 创建 Job 并
|
||||
// AssignProcessToJobObject)。Unix 为记录 pgid。必须在子进程派生前调用。
|
||||
Adopt(cmd *exec.Cmd) error
|
||||
// TerminateGroup 优雅终止整组:Unix 先 SIGTERM(-pgid) 等 grace 再 SIGKILL(-pgid);
|
||||
// Windows CloseHandle(job) 触发 KILL_ON_JOB_CLOSE 整树终止。
|
||||
// done 由调用方(monitor goroutine)关闭,用于感知优雅退出、提前返回。
|
||||
TerminateGroup(done <-chan struct{}, grace time.Duration)
|
||||
// Close 释放平台句柄(Windows: 关闭 Job handle;Unix: no-op)。幂等。
|
||||
Close()
|
||||
}
|
||||
|
||||
// NewGroup 按编译期 build tag 返回 unixGroup / windowsGroup。
|
||||
func NewGroup() Group { return newGroup() }
|
||||
@@ -0,0 +1,54 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sleeperCmd 返回一个会阻塞约 d 秒的跨平台命令。
|
||||
func sleeperCmd(d int) *exec.Cmd {
|
||||
if runtime.GOOS == "windows" {
|
||||
return exec.Command("cmd", "/c", fmt.Sprintf("timeout /t %d /nobreak >nul", d))
|
||||
}
|
||||
return exec.Command("sh", "-c", fmt.Sprintf("sleep %d", d))
|
||||
}
|
||||
|
||||
// TestGroup_TerminateKillsStartedProcess 验证完整生命周期:Prepare → Start →
|
||||
// Adopt → TerminateGroup 能在 grace 内终止一个本应长跑的进程。
|
||||
func TestGroup_TerminateKillsStartedProcess(t *testing.T) {
|
||||
g := NewGroup()
|
||||
cmd := sleeperCmd(30)
|
||||
g.Prepare(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := g.Adopt(cmd); err != nil {
|
||||
t.Fatalf("adopt: %v", err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
g.TerminateGroup(done, 2*time.Second)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// 进程已终止,符合预期。
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("process not terminated within 10s after TerminateGroup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewGroup_NotNil 守护 build tag 选择逻辑:任意平台都应返回非 nil Group。
|
||||
func TestNewGroup_NotNil(t *testing.T) {
|
||||
if NewGroup() == nil {
|
||||
t.Fatal("NewGroup returned nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build !windows
|
||||
|
||||
package proc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// unixGroup 用 POSIX 进程组实现整树终止:子进程经 Setpgid 成为新进程组组长
|
||||
// (pgid == pid),向负 pgid 发信号即覆盖整个进程组的所有子孙。
|
||||
type unixGroup struct {
|
||||
pgid int
|
||||
}
|
||||
|
||||
func newGroup() Group { return &unixGroup{} }
|
||||
|
||||
func (g *unixGroup) Prepare(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
}
|
||||
|
||||
func (g *unixGroup) Adopt(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return errors.New("proc: Adopt before Start")
|
||||
}
|
||||
// Setpgid 使子进程成为新进程组组长,pgid == pid。
|
||||
g.pgid = cmd.Process.Pid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *unixGroup) TerminateGroup(done <-chan struct{}, grace time.Duration) {
|
||||
if g.pgid <= 0 {
|
||||
return
|
||||
}
|
||||
// 向负 pgid 发信号 = 向整个进程组(含所有子孙)发信号。忽略 ESRCH 等错误。
|
||||
_ = syscall.Kill(-g.pgid, syscall.SIGTERM)
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(grace):
|
||||
_ = syscall.Kill(-g.pgid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *unixGroup) Close() {}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build !windows
|
||||
|
||||
package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestUnixGroup_KillsChildProcess 验证整树清理:父 shell 派生一个后台 sleep
|
||||
// 子进程并打印其 PID,TerminateGroup 后该子进程(孙级)也应被 kill(-pgid) 回收,
|
||||
// 证明终止覆盖整个进程组而非仅组长。
|
||||
func TestUnixGroup_KillsChildProcess(t *testing.T) {
|
||||
g := NewGroup()
|
||||
// `sleep 30 &` 后台子进程;`echo $!` 打印其 PID;`wait` 让 shell 阻塞存活。
|
||||
cmd := exec.Command("sh", "-c", "sleep 30 & echo $!; wait")
|
||||
g.Prepare(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := g.Adopt(cmd); err != nil {
|
||||
t.Fatalf("adopt: %v", err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
var childPID int
|
||||
if _, err := fmt.Fscan(stdout, &childPID); err != nil {
|
||||
t.Fatalf("read child pid: %v", err)
|
||||
}
|
||||
if childPID <= 0 {
|
||||
t.Fatalf("invalid child pid: %d", childPID)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
g.TerminateGroup(done, 2*time.Second)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("parent not terminated")
|
||||
}
|
||||
|
||||
// 给内核一点时间回收子进程,然后用 kill(pid, 0) 探测:返回错误(ESRCH)=已死。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if err := syscall.Kill(childPID, 0); err != nil {
|
||||
return // 子进程已死,符合预期。
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("child process %d still alive after group terminate", childPID)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package proc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsGroup 用 Job Object 实现整树终止:把进程纳入一个设置了
|
||||
// KILL_ON_JOB_CLOSE 的 Job,此后该进程派生的所有子孙自动归入同一 Job,
|
||||
// 关闭 Job 句柄即整树终止。优于 Process.Kill(只杀单 PID,漏 node 等子进程)。
|
||||
type windowsGroup struct {
|
||||
job windows.Handle
|
||||
}
|
||||
|
||||
func newGroup() Group { return &windowsGroup{} }
|
||||
|
||||
// Prepare 在 Windows 为 no-op:Job 须在进程 Start 之后才能 Assign。
|
||||
func (g *windowsGroup) Prepare(cmd *exec.Cmd) {}
|
||||
|
||||
// Adopt 必须在 cmd.Start() 之后、子进程派生之前调用。竞态窗口:父进程 exec 后
|
||||
// 到首个子进程派生之间存在微秒级窗口(Go 无 pre-exec hook 无法完全消除),
|
||||
// 实践风险极小,文档化为已知限制。
|
||||
func (g *windowsGroup) Adopt(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return errors.New("proc: Adopt before Start")
|
||||
}
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(
|
||||
job,
|
||||
windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return err
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return err
|
||||
}
|
||||
g.job = job
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateGroup 在 Windows 无可靠 SIGTERM 等价物,直接关闭 Job 句柄触发
|
||||
// KILL_ON_JOB_CLOSE 整树终止(grace/done 不参与,仅保持接口签名一致)。
|
||||
func (g *windowsGroup) TerminateGroup(_ <-chan struct{}, _ time.Duration) {
|
||||
g.Close()
|
||||
}
|
||||
|
||||
func (g *windowsGroup) Close() {
|
||||
if g.job != 0 {
|
||||
_ = windows.CloseHandle(g.job)
|
||||
g.job = 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user