- commit
- 3e6b935
- parent
- 7fd959d
- author
- im_wower
- date
- 2026-03-22 01:25:37 +0800 CST
Merge remote-tracking branch 'origin/feat/T-019-conductor-http' into integration/fourth-wave-20260322
5 files changed,
+518,
-21
+131,
-2
1@@ -5,6 +5,7 @@ import {
2 ConductorDaemon,
3 ConductorRuntime,
4 createFetchControlApiClient,
5+ handleConductorHttpRequest,
6 parseConductorCliRequest
7 } from "./index.ts";
8
9@@ -278,10 +279,137 @@ test("parseConductorCliRequest merges launchd env defaults with CLI overrides",
10 assert.equal(request.config.role, "standby");
11 assert.equal(request.config.nodeId, "mini-main");
12 assert.equal(request.config.controlApiBase, "https://control.example.test");
13- assert.equal(request.config.localApiBase, "http://127.0.0.1:4317/");
14+ assert.equal(request.config.localApiBase, "http://127.0.0.1:4317");
15 assert.equal(request.config.paths.runsDir, "/tmp/runs");
16 });
17
18+test("handleConductorHttpRequest keeps degraded runtimes observable but not ready", () => {
19+ const snapshot = {
20+ daemon: {
21+ nodeId: "mini-main",
22+ host: "mini",
23+ role: "primary",
24+ leaseState: "degraded",
25+ schedulerEnabled: false,
26+ currentLeaderId: null,
27+ currentTerm: null,
28+ leaseExpiresAt: null,
29+ lastHeartbeatAt: 100,
30+ lastLeaseOperation: "acquire",
31+ nextLeaseOperation: "acquire",
32+ consecutiveRenewFailures: 2,
33+ lastError: "lease endpoint timeout"
34+ },
35+ identity: "mini-main@mini(primary)",
36+ loops: {
37+ heartbeat: false,
38+ lease: false
39+ },
40+ paths: {
41+ logsDir: null,
42+ runsDir: null,
43+ stateDir: null,
44+ tmpDir: null,
45+ worktreesDir: null
46+ },
47+ controlApi: {
48+ baseUrl: "https://control.example.test",
49+ localApiBase: "http://127.0.0.1:4317",
50+ hasSharedToken: true,
51+ usesPlaceholderToken: false
52+ },
53+ runtime: {
54+ pid: 123,
55+ started: true,
56+ startedAt: 100
57+ },
58+ startupChecklist: [],
59+ warnings: []
60+ };
61+
62+ const readyResponse = handleConductorHttpRequest(
63+ {
64+ method: "GET",
65+ path: "/readyz"
66+ },
67+ () => snapshot
68+ );
69+ assert.equal(readyResponse.status, 503);
70+ assert.equal(readyResponse.body, "not_ready\n");
71+
72+ const roleResponse = handleConductorHttpRequest(
73+ {
74+ method: "GET",
75+ path: "/rolez"
76+ },
77+ () => snapshot
78+ );
79+ assert.equal(roleResponse.status, 200);
80+ assert.equal(roleResponse.body, "standby\n");
81+});
82+
83+test("ConductorRuntime serves health and runtime probes over the local HTTP endpoint", async () => {
84+ const runtime = new ConductorRuntime(
85+ {
86+ nodeId: "mini-main",
87+ host: "mini",
88+ role: "primary",
89+ controlApiBase: "https://control.example.test",
90+ localApiBase: "http://127.0.0.1:0",
91+ sharedToken: "replace-me",
92+ paths: {
93+ runsDir: "/tmp/runs"
94+ }
95+ },
96+ {
97+ autoStartLoops: false,
98+ client: {
99+ async acquireLeaderLease() {
100+ return createLeaseResult({
101+ holderId: "mini-main",
102+ term: 2,
103+ leaseExpiresAt: 130,
104+ renewedAt: 100,
105+ isLeader: true,
106+ operation: "acquire"
107+ });
108+ },
109+ async sendControllerHeartbeat() {}
110+ },
111+ now: () => 100
112+ }
113+ );
114+
115+ const snapshot = await runtime.start();
116+ assert.equal(snapshot.daemon.schedulerEnabled, true);
117+ assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
118+
119+ const baseUrl = snapshot.controlApi.localApiBase;
120+
121+ const healthResponse = await fetch(`${baseUrl}/healthz`);
122+ assert.equal(healthResponse.status, 200);
123+ assert.equal(await healthResponse.text(), "ok\n");
124+
125+ const readyResponse = await fetch(`${baseUrl}/readyz`);
126+ assert.equal(readyResponse.status, 200);
127+ assert.equal(await readyResponse.text(), "ready\n");
128+
129+ const roleResponse = await fetch(`${baseUrl}/rolez`);
130+ assert.equal(roleResponse.status, 200);
131+ assert.equal(await roleResponse.text(), "leader\n");
132+
133+ const runtimeResponse = await fetch(`${baseUrl}/v1/runtime`);
134+ assert.equal(runtimeResponse.status, 200);
135+ const payload = await runtimeResponse.json();
136+ assert.equal(payload.ok, true);
137+ assert.equal(payload.data.identity, "mini-main@mini(primary)");
138+ assert.equal(payload.data.controlApi.localApiBase, baseUrl);
139+ assert.equal(payload.data.runtime.started, true);
140+
141+ const stoppedSnapshot = await runtime.stop();
142+ assert.equal(stoppedSnapshot.runtime.started, false);
143+});
144+
145 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
146 const runtime = new ConductorRuntime(
147 {
148@@ -319,11 +447,12 @@ test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status sur
149 const startedSnapshot = await runtime.start();
150 assert.equal(startedSnapshot.runtime.started, true);
151 assert.equal(startedSnapshot.daemon.leaseState, "leader");
152+ assert.equal(startedSnapshot.daemon.schedulerEnabled, true);
153 assert.equal(startedSnapshot.loops.heartbeat, false);
154 assert.equal(startedSnapshot.loops.lease, false);
155 assert.equal(startedSnapshot.controlApi.usesPlaceholderToken, true);
156 assert.match(startedSnapshot.warnings.join("\n"), /replace-me/);
157
158- const stoppedSnapshot = runtime.stop();
159+ const stoppedSnapshot = await runtime.stop();
160 assert.equal(stoppedSnapshot.runtime.started, false);
161 });
+328,
-10
1@@ -1,4 +1,13 @@
2+import {
3+ createServer,
4+ type IncomingMessage,
5+ type Server,
6+ type ServerResponse
7+} from "node:http";
8+import type { AddressInfo } from "node:net";
9+
10 export type ConductorRole = "primary" | "standby";
11+export type ConductorLeadershipRole = "leader" | "standby";
12 export type LeaseState = "leader" | "standby" | "degraded";
13 export type SchedulerDecision = "scheduled" | "skipped_not_leader";
14 export type TimerHandle = ReturnType<typeof globalThis.setInterval>;
15@@ -9,6 +18,14 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
16 const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
17 const DEFAULT_LEASE_TTL_SEC = 30;
18 const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
19+const JSON_RESPONSE_HEADERS = {
20+ "cache-control": "no-store",
21+ "content-type": "application/json; charset=utf-8"
22+} as const;
23+const TEXT_RESPONSE_HEADERS = {
24+ "cache-control": "no-store",
25+ "content-type": "text/plain; charset=utf-8"
26+} as const;
27
28 const STARTUP_CHECKLIST: StartupChecklistItem[] = [
29 { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
30@@ -147,6 +164,17 @@ export interface ConductorRuntimeSnapshot {
31 warnings: string[];
32 }
33
34+export interface ConductorHttpRequest {
35+ method: string;
36+ path: string;
37+}
38+
39+export interface ConductorHttpResponse {
40+ status: number;
41+ headers: Record<string, string>;
42+ body: string;
43+}
44+
45 export interface SchedulerContext {
46 controllerId: string;
47 host: string;
48@@ -258,6 +286,11 @@ interface JsonRequestOptions {
49 headers?: Record<string, string>;
50 }
51
52+interface LocalApiListenConfig {
53+ host: string;
54+ port: number;
55+}
56+
57 interface CliValueOverrides {
58 controlApiBase?: string;
59 heartbeatIntervalMs?: string;
60@@ -301,6 +334,273 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
61 return normalized === "" ? null : normalized;
62 }
63
64+function normalizePath(value: string): string {
65+ const baseUrl = "http://conductor.local";
66+ const url = new URL(value || "/", baseUrl);
67+ const normalized = url.pathname.replace(/\/+$/u, "");
68+
69+ return normalized === "" ? "/" : normalized;
70+}
71+
72+function normalizeLoopbackHost(hostname: string): string {
73+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
74+}
75+
76+function isLoopbackHost(hostname: string): boolean {
77+ const normalized = normalizeLoopbackHost(hostname);
78+ return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
79+}
80+
81+function readHttpPort(url: URL): number {
82+ if (url.port === "") {
83+ return 80;
84+ }
85+
86+ const port = Number(url.port);
87+
88+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
89+ throw new Error("Conductor localApiBase must use a valid TCP port.");
90+ }
91+
92+ return port;
93+}
94+
95+function formatLocalApiBaseUrl(hostname: string, port: number): string {
96+ const normalizedHost = normalizeLoopbackHost(hostname);
97+ const formattedHost = normalizedHost.includes(":") ? `[${normalizedHost}]` : normalizedHost;
98+
99+ return `http://${formattedHost}${port === 80 ? "" : `:${port}`}`;
100+}
101+
102+function resolveLocalApiBase(value: string | null | undefined): string | null {
103+ const normalized = normalizeOptionalString(value);
104+
105+ if (normalized == null) {
106+ return null;
107+ }
108+
109+ let url: URL;
110+
111+ try {
112+ url = new URL(normalized);
113+ } catch {
114+ throw new Error("Conductor localApiBase must be a valid absolute http:// URL.");
115+ }
116+
117+ if (url.protocol !== "http:") {
118+ throw new Error("Conductor localApiBase must use the http:// scheme.");
119+ }
120+
121+ if (!isLoopbackHost(url.hostname)) {
122+ throw new Error("Conductor localApiBase must use a loopback host.");
123+ }
124+
125+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
126+ throw new Error("Conductor localApiBase must not include a path, query, or hash.");
127+ }
128+
129+ if (url.username !== "" || url.password !== "") {
130+ throw new Error("Conductor localApiBase must not include credentials.");
131+ }
132+
133+ return formatLocalApiBaseUrl(url.hostname, readHttpPort(url));
134+}
135+
136+function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig {
137+ const url = new URL(localApiBase);
138+
139+ return {
140+ host: normalizeLoopbackHost(url.hostname),
141+ port: readHttpPort(url)
142+ };
143+}
144+
145+function jsonResponse(
146+ status: number,
147+ payload: unknown,
148+ extraHeaders: Record<string, string> = {}
149+): ConductorHttpResponse {
150+ return {
151+ status,
152+ headers: {
153+ ...JSON_RESPONSE_HEADERS,
154+ ...extraHeaders
155+ },
156+ body: `${JSON.stringify(payload, null, 2)}\n`
157+ };
158+}
159+
160+function textResponse(
161+ status: number,
162+ body: string,
163+ extraHeaders: Record<string, string> = {}
164+): ConductorHttpResponse {
165+ return {
166+ status,
167+ headers: {
168+ ...TEXT_RESPONSE_HEADERS,
169+ ...extraHeaders
170+ },
171+ body: `${body}\n`
172+ };
173+}
174+
175+function resolveLeadershipRole(snapshot: ConductorRuntimeSnapshot): ConductorLeadershipRole {
176+ return snapshot.daemon.leaseState === "leader" ? "leader" : "standby";
177+}
178+
179+function isRuntimeReady(snapshot: ConductorRuntimeSnapshot): boolean {
180+ return snapshot.runtime.started && snapshot.daemon.leaseState !== "degraded";
181+}
182+
183+export function handleConductorHttpRequest(
184+ request: ConductorHttpRequest,
185+ snapshotLoader: () => ConductorRuntimeSnapshot
186+): ConductorHttpResponse {
187+ const method = request.method.toUpperCase();
188+
189+ if (method !== "GET") {
190+ return jsonResponse(
191+ 405,
192+ {
193+ ok: false,
194+ error: "method_not_allowed",
195+ message: "Conductor local API is read-only and only accepts GET requests."
196+ },
197+ { Allow: "GET" }
198+ );
199+ }
200+
201+ const path = normalizePath(request.path);
202+
203+ switch (path) {
204+ case "/healthz":
205+ return textResponse(200, "ok");
206+
207+ case "/readyz": {
208+ const snapshot = snapshotLoader();
209+ const ready = isRuntimeReady(snapshot);
210+ return textResponse(ready ? 200 : 503, ready ? "ready" : "not_ready");
211+ }
212+
213+ case "/rolez":
214+ return textResponse(200, resolveLeadershipRole(snapshotLoader()));
215+
216+ case "/v1/runtime":
217+ return jsonResponse(200, {
218+ ok: true,
219+ data: snapshotLoader()
220+ });
221+
222+ default:
223+ return jsonResponse(404, {
224+ ok: false,
225+ error: "not_found",
226+ message: `No conductor route matches "${path}".`
227+ });
228+ }
229+}
230+
231+function writeHttpResponse(
232+ response: ServerResponse<IncomingMessage>,
233+ payload: ConductorHttpResponse
234+): void {
235+ response.statusCode = payload.status;
236+
237+ for (const [name, value] of Object.entries(payload.headers)) {
238+ response.setHeader(name, value);
239+ }
240+
241+ response.end(payload.body);
242+}
243+
244+class ConductorLocalHttpServer {
245+ private readonly localApiBase: string;
246+ private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
247+ private resolvedBaseUrl: string;
248+ private server: Server | null = null;
249+
250+ constructor(localApiBase: string, snapshotLoader: () => ConductorRuntimeSnapshot) {
251+ this.localApiBase = localApiBase;
252+ this.snapshotLoader = snapshotLoader;
253+ this.resolvedBaseUrl = localApiBase;
254+ }
255+
256+ getBaseUrl(): string {
257+ return this.resolvedBaseUrl;
258+ }
259+
260+ async start(): Promise<string> {
261+ if (this.server != null) {
262+ return this.resolvedBaseUrl;
263+ }
264+
265+ const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
266+ const server = createServer((request, response) => {
267+ writeHttpResponse(
268+ response,
269+ handleConductorHttpRequest(
270+ {
271+ method: request.method ?? "GET",
272+ path: request.url ?? "/"
273+ },
274+ this.snapshotLoader
275+ )
276+ );
277+ });
278+
279+ await new Promise<void>((resolve, reject) => {
280+ const onError = (error: Error) => {
281+ server.off("listening", onListening);
282+ reject(error);
283+ };
284+ const onListening = () => {
285+ server.off("error", onError);
286+ resolve();
287+ };
288+
289+ server.once("error", onError);
290+ server.once("listening", onListening);
291+ server.listen({
292+ host: listenConfig.host,
293+ port: listenConfig.port
294+ });
295+ });
296+
297+ const address = server.address();
298+
299+ if (address == null || typeof address === "string") {
300+ server.close();
301+ throw new Error("Conductor local API started without a TCP listen address.");
302+ }
303+
304+ this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
305+ this.server = server;
306+ return this.resolvedBaseUrl;
307+ }
308+
309+ async stop(): Promise<void> {
310+ if (this.server == null) {
311+ return;
312+ }
313+
314+ const server = this.server;
315+ this.server = null;
316+
317+ await new Promise<void>((resolve, reject) => {
318+ server.close((error) => {
319+ if (error) {
320+ reject(error);
321+ return;
322+ }
323+
324+ resolve();
325+ });
326+ server.closeAllConnections?.();
327+ });
328+ }
329+}
330+
331 function toErrorMessage(error: unknown): string {
332 if (error instanceof Error) {
333 return error.message;
334@@ -987,7 +1287,7 @@ export function resolveConductorRuntimeConfig(
335 heartbeatIntervalMs,
336 leaseRenewIntervalMs,
337 leaseTtlSec,
338- localApiBase: normalizeOptionalString(config.localApiBase),
339+ localApiBase: resolveLocalApiBase(config.localApiBase),
340 paths: resolvePathConfig(config.paths),
341 preferred: config.preferred ?? config.role === "primary",
342 priority,
343@@ -1348,11 +1648,13 @@ function getUsageText(): string {
344 export class ConductorRuntime {
345 private readonly config: ResolvedConductorRuntimeConfig;
346 private readonly daemon: ConductorDaemon;
347+ private readonly localApiServer: ConductorLocalHttpServer | null;
348+ private readonly now: () => number;
349 private started = false;
350
351 constructor(config: ConductorRuntimeConfig, options: ConductorRuntimeOptions = {}) {
352- const now = options.now ?? defaultNowUnixSeconds;
353- const startedAt = config.startedAt ?? now();
354+ this.now = options.now ?? defaultNowUnixSeconds;
355+ const startedAt = config.startedAt ?? this.now();
356
357 this.config = resolveConductorRuntimeConfig({
358 ...config,
359@@ -1361,26 +1663,42 @@ export class ConductorRuntime {
360 this.daemon = new ConductorDaemon(this.config, {
361 ...options,
362 controlApiBearerToken: options.controlApiBearerToken ?? this.config.sharedToken,
363- now
364+ now: this.now
365 });
366+ this.localApiServer =
367+ this.config.localApiBase == null
368+ ? null
369+ : new ConductorLocalHttpServer(this.config.localApiBase, () => this.getRuntimeSnapshot());
370 }
371
372 async start(): Promise<ConductorRuntimeSnapshot> {
373 if (!this.started) {
374 await this.daemon.start();
375 this.started = true;
376+
377+ try {
378+ await this.localApiServer?.start();
379+ } catch (error) {
380+ this.started = false;
381+ this.daemon.stop();
382+ throw error;
383+ }
384 }
385
386 return this.getRuntimeSnapshot();
387 }
388
389- stop(): ConductorRuntimeSnapshot {
390- this.daemon.stop();
391+ async stop(): Promise<ConductorRuntimeSnapshot> {
392 this.started = false;
393+ this.daemon.stop();
394+ await this.localApiServer?.stop();
395+
396 return this.getRuntimeSnapshot();
397 }
398
399- getRuntimeSnapshot(now: number = defaultNowUnixSeconds()): ConductorRuntimeSnapshot {
400+ getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
401+ const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
402+
403 return {
404 daemon: this.daemon.getStatusSnapshot(now),
405 identity: this.daemon.describeIdentity(),
406@@ -1388,7 +1706,7 @@ export class ConductorRuntime {
407 paths: { ...this.config.paths },
408 controlApi: {
409 baseUrl: this.config.controlApiBase,
410- localApiBase: this.config.localApiBase,
411+ localApiBase,
412 hasSharedToken: this.config.sharedToken != null,
413 usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
414 },
415@@ -1494,12 +1812,12 @@ export async function runConductorCli(options: RunConductorCliOptions = {}): Pro
416 }
417
418 if (request.runOnce) {
419- runtime.stop();
420+ await runtime.stop();
421 return 0;
422 }
423
424 const signal = await waitForShutdownSignal(processLike);
425- runtime.stop();
426+ await runtime.stop();
427
428 if (!request.printJson) {
429 writeLine(stdout, `conductor stopped${signal ? ` after ${signal}` : ""}`);
1@@ -0,0 +1,38 @@
2+declare module "node:net" {
3+ export interface AddressInfo {
4+ address: string;
5+ family: string;
6+ port: number;
7+ }
8+}
9+
10+declare module "node:http" {
11+ import type { AddressInfo } from "node:net";
12+
13+ export interface IncomingMessage {
14+ method?: string;
15+ url?: string;
16+ }
17+
18+ export interface ServerResponse<Request extends IncomingMessage = IncomingMessage> {
19+ end(chunk?: string): void;
20+ setHeader(name: string, value: string): this;
21+ statusCode: number;
22+ }
23+
24+ export interface Server {
25+ address(): AddressInfo | string | null;
26+ close(callback?: (error?: Error) => void): this;
27+ closeAllConnections?(): void;
28+ listen(options: { host: string; port: number }): this;
29+ off(event: "error", listener: (error: Error) => void): this;
30+ off(event: "listening", listener: () => void): this;
31+ off(event: string, listener: (...args: unknown[]) => void): this;
32+ once(event: "error", listener: (error: Error) => void): this;
33+ once(event: "listening", listener: () => void): this;
34+ }
35+
36+ export function createServer(
37+ handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void
38+ ): Server;
39+}
+1,
-2
1@@ -4,6 +4,5 @@
2 "rootDir": "src",
3 "outDir": "dist"
4 },
5- "include": ["src/**/*.ts"]
6+ "include": ["src/**/*.ts", "src/**/*.d.ts"]
7 }
8-
1@@ -1,10 +1,10 @@
2 ---
3 task_id: T-019
4 title: Conductor 本地 HTTP 入口
5-status: todo
6+status: review
7 branch: feat/T-019-conductor-http
8 repo: /Users/george/code/baa-conductor
9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12 - T-015
13 write_scope:
14@@ -61,23 +61,36 @@ updated_at: 2026-03-22
15
16 ## files_changed
17
18-- 待填写
19+- `apps/conductor-daemon/src/index.ts`
20+- `apps/conductor-daemon/src/index.test.js`
21+- `apps/conductor-daemon/src/node-shims.d.ts`
22+- `apps/conductor-daemon/tsconfig.json`
23+- `coordination/tasks/T-019-conductor-http.md`
24
25 ## commands_run
26
27-- 待填写
28+- `git worktree add /Users/george/code/baa-conductor-T019 -b feat/T-019-conductor-http 458d7cf`
29+- `npx --yes pnpm install`
30+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
31+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
32+- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
33+- `git diff --check`
34
35 ## result
36
37-- 待填写
38+- 已在 `apps/conductor-daemon` 内补上最小本地只读 HTTP server,基于 `BAA_CONDUCTOR_LOCAL_API` 暴露 `GET /healthz`、`GET /readyz`、`GET /rolez` 与 `GET /v1/runtime`,并限制为 loopback `http://` 地址。
39+- 已把现有 runtime snapshot 接到 HTTP 读取面,`ConductorRuntime` 现在会随启动/停止一起管理本地 server 生命周期,并在快照中回填实际监听地址。
40+- 已补测试覆盖降级态 `readyz/rolez` 语义与真实本地 HTTP 冒烟请求,`typecheck`、`build`、`node --test` 均通过。
41
42 ## risks
43
44-- 待填写
45+- 当前 `/readyz` 的语义是“runtime 已启动且 lease 未降级”;后续如果要把本地 runs 恢复、调度器预热或更多依赖也纳入 readiness,需要再扩展判定。
46+- 为避免越出本任务 `write_scope`,Node 内建模块类型声明目前以 `apps/conductor-daemon/src/node-shims.d.ts` 本地 shim 形式维护;如果仓库后续统一引入 `@types/node`,应顺手收敛这里的声明。
47
48 ## next_handoff
49
50-- 待填写
51+- launchd、Nginx 或后续 status 相关整合可以直接复用 `http://127.0.0.1:4317/healthz`、`/readyz`、`/rolez`、`/v1/runtime` 作为 conductor 的本地读取面。
52+- 如果后续需要更严格的运维判断,可在不改外部路由的前提下继续细化 `/readyz` 与 `/v1/runtime` 的字段。
53
54 开始时建议直接把 `status` 改为 `in_progress`。
55