fix: 收敛 queue runner 失败终态与 CLI 生命周期

This commit is contained in:
Codex
2026-06-01 23:33:08 +08:00
parent b19143ad85
commit 5104d402c7
6 changed files with 196 additions and 9 deletions
+8 -2
View File
@@ -34,7 +34,10 @@ CLI 默认输出 JSON。空 stdout 是失败,不是成功。每个命令都必
./scripts/agentrun runner start --run-id <runId> --backend <backendProfile> ./scripts/agentrun runner start --run-id <runId> --backend <backendProfile>
./scripts/agentrun runner job --run-id <runId> --command-id <commandId> [--idempotency-key <key>] ./scripts/agentrun runner job --run-id <runId> --command-id <commandId> [--idempotency-key <key>]
./scripts/agentrun backends list ./scripts/agentrun backends list
./scripts/agentrun server start|status ./scripts/agentrun server start [--port <port>] [--host <host>] [--foreground]
./scripts/agentrun server status [--port <port>]
./scripts/agentrun server logs [--port <port>] [--tail-bytes <bytes>] [--log-file <path>]
./scripts/agentrun server stop [--port <port>]
``` ```
行为必须保持以下规则: 行为必须保持以下规则:
@@ -43,7 +46,10 @@ CLI 默认输出 JSON。空 stdout 是失败,不是成功。每个命令都必
- `runner start` 启动本地进程或 Kubernetes Job,并返回 process/job identity、log path 和 poll commands。 - `runner start` 启动本地进程或 Kubernetes Job,并返回 process/job identity、log path 和 poll commands。
- `events` 默认分页且有界。 - `events` 默认分页且有界。
- `server logs` 返回有界日志,并指向完整日志文件。 - `server logs` 返回有界日志,并指向完整日志文件。
- `status` 在本地服务存在后必须暴露 port、process id、health 和 log paths - `server start` 默认后台启动本地 manager,短返回 pid、pidFile、logPath 和后续 status/stop 命令;`--foreground` 仅用于容器入口或显式调试
- `server status` 暴露 port、process id、health 和 log pathshealth 不通时仍返回结构化 JSON。
- `server logs` 返回有界日志尾部和截断元数据,不能要求人工直接打开日志文件排障。
- `server stop` 通过 pidFile 和端口进程清理本地 manager,返回 before/after 状态。
## 配置与日志 ## 配置与日志
+10 -2
View File
@@ -50,7 +50,10 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
./scripts/agentrun runner job-status [runnerJobId] --run-id <runId> ./scripts/agentrun runner job-status [runnerJobId] --run-id <runId>
./scripts/agentrun secrets codex render --dry-run [--profile codex|deepseek] [--codex-home <dir>] ./scripts/agentrun secrets codex render --dry-run [--profile codex|deepseek] [--codex-home <dir>]
./scripts/agentrun backends list ./scripts/agentrun backends list
./scripts/agentrun server start|status ./scripts/agentrun server start [--port <port>] [--host <host>] [--foreground]
./scripts/agentrun server status [--port <port>]
./scripts/agentrun server logs [--port <port>] [--tail-bytes <bytes>] [--log-file <path>]
./scripts/agentrun server stop [--port <port>]
./scripts/agentrun queue submit --json-file <task.json> ./scripts/agentrun queue submit --json-file <task.json>
./scripts/agentrun queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] ./scripts/agentrun queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>]
./scripts/agentrun queue show <taskId> ./scripts/agentrun queue show <taskId>
@@ -73,7 +76,10 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
- `runner jobs` / `runner job-status` 返回 manager 持久化的 runner Job 最小状态摘要,包括 attemptId、runnerId、namespace、jobName、phase、terminalStatus、logPath、retention 和 redacted Kubernetes identity;业务方不需要直连 Kubernetes 才能定位当前 attempt。 - `runner jobs` / `runner job-status` 返回 manager 持久化的 runner Job 最小状态摘要,包括 attemptId、runnerId、namespace、jobName、phase、terminalStatus、logPath、retention 和 redacted Kubernetes identity;业务方不需要直连 Kubernetes 才能定位当前 attempt。
- 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。 - 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。
- `events` 默认分页且有界,必须支持 `afterSeq``limit` - `events` 默认分页且有界,必须支持 `afterSeq``limit`
- `server logs` 返回有界日志摘要,并指向完整日志文件或 Kubernetes pod identity - `server start` 默认以本地后台进程启动 manager,立刻返回 pid、pidFile、logPath、baseUrl 和后续 `status/stop` 命令;只有显式 `--foreground` 才允许占用当前终端。启动前必须检查 pidFile 与端口占用,避免同一端口上堆叠临时 manager
- `server status` 必须同时返回本地 pid/port/logPath 状态和 `/health/readiness` 结果;即使 readiness 失败,也要输出结构化 JSON 和 failure details。
- `server logs` 必须返回有界日志尾部、bytes、truncated 和 logPath;找不到日志文件时也必须返回非空 JSON。
- `server stop` 必须按 pidFile 与端口进程清理本地 manager,并返回 before/after 状态;不得要求人工用 `ps/kill/ss` 组合命令清理常见临时服务。
- `secrets codex render --dry-run` 返回 Codex stdio profile Secret 创建计划、输入文件 bytes/hash、SecretRef、manifest 摘要和 apply 命令形状;`--profile codex` 默认 Secret name 为 `agentrun-v01-provider-codex``--profile deepseek` 默认 Secret name 为 `agentrun-v01-provider-deepseek`;它不得输出 Secret value 或执行 Kubernetes 写操作。 - `secrets codex render --dry-run` 返回 Codex stdio profile Secret 创建计划、输入文件 bytes/hash、SecretRef、manifest 摘要和 apply 命令形状;`--profile codex` 默认 Secret name 为 `agentrun-v01-provider-codex``--profile deepseek` 默认 Secret name 为 `agentrun-v01-provider-deepseek`;它不得输出 Secret value 或执行 Kubernetes 写操作。
- `backends list` 必须显示 `codex``deepseek` profile 的 backendKind、protocol、transport、command、requiredSecretKeys 和状态;不得因为 `deepseek` 尚未配置 Secret 就隐藏 capability。 - `backends list` 必须显示 `codex``deepseek` profile 的 backendKind、protocol、transport、command、requiredSecretKeys 和状态;不得因为 `deepseek` 尚未配置 Secret 就隐藏 capability。
- `queue dispatch` 是 Q2 的受控手动调度入口,只对单个 task 显式创建 attempt 和 Core run/command/runner job;不得伪装成自动 scheduler。 - `queue dispatch` 是 Q2 的受控手动调度入口,只对单个 task 显式创建 attempt 和 Core run/command/runner job;不得伪装成自动 scheduler。
@@ -125,6 +131,8 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
| runner jobs/job-status | 已实现 | CLI 通过 manager REST 查询 runner Job 持久记录和最小状态摘要,不直连 Kubernetes、不读取 Secret 值。 | | runner jobs/job-status | 已实现 | CLI 通过 manager REST 查询 runner Job 持久记录和最小状态摘要,不直连 Kubernetes、不读取 Secret 值。 |
| result/cancel CLI | 已实现 | `runs result``commands result``runs cancel``commands cancel` 均调用 manager REST,不维护独立状态。 | | result/cancel CLI | 已实现 | `runs result``commands result``runs cancel``commands cancel` 均调用 manager REST,不维护独立状态。 |
| Queue CLI | 已实现/Q1 | 已提供 `queue submit/list/show/stats/commander/read/cancel`,通过 manager REST 访问 Queue task 和 stats,不直连 Postgres。 | | Queue CLI | 已实现/Q1 | 已提供 `queue submit/list/show/stats/commander/read/cancel`,通过 manager REST 访问 Queue task 和 stats,不直连 Postgres。 |
| Queue dispatch/refresh CLI | 已实现/Q2 | `queue dispatch` 受控创建 Core run/command/runner job`queue refresh` 从 Core run/command 终态回写 Queue task/latestAttempt。 |
| 本地 server 生命周期 CLI | 已实现/Q2 hardening | `server start` 默认后台短返回,`server status/stop` 提供 pid、port、logPath 和 readiness 可见性;`--foreground` 保留给容器/显式调试。 |
| Session CLI | 待实现 | 规格见 [spec-v01-queue.md](spec-v01-queue.md);输出和 trace 进入 Session 命令,Queue 命令不得代理 output/trace。 | | Session CLI | 待实现 | 规格见 [spec-v01-queue.md](spec-v01-queue.md);输出和 trace 进入 Session 命令,Queue 命令不得代理 output/trace。 |
| CLI 测试规格 | 已定义/已验证主闭环 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md);每次发布仍按手动交互验收复跑。 | | CLI 测试规格 | 已定义/已验证主闭环 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md);每次发布仍按手动交互验收复跑。 |
| `deepseek` profile CLI | 已实现/已通过主闭环 | `secrets codex render --profile deepseek``backends list``runner start --backend``runner job` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调已通过 `codex -> deepseek -> codex` 切换主闭环。 | | `deepseek` profile CLI | 已实现/已通过主闭环 | `secrets codex render --profile deepseek``backends list``runner start --backend``runner job` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调已通过 `codex -> deepseek -> codex` 切换主闭环。 |
+10
View File
@@ -149,6 +149,15 @@ Queue 首版新增或扩展的稳定表方向:
单元测试和组件自测试可以使用 mock、fake backend 或内存 store。最终 CLI 交互验收必须在真实 AgentRun runtime 上手动执行,不使用 mock,不写自动交互脚本,不做 Web 或 Playwright 验收。 单元测试和组件自测试可以使用 mock、fake backend 或内存 store。最终 CLI 交互验收必须在真实 AgentRun runtime 上手动执行,不使用 mock,不写自动交互脚本,不做 Web 或 Playwright 验收。
Queue Q2 的真实手动验收必须覆盖以下稳定边界:
- `queue submit` 只创建 Queue task,不触发自动 scheduler。
- `queue dispatch` 是受控手动调度入口,必须创建 Core run、command 和 runner job,并把 attempt 引用写回 Queue task。
- `queue refresh` 只读取 Queue task 保存的 run/command 引用,将 Core 终态回写到 Queue task 和 latestAttempt;不得读取 Core trace、Session trace 或 events 来反推 Queue stats/commander。
- `queue show/list/stats/commander` 的统计口径必须来自 Queue 模型;输出、trace 和会话控制继续由 Session API/CLI 承接。
- 综合联调里的 `workspaceRef` 必须是 runner 实际可访问且能启动 backend command 的工作区。`opaque` workspace 可用于不依赖 Git checkout 的最小 Queue dispatch 验收;`host-path` 只有在该 path 在 runner 容器/进程内可访问且不破坏 Codex command resolution 时才能作为通过证据。
- 因 workspace 形态导致的 backend command spawn 失败,应归类为 runtime workspace/command 一致性问题,不能误判为 Queue dispatch 或 Queue refresh 失败。
首版通过标准: 首版通过标准:
- `queue submit/list/show/stats/read/cancel` 均通过正式 CLI 和 RESTful API 返回非空 JSON。 - `queue submit/list/show/stats/read/cancel` 均通过正式 CLI 和 RESTful API 返回非空 JSON。
@@ -167,6 +176,7 @@ Queue 首版新增或扩展的稳定表方向:
| AgentRun Queue 直接吸收规格 | 已定义 | 本文为 Queue 吸收 Code Queue 的首版权威规格。 | | AgentRun Queue 直接吸收规格 | 已定义 | 本文为 Queue 吸收 Code Queue 的首版权威规格。 |
| Queue RESTful API | 已实现/Q1 | 已通过 `agentrun-mgr` 暴露 `submit/list/show/stats/read/cancel/commander`,使用短请求和 Queue version/cursor 轻量轮询;Q2 再接入 attempt 与真实执行。 | | Queue RESTful API | 已实现/Q1 | 已通过 `agentrun-mgr` 暴露 `submit/list/show/stats/read/cancel/commander`,使用短请求和 Queue version/cursor 轻量轮询;Q2 再接入 attempt 与真实执行。 |
| Queue CLI | 已实现/Q1 | 已加入 `queue submit/list/show/stats/commander/read/cancel`Queue 命令只返回 task summary、stats、read cursor 和 `sessionPath`。 | | Queue CLI | 已实现/Q1 | 已加入 `queue submit/list/show/stats/commander/read/cancel`Queue 命令只返回 task summary、stats、read cursor 和 `sessionPath`。 |
| Queue dispatch/refresh | 已实现/Q2 | `queue dispatch` 受控创建 Core run/command/runner job`queue refresh` 从 Core run/command 终态回写 Queue task/latestAttempt;自动 Scheduler 仍 deferred。 |
| Session API/CLI | 待实现 | Queue 只返回 `sessionPath`Session 层承接输出、trace 和控制。 | | Session API/CLI | 待实现 | Queue 只返回 `sessionPath`Session 层承接输出、trace 和控制。 |
| Scheduler 接入 | 待实现 | 旧 Code Queue scheduler 不保留;AgentRun Scheduler 是唯一调度方向。 | | Scheduler 接入 | 待实现 | 旧 Code Queue scheduler 不保留;AgentRun Scheduler 是唯一调度方向。 |
| OA/Event/integrations | 不采用 | 首版不做,后续如需外部 connector/sink 必须单独立规格。 | | OA/Event/integrations | 不采用 | 首版不做,后续如需外部 connector/sink 必须单独立规格。 |
+3 -1
View File
@@ -73,6 +73,7 @@ CLI 交互联调必须满足:
- CLI 输出、日志摘要和错误信息必须保留可观测性,不能只返回成功/失败布尔值;错误必须能区分 missing SecretRef、provider auth failure、runner lease conflict、backend failure 和 infra failure。 - CLI 输出、日志摘要和错误信息必须保留可观测性,不能只返回成功/失败布尔值;错误必须能区分 missing SecretRef、provider auth failure、runner lease conflict、backend failure 和 infra failure。
- CLI 不得输出 provider credential、Postgres DSN password、token、URL credential 或 Secret value;只能输出 redacted value 或 SecretRef。 - CLI 不得输出 provider credential、Postgres DSN password、token、URL credential 或 Secret value;只能输出 redacted value 或 SecretRef。
- 重启或滚动 `agentrun-mgr` 后,CLI 仍能查询同一 run、command、events 和 terminal_status,证明结果来自 Postgres 而不是进程内存。 - 重启或滚动 `agentrun-mgr` 后,CLI 仍能查询同一 run、command、events 和 terminal_status,证明结果来自 Postgres 而不是进程内存。
- 本地临时 manager 验收必须使用 `server start/status/stop` 生命周期命令管理进程。不得把长驻前台 `server start` 留给 SSH/tran 顶层超时清理;若需要前台模式,必须显式 `--foreground` 并在同一终端可控结束。
### RESTful API 交互联调标准 ### RESTful API 交互联调标准
@@ -187,7 +188,7 @@ T8 是涉及 backend profile 变更时的综合联调标准;不涉及 backend
### T11 Queue 吸收 Code Queue 手动验收 ### T11 Queue 吸收 Code Queue 手动验收
阅读本文和 [spec-v01-queue.md](spec-v01-queue.md),然后在真实 `agentrun-v01` runtime 中用正式 CLI 手动验证 Queue 首版闭环:创建 Queue task,轮询 `queue list/show/stats/commander/read`,触发真实 Codex/Codex-compatible attempt 到 terminal确认 `queue show` 返回 `sessionPath`,再通过 `sessions output/trace` 查询输出和 trace。该验收不得使用 mock,不写自动交互脚本,不做 Web/Playwright。确认 Queue overview、stats、read 和 commander 均来自 Queue summary/stats/read 模型,不从 Core trace 或 Session trace 反推;确认不存在首版 OA/Event/OA sink/integrations/notification/GitHub sink 依赖;确认 MiniMax/OpenCode 不作为 Queue 首版能力;确认旧 UniDesk Code Queue 不接收新任务且历史数据不迁移到 AgentRun。 阅读本文和 [spec-v01-queue.md](spec-v01-queue.md),然后在真实 `agentrun-v01` runtime 中用正式 CLI 手动验证 Queue 首版闭环:创建 Queue task,轮询 `queue list/show/stats/commander/read``queue dispatch` 触发真实 Codex/Codex-compatible attempt 到 terminal再用 `queue refresh` 从 Core run/command 终态回写 Queue task/latestAttempt。确认 `queue show` 返回 `sessionPath`,输出和 trace 只通过 Session API/CLI 查询。该验收不得使用 mock,不写自动交互脚本,不做 Web/Playwright。确认 Queue overview、stats、read 和 commander 均来自 Queue summary/stats/read 模型,不从 Core trace 或 Session trace 反推;确认不存在首版 OA/Event/OA sink/integrations/notification/GitHub sink 依赖;确认 MiniMax/OpenCode 不作为 Queue 首版能力;确认旧 UniDesk Code Queue 不接收新任务且历史数据不迁移到 AgentRun。若使用 `host-path` workspace,必须先证明该路径在 runner 运行面可访问且不会破坏 backend command resolution;否则应使用 `opaque` 或 Git-only ResourceBundleRef 做 Queue dispatch 最小验收。
## 规格的实现情况 ## 规格的实现情况
@@ -204,4 +205,5 @@ T8 是涉及 backend profile 变更时的综合联调标准;不涉及 backend
| RuntimeAssembly 四要素验收 | 已定义 | T9 收敛为四个最简问题:image digest、profile/SecretRef、session null/deferred、Git-only repo/full commitsession/resource materialization 后续实现时必须补真实联调。 | | RuntimeAssembly 四要素验收 | 已定义 | T9 收敛为四个最简问题:image digest、profile/SecretRef、session null/deferred、Git-only repo/full commitsession/resource materialization 后续实现时必须补真实联调。 |
| HWLAB 手动调度 canary 验收 | 已定义 | T10 规定 HWLAB dispatcher 通过手动 runner Job API 使用 AgentRun 的真实联调口径;自动 scheduler 不是前置条件。 | | HWLAB 手动调度 canary 验收 | 已定义 | T10 规定 HWLAB dispatcher 通过手动 runner Job API 使用 AgentRun 的真实联调口径;自动 scheduler 不是前置条件。 |
| Queue 吸收 Code Queue 验收 | 已定义 | T11 规定 Queue RESTful/CLI/Session 分层的真实手动交互验收;mock 只允许在自测试层。 | | Queue 吸收 Code Queue 验收 | 已定义 | T11 规定 Queue RESTful/CLI/Session 分层的真实手动交互验收;mock 只允许在自测试层。 |
| Queue Q2 受控 dispatch/refresh 主闭环 | 已通过 | 已在真实 `agentrun-v01` Postgres 与 G14 k3s runner Job 上完成正式 CLI 手动验收;通过样本使用 `opaque` workspace 完成 Codex stdio turn`queue refresh` 后 Queue task/latestAttempt 为 completed。 |
| mock 作为发布证据 | 不采用 | mock 只能证明自测试通过。 | | mock 作为发布证据 | 不采用 | mock 只能证明自测试通过。 |
+164 -4
View File
@@ -1,4 +1,7 @@
import { readFile } from "node:fs/promises"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { closeSync, existsSync, openSync } from "node:fs";
import path from "node:path";
import { startManagerServer } from "../../src/mgr/server.js"; import { startManagerServer } from "../../src/mgr/server.js";
import { MemoryAgentRunStore } from "../../src/mgr/store.js"; import { MemoryAgentRunStore } from "../../src/mgr/store.js";
import { ManagerClient } from "../../src/mgr/client.js"; import { ManagerClient } from "../../src/mgr/client.js";
@@ -30,7 +33,9 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
const [group, command, id] = args.positional; const [group, command, id] = args.positional;
if (!group || group === "help") return help(); if (!group || group === "help") return help();
if (group === "server" && command === "start") return startServer(args); if (group === "server" && command === "start") return startServer(args);
if (group === "server" && command === "status") return client(args).get("/health/readiness"); if (group === "server" && command === "status") return serverStatus(args);
if (group === "server" && command === "logs") return serverLogs(args);
if (group === "server" && command === "stop") return stopServer(args);
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends"); if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args); if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
if (group === "queue" && command === "submit") return submitQueueTask(args); if (group === "queue" && command === "submit") return submitQueueTask(args);
@@ -223,12 +228,91 @@ async function renderCodexSecret(args: ParsedArgs): Promise<JsonRecord> {
} }
async function startServer(args: ParsedArgs): Promise<JsonRecord> { async function startServer(args: ParsedArgs): Promise<JsonRecord> {
if (args.flags.get("foreground") === true) return startServerForeground(args);
const port = Number(flag(args, "port", "8080"));
const host = flag(args, "host", "0.0.0.0");
const state = await readServerState(port);
if (state.pidAlive || state.portListening) {
throw new AgentRunError("infra-failed", `agentrun-mgr already appears to be running on port ${port}; use server status or server stop first`, { httpStatus: 409, details: state as JsonRecord });
}
await ensureDir(stateDir());
await ensureDir(logDir());
const logPath = serverLogPath(port);
const argsForChild = [process.argv[1] ?? "scripts/agentrun-cli.ts", "server", "start", "--foreground", "--host", host, "--port", String(port)];
const store = optionalFlag(args, "store");
if (store) argsForChild.push("--store", store);
const stdoutFd = openSync(logPath, "a");
const stderrFd = openSync(logPath, "a");
const child = spawn(process.execPath, argsForChild, {
cwd: process.cwd(),
env: process.env,
detached: true,
stdio: ["ignore", stdoutFd, stderrFd],
});
closeSync(stdoutFd);
closeSync(stderrFd);
child.unref();
const pidFile = pidFilePath(port);
await writeFile(pidFile, JSON.stringify({ pid: child.pid, port, host, logPath, startedAt: new Date().toISOString() }) + "\n", "utf8");
const localBaseUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
return { action: "server-start", mode: "background", serviceId: "agentrun-mgr", pid: child.pid ?? null, port, host, baseUrl: localBaseUrl, pidFile, logPath, pollCommands: { status: `./scripts/agentrun server status --port ${port}`, logs: `./scripts/agentrun server logs --port ${port}`, stop: `./scripts/agentrun server stop --port ${port}` } };
}
async function startServerForeground(args: ParsedArgs): Promise<JsonRecord> {
const port = Number(flag(args, "port", "8080")); const port = Number(flag(args, "port", "8080"));
const host = flag(args, "host", "0.0.0.0"); const host = flag(args, "host", "0.0.0.0");
const storeMode = optionalFlag(args, "store") ?? process.env.AGENTRUN_STORE ?? process.env.AGENTRUN_MGR_STORE; const storeMode = optionalFlag(args, "store") ?? process.env.AGENTRUN_STORE ?? process.env.AGENTRUN_MGR_STORE;
const started = await startManagerServer({ port, host, ...(storeMode === "memory" ? { store: new MemoryAgentRunStore() } : {}) }); const started = await startManagerServer({ port, host, ...(storeMode === "memory" ? { store: new MemoryAgentRunStore() } : {}) });
const database = await started.store.health(); const database = await started.store.health();
return { serviceId: "agentrun-mgr", baseUrl: started.baseUrl, pid: process.pid, database, note: "foreground process; use Kubernetes/Tekton for v0.1 runtime" }; return { serviceId: "agentrun-mgr", baseUrl: started.baseUrl, pid: process.pid, database, mode: "foreground", note: "foreground process; use server start without --foreground for local background mode" };
}
async function serverStatus(args: ParsedArgs): Promise<JsonRecord> {
const explicitPort = optionalFlag(args, "port");
const baseUrl = explicitPort ? `http://127.0.0.1:${Number(explicitPort)}` : managerUrl(args);
const port = Number(explicitPort ?? portFromUrl(baseUrl));
const state = await readServerState(port);
let readiness: JsonValue = null;
let readinessFailure: JsonRecord | null = null;
if (explicitPort ? state.portListening : true) {
try {
readiness = await new ManagerClient(baseUrl).get("/health/readiness");
} catch (error) {
readinessFailure = errorToJson(error);
}
}
return { action: "server-status", serviceId: "agentrun-mgr", port, baseUrl, local: state as JsonRecord, readiness, readinessFailure, pollCommands: { status: `./scripts/agentrun server status --port ${port}`, logs: `./scripts/agentrun server logs --port ${port}`, stop: `./scripts/agentrun server stop --port ${port}` } };
}
async function serverLogs(args: ParsedArgs): Promise<JsonRecord> {
const port = Number(flag(args, "port", portFromManagerUrl(args)));
const state = await readServerState(port);
const logPath = optionalFlag(args, "log-file") ?? (typeof state.logPath === "string" ? state.logPath : null);
const tailBytes = Number(flag(args, "tail-bytes", "12000"));
if (!logPath) return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath: null, exists: false, tail: "", bytes: 0, truncated: false, message: "no log file recorded for this port" };
if (!existsSync(logPath)) return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath, exists: false, tail: "", bytes: 0, truncated: false, message: "log file does not exist" };
const bytes = await readFile(logPath);
const start = Math.max(0, bytes.byteLength - tailBytes);
return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath, exists: true, bytes: bytes.byteLength, tailBytes, truncated: start > 0, tail: bytes.subarray(start).toString("utf8") };
}
async function stopServer(args: ParsedArgs): Promise<JsonRecord> {
const port = Number(flag(args, "port", portFromManagerUrl(args)));
const before = await readServerState(port);
let signalSent = false;
const beforePid = typeof before.pid === "number" ? before.pid : null;
const beforePortPid = typeof before.portPid === "number" ? before.portPid : null;
if (before.pidAlive === true && beforePid !== null) {
process.kill(beforePid, "SIGTERM");
signalSent = true;
} else if (beforePortPid !== null) {
process.kill(beforePortPid, "SIGTERM");
signalSent = true;
}
await sleep(500);
const after = await readServerState(port);
if (!after.pidAlive && !after.portListening && existsSync(pidFilePath(port))) await rm(pidFilePath(port), { force: true });
return { action: "server-stop", serviceId: "agentrun-mgr", port, signalSent, before: before as JsonRecord, after: after as JsonRecord, stopped: !after.pidAlive && !after.portListening };
} }
function client(args: ParsedArgs): ManagerClient { function client(args: ParsedArgs): ManagerClient {
@@ -239,6 +323,79 @@ function managerUrl(args: ParsedArgs): string {
return optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL ?? "http://127.0.0.1:8080"; return optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL ?? "http://127.0.0.1:8080";
} }
function portFromManagerUrl(args: ParsedArgs): string {
return portFromUrl(managerUrl(args));
}
function portFromUrl(value: string): string {
try {
const url = new URL(value);
return url.port || (url.protocol === "https:" ? "443" : "80");
} catch {
return "8080";
}
}
function stateDir(): string {
return path.join(process.cwd(), ".state");
}
function logDir(): string {
return path.join(process.cwd(), "logs", new Date().toISOString().slice(0, 10).replace(/-/gu, ""));
}
function pidFilePath(port: number): string {
return path.join(stateDir(), `agentrun-mgr-${port}.pid.json`);
}
function serverLogPath(port: number): string {
return path.join(logDir(), `agentrun-mgr-${port}-${new Date().toISOString().replace(/[:.]/gu, "-")}.jsonl`);
}
async function ensureDir(dir: string): Promise<void> {
await mkdir(dir, { recursive: true });
}
async function readServerState(port: number): Promise<JsonRecord> {
const pidFile = pidFilePath(port);
const pidRecord = await readPidFile(pidFile);
const pid = typeof pidRecord?.pid === "number" ? pidRecord.pid : null;
const pidAlive = pid !== null && isPidAlive(pid);
const portPid = await pidForPort(port);
return { pidFile, pid, pidAlive, port, portListening: portPid !== null, portPid, logPath: typeof pidRecord?.logPath === "string" ? pidRecord.logPath : null, startedAt: typeof pidRecord?.startedAt === "string" ? pidRecord.startedAt : null };
}
async function readPidFile(pidFile: string): Promise<JsonRecord | null> {
try {
return JSON.parse(await readFile(pidFile, "utf8")) as JsonRecord;
} catch {
return null;
}
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function pidForPort(port: number): Promise<number | null> {
const proc = spawn("sh", ["-c", `command -v ss >/dev/null 2>&1 && ss -ltnp 'sport = :${port}' || true`], { stdio: ["ignore", "pipe", "ignore"] });
const chunks: Buffer[] = [];
proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
await new Promise<void>((resolve) => proc.on("close", () => resolve()));
const text = Buffer.concat(chunks).toString("utf8");
const match = text.match(/pid=(\d+)/u);
return match ? Number(match[1]) : null;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function jsonFile(args: ParsedArgs): Promise<JsonRecord> { async function jsonFile(args: ParsedArgs): Promise<JsonRecord> {
const file = optionalFlag(args, "json-file"); const file = optionalFlag(args, "json-file");
if (!file) throw new AgentRunError("schema-invalid", "--json-file is required", { httpStatus: 2 }); if (!file) throw new AgentRunError("schema-invalid", "--json-file is required", { httpStatus: 2 });
@@ -316,7 +473,10 @@ function help(): JsonRecord {
"queue refresh <taskId>", "queue refresh <taskId>",
"secrets codex render --dry-run [--profile codex|deepseek] [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name <name>]", "secrets codex render --dry-run [--profile codex|deepseek] [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name <name>]",
"backends list", "backends list",
"server start|status", "server start [--port <port>] [--host <host>] [--foreground]",
"server status [--port <port>]",
"server logs [--port <port>] [--tail-bytes <bytes>] [--log-file <path>]",
"server stop [--port <port>]",
], ],
}; };
} }
+1
View File
@@ -198,6 +198,7 @@ async function reportCommandFailure(api: RunnerManagerApi, runId: string, comman
await api.appendEvent(runId, { type: "error", payload: { failureKind: failure.failureKind, message: failure.message, phase, commandId, attemptId, runnerId: runner.id } }); await api.appendEvent(runId, { type: "error", payload: { failureKind: failure.failureKind, message: failure.message, phase, commandId, attemptId, runnerId: runner.id } });
await api.appendEvent(runId, { type: "terminal_status", payload: { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, message: failure.message, commandId, attemptId, runnerId: runner.id } }); await api.appendEvent(runId, { type: "terminal_status", payload: { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, message: failure.message, commandId, attemptId, runnerId: runner.id } });
await api.reportCommandStatus(commandId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message }); await api.reportCommandStatus(commandId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message });
await api.reportStatus(runId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message });
return { commandId, terminalStatus: failure.terminalStatus, failureKind: failure.failureKind } as CommandExecutionResult; return { commandId, terminalStatus: failure.terminalStatus, failureKind: failure.failureKind } as CommandExecutionResult;
} }