baa-conductor

git clone 

commit
3926e4a
parent
458d7cf
author
im_wower
date
2026-03-22 01:02:48 +0800 CST
feat(conductor-daemon): add local http probes
5 files changed,  +518, -21
M apps/conductor-daemon/src/index.test.js
+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 });
M apps/conductor-daemon/src/index.ts
+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}` : ""}`);
A apps/conductor-daemon/src/node-shims.d.ts
+38, -0
 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+}
M apps/conductor-daemon/tsconfig.json
+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-
M coordination/tasks/T-019-conductor-http.md
+20, -7
 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