baa-conductor

git clone 

commit
76b426e
parent
c5e007b
author
im_wower
date
2026-03-22 00:28:25 +0800 CST
feat(conductor-daemon): wire runtime entry
4 files changed,  +1318, -40
M apps/conductor-daemon/package.json
+4, -1
 1@@ -3,7 +3,10 @@
 2   "private": true,
 3   "type": "module",
 4   "scripts": {
 5-    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
 6+    "build": "pnpm exec tsc -p tsconfig.json",
 7+    "dev": "node --experimental-strip-types src/index.ts",
 8+    "start": "node dist/index.js",
 9+    "test": "node --test --experimental-strip-types src/index.test.js",
10     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
11   }
12 }
M apps/conductor-daemon/src/index.test.js
+152, -1
  1@@ -1,7 +1,12 @@
  2 import assert from "node:assert/strict";
  3 import test from "node:test";
  4 
  5-import { ConductorDaemon } from "./index.ts";
  6+import {
  7+  ConductorDaemon,
  8+  ConductorRuntime,
  9+  createFetchControlApiClient,
 10+  parseConductorCliRequest
 11+} from "./index.ts";
 12 
 13 function createLeaseResult({
 14   holderId,
 15@@ -176,3 +181,149 @@ test("repeated renew failures degrade the daemon after the configured threshold"
 16   assert.equal(daemon.getStatusSnapshot().leaseState, "degraded");
 17   assert.equal(daemon.getStatusSnapshot().consecutiveRenewFailures, 2);
 18 });
 19+
 20+test("createFetchControlApiClient unwraps control-api envelopes and sends bearer auth", async () => {
 21+  const observedRequests = [];
 22+  const client = createFetchControlApiClient(
 23+    "https://control.example.test/",
 24+    async (url, init) => {
 25+      observedRequests.push({
 26+        url,
 27+        init
 28+      });
 29+
 30+      return new Response(
 31+        JSON.stringify({
 32+          ok: true,
 33+          request_id: "req-1",
 34+          data: {
 35+            holder_id: "mini-main",
 36+            holder_host: "mini",
 37+            term: 3,
 38+            lease_expires_at: 130,
 39+            renewed_at: 100,
 40+            is_leader: true,
 41+            operation: "renew",
 42+            lease: {
 43+              lease_name: "global",
 44+              holder_id: "mini-main",
 45+              holder_host: "mini",
 46+              term: 3,
 47+              lease_expires_at: 130,
 48+              renewed_at: 100,
 49+              preferred_holder_id: "mini-main",
 50+              metadata_json: null
 51+            }
 52+          }
 53+        }),
 54+        {
 55+          status: 200,
 56+          headers: {
 57+            "content-type": "application/json"
 58+          }
 59+        }
 60+      );
 61+    },
 62+    {
 63+      bearerToken: "secret-token"
 64+    }
 65+  );
 66+
 67+  const result = await client.acquireLeaderLease({
 68+    controllerId: "mini-main",
 69+    host: "mini",
 70+    ttlSec: 30,
 71+    preferred: true,
 72+    now: 100
 73+  });
 74+
 75+  assert.equal(observedRequests.length, 1);
 76+  assert.equal(String(observedRequests[0].url), "https://control.example.test/v1/leader/acquire");
 77+  assert.equal(
 78+    new Headers(observedRequests[0].init.headers).get("authorization"),
 79+    "Bearer secret-token"
 80+  );
 81+  assert.deepEqual(JSON.parse(String(observedRequests[0].init.body)), {
 82+    controller_id: "mini-main",
 83+    host: "mini",
 84+    ttl_sec: 30,
 85+    preferred: true,
 86+    now: 100
 87+  });
 88+  assert.equal(result.holderId, "mini-main");
 89+  assert.equal(result.holderHost, "mini");
 90+  assert.equal(result.term, 3);
 91+  assert.equal(result.operation, "renew");
 92+  assert.equal(result.isLeader, true);
 93+});
 94+
 95+test("parseConductorCliRequest merges launchd env defaults with CLI overrides", () => {
 96+  const request = parseConductorCliRequest(["start", "--role", "standby", "--run-once"], {
 97+    BAA_NODE_ID: "mini-main",
 98+    BAA_CONDUCTOR_HOST: "mini",
 99+    BAA_CONDUCTOR_ROLE: "primary",
100+    BAA_CONTROL_API_BASE: "https://control.example.test/",
101+    BAA_CONDUCTOR_LOCAL_API: "http://127.0.0.1:4317/",
102+    BAA_SHARED_TOKEN: "replace-me",
103+    BAA_RUNS_DIR: "/tmp/runs"
104+  });
105+
106+  assert.equal(request.action, "start");
107+
108+  if (request.action !== "start") {
109+    throw new Error("expected start action");
110+  }
111+
112+  assert.equal(request.runOnce, true);
113+  assert.equal(request.config.role, "standby");
114+  assert.equal(request.config.nodeId, "mini-main");
115+  assert.equal(request.config.controlApiBase, "https://control.example.test");
116+  assert.equal(request.config.localApiBase, "http://127.0.0.1:4317/");
117+  assert.equal(request.config.paths.runsDir, "/tmp/runs");
118+});
119+
120+test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
121+  const runtime = new ConductorRuntime(
122+    {
123+      nodeId: "mini-main",
124+      host: "mini",
125+      role: "primary",
126+      controlApiBase: "https://control.example.test",
127+      localApiBase: "http://127.0.0.1:4317",
128+      sharedToken: "replace-me",
129+      paths: {
130+        runsDir: "/tmp/runs"
131+      }
132+    },
133+    {
134+      autoStartLoops: false,
135+      client: {
136+        async acquireLeaderLease() {
137+          return createLeaseResult({
138+            holderId: "mini-main",
139+            term: 2,
140+            leaseExpiresAt: 130,
141+            renewedAt: 100,
142+            isLeader: true,
143+            operation: "acquire"
144+          });
145+        },
146+        async sendControllerHeartbeat() {}
147+      },
148+      now: () => 100
149+    }
150+  );
151+
152+  assert.equal(runtime.getRuntimeSnapshot().runtime.started, false);
153+
154+  const startedSnapshot = await runtime.start();
155+  assert.equal(startedSnapshot.runtime.started, true);
156+  assert.equal(startedSnapshot.daemon.leaseState, "leader");
157+  assert.equal(startedSnapshot.loops.heartbeat, false);
158+  assert.equal(startedSnapshot.loops.lease, false);
159+  assert.equal(startedSnapshot.controlApi.usesPlaceholderToken, true);
160+  assert.match(startedSnapshot.warnings.join("\n"), /replace-me/);
161+
162+  const stoppedSnapshot = runtime.stop();
163+  assert.equal(stoppedSnapshot.runtime.started, false);
164+});
M apps/conductor-daemon/src/index.ts
+1140, -29
   1@@ -3,12 +3,22 @@ export type LeaseState = "leader" | "standby" | "degraded";
   2 export type SchedulerDecision = "scheduled" | "skipped_not_leader";
   3 export type TimerHandle = ReturnType<typeof globalThis.setInterval>;
   4 export type LeaderLeaseOperation = "acquire" | "renew";
   5+export type ConductorCliAction = "checklist" | "config" | "help" | "start";
   6 
   7 const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
   8 const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
   9 const DEFAULT_LEASE_TTL_SEC = 30;
  10 const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
  11 
  12+const STARTUP_CHECKLIST: StartupChecklistItem[] = [
  13+  { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
  14+  { key: "acquire-lease", description: "尝试获取或续租 leader lease" },
  15+  { key: "load-runs", description: "扫描本地未完成 runs" },
  16+  { key: "reconcile-runs", description: "对账本地过期 runs 与控制平面状态" },
  17+  { key: "start-heartbeat-loop", description: "启动 controller heartbeat loop" },
  18+  { key: "start-scheduler", description: "仅 leader 进入 scheduler loop,standby 拒绝调度" }
  19+];
  20+
  21 export interface ControllerHeartbeatInput {
  22   controllerId: string;
  23   host: string;
  24@@ -66,6 +76,33 @@ export interface ConductorConfig {
  25   startedAt?: number;
  26 }
  27 
  28+export interface ConductorRuntimePaths {
  29+  logsDir: string | null;
  30+  runsDir: string | null;
  31+  stateDir: string | null;
  32+  tmpDir: string | null;
  33+  worktreesDir: string | null;
  34+}
  35+
  36+export interface ConductorRuntimeConfig extends ConductorConfig {
  37+  localApiBase?: string | null;
  38+  paths?: Partial<ConductorRuntimePaths>;
  39+  sharedToken?: string | null;
  40+}
  41+
  42+export interface ResolvedConductorRuntimeConfig extends ConductorConfig {
  43+  heartbeatIntervalMs: number;
  44+  leaseRenewIntervalMs: number;
  45+  leaseTtlSec: number;
  46+  localApiBase: string | null;
  47+  paths: ConductorRuntimePaths;
  48+  preferred: boolean;
  49+  priority: number;
  50+  renewFailureThreshold: number;
  51+  sharedToken: string | null;
  52+  version: string | null;
  53+}
  54+
  55 export interface StartupChecklistItem {
  56   key: string;
  57   description: string;
  58@@ -87,6 +124,29 @@ export interface ConductorStatusSnapshot {
  59   lastError: string | null;
  60 }
  61 
  62+export interface ConductorRuntimeSnapshot {
  63+  daemon: ConductorStatusSnapshot;
  64+  identity: string;
  65+  loops: {
  66+    heartbeat: boolean;
  67+    lease: boolean;
  68+  };
  69+  paths: ConductorRuntimePaths;
  70+  controlApi: {
  71+    baseUrl: string;
  72+    localApiBase: string | null;
  73+    hasSharedToken: boolean;
  74+    usesPlaceholderToken: boolean;
  75+  };
  76+  runtime: {
  77+    pid: number | null;
  78+    started: boolean;
  79+    startedAt: number;
  80+  };
  81+  startupChecklist: StartupChecklistItem[];
  82+  warnings: string[];
  83+}
  84+
  85 export interface SchedulerContext {
  86   controllerId: string;
  87   host: string;
  88@@ -98,6 +158,12 @@ export interface ConductorControlApiClient {
  89   sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void>;
  90 }
  91 
  92+export interface ConductorControlApiClientOptions {
  93+  bearerToken?: string | null;
  94+  defaultHeaders?: Record<string, string>;
  95+  fetchImpl?: typeof fetch;
  96+}
  97+
  98 export interface ConductorDaemonHooks {
  99   loadLocalRuns?: () => Promise<void>;
 100   onLeaseStateChange?: (snapshot: ConductorStatusSnapshot) => Promise<void> | void;
 101@@ -108,12 +174,61 @@ export interface ConductorDaemonOptions {
 102   autoStartLoops?: boolean;
 103   clearIntervalImpl?: (handle: TimerHandle) => void;
 104   client?: ConductorControlApiClient;
 105+  controlApiBearerToken?: string | null;
 106   fetchImpl?: typeof fetch;
 107   hooks?: ConductorDaemonHooks;
 108   now?: () => number;
 109   setIntervalImpl?: (handler: () => void, intervalMs: number) => TimerHandle;
 110 }
 111 
 112+export interface ConductorRuntimeOptions extends ConductorDaemonOptions {}
 113+
 114+export type ConductorEnvironment = Record<string, string | undefined>;
 115+
 116+export interface ConductorTextWriter {
 117+  write(chunk: string): unknown;
 118+}
 119+
 120+type ConductorOutputWriter = ConductorTextWriter | typeof console;
 121+
 122+export interface ConductorProcessLike {
 123+  argv: string[];
 124+  env: ConductorEnvironment;
 125+  exitCode?: number;
 126+  off?(event: string, listener: () => void): unknown;
 127+  on?(event: string, listener: () => void): unknown;
 128+  pid?: number;
 129+}
 130+
 131+export type ConductorCliRequest =
 132+  | {
 133+      action: "help";
 134+    }
 135+  | {
 136+      action: "checklist";
 137+      printJson: boolean;
 138+    }
 139+  | {
 140+      action: "config";
 141+      config: ResolvedConductorRuntimeConfig;
 142+      printJson: boolean;
 143+    }
 144+  | {
 145+      action: "start";
 146+      config: ResolvedConductorRuntimeConfig;
 147+      printJson: boolean;
 148+      runOnce: boolean;
 149+    };
 150+
 151+export interface RunConductorCliOptions {
 152+  argv?: readonly string[];
 153+  env?: ConductorEnvironment;
 154+  fetchImpl?: typeof fetch;
 155+  processLike?: ConductorProcessLike;
 156+  stderr?: ConductorTextWriter;
 157+  stdout?: ConductorTextWriter;
 158+}
 159+
 160 interface ConductorRuntimeState {
 161   consecutiveRenewFailures: number;
 162   currentLeaderId: string | null;
 163@@ -125,10 +240,67 @@ interface ConductorRuntimeState {
 164   leaseState: LeaseState;
 165 }
 166 
 167+interface ControlApiSuccessEnvelope<T> {
 168+  ok: true;
 169+  request_id: string;
 170+  data: T;
 171+}
 172+
 173+interface ControlApiErrorEnvelope {
 174+  ok: false;
 175+  request_id: string;
 176+  error: string;
 177+  message: string;
 178+  details?: unknown;
 179+}
 180+
 181+interface JsonRequestOptions {
 182+  headers?: Record<string, string>;
 183+}
 184+
 185+interface CliValueOverrides {
 186+  controlApiBase?: string;
 187+  heartbeatIntervalMs?: string;
 188+  host?: string;
 189+  leaseRenewIntervalMs?: string;
 190+  leaseTtlSec?: string;
 191+  localApiBase?: string;
 192+  logsDir?: string;
 193+  nodeId?: string;
 194+  preferred?: boolean;
 195+  priority?: string;
 196+  renewFailureThreshold?: string;
 197+  role?: string;
 198+  runOnce: boolean;
 199+  runsDir?: string;
 200+  sharedToken?: string;
 201+  stateDir?: string;
 202+  tmpDir?: string;
 203+  version?: string;
 204+  worktreesDir?: string;
 205+}
 206+
 207+function getDefaultStartupChecklist(): StartupChecklistItem[] {
 208+  return STARTUP_CHECKLIST.map((item) => ({ ...item }));
 209+}
 210+
 211+function defaultNowUnixSeconds(): number {
 212+  return Math.floor(Date.now() / 1000);
 213+}
 214+
 215 function normalizeBaseUrl(baseUrl: string): string {
 216   return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
 217 }
 218 
 219+function normalizeOptionalString(value: string | null | undefined): string | null {
 220+  if (value == null) {
 221+    return null;
 222+  }
 223+
 224+  const normalized = value.trim();
 225+  return normalized === "" ? null : normalized;
 226+}
 227+
 228 function toErrorMessage(error: unknown): string {
 229   if (error instanceof Error) {
 230     return error.message;
 231@@ -137,43 +309,292 @@ function toErrorMessage(error: unknown): string {
 232   return String(error);
 233 }
 234 
 235+function isControlApiSuccessEnvelope<T>(value: unknown): value is ControlApiSuccessEnvelope<T> {
 236+  if (value === null || typeof value !== "object") {
 237+    return false;
 238+  }
 239+
 240+  const record = value as Record<string, unknown>;
 241+  return record.ok === true && "data" in record;
 242+}
 243+
 244+function isControlApiErrorEnvelope(value: unknown): value is ControlApiErrorEnvelope {
 245+  if (value === null || typeof value !== "object") {
 246+    return false;
 247+  }
 248+
 249+  const record = value as Record<string, unknown>;
 250+  return record.ok === false && typeof record.error === "string" && typeof record.message === "string";
 251+}
 252+
 253+function asRecord(value: unknown): Record<string, unknown> | null {
 254+  if (value === null || typeof value !== "object" || Array.isArray(value)) {
 255+    return null;
 256+  }
 257+
 258+  return value as Record<string, unknown>;
 259+}
 260+
 261+function compactRecord(input: Record<string, unknown>): Record<string, unknown> {
 262+  const result: Record<string, unknown> = {};
 263+
 264+  for (const [key, value] of Object.entries(input)) {
 265+    if (value !== undefined) {
 266+      result[key] = value;
 267+    }
 268+  }
 269+
 270+  return result;
 271+}
 272+
 273+function readFirstString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
 274+  for (const key of keys) {
 275+    const value = record[key];
 276+
 277+    if (typeof value === "string" && value.trim() !== "") {
 278+      return value;
 279+    }
 280+  }
 281+
 282+  return undefined;
 283+}
 284+
 285+function readFirstNullableString(record: Record<string, unknown>, keys: readonly string[]): string | null | undefined {
 286+  for (const key of keys) {
 287+    const value = record[key];
 288+
 289+    if (value === null) {
 290+      return null;
 291+    }
 292+
 293+    if (typeof value === "string") {
 294+      return value;
 295+    }
 296+  }
 297+
 298+  return undefined;
 299+}
 300+
 301+function readFirstNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
 302+  for (const key of keys) {
 303+    const value = record[key];
 304+
 305+    if (typeof value === "number" && Number.isFinite(value)) {
 306+      return value;
 307+    }
 308+  }
 309+
 310+  return undefined;
 311+}
 312+
 313+function readFirstBoolean(record: Record<string, unknown>, keys: readonly string[]): boolean | undefined {
 314+  for (const key of keys) {
 315+    const value = record[key];
 316+
 317+    if (typeof value === "boolean") {
 318+      return value;
 319+    }
 320+  }
 321+
 322+  return undefined;
 323+}
 324+
 325+function parseResponseJson(text: string, path: string): unknown {
 326+  try {
 327+    return JSON.parse(text) as unknown;
 328+  } catch {
 329+    throw new Error(`Control API returned invalid JSON for ${path}.`);
 330+  }
 331+}
 332+
 333+function buildControlApiError(
 334+  path: string,
 335+  status: number,
 336+  statusText: string,
 337+  parsedBody: unknown,
 338+  rawBody: string
 339+): Error {
 340+  if (isControlApiErrorEnvelope(parsedBody)) {
 341+    return new Error(`Control API ${status} ${path}: ${parsedBody.error}: ${parsedBody.message}`);
 342+  }
 343+
 344+  if (rawBody !== "") {
 345+    return new Error(`Control API ${status} ${path}: ${rawBody}`);
 346+  }
 347+
 348+  return new Error(`Control API ${status} ${path}: ${statusText}`);
 349+}
 350+
 351 async function postJson<T>(
 352   fetchImpl: typeof fetch,
 353   baseUrl: string,
 354   path: string,
 355-  body: unknown
 356+  body: unknown,
 357+  options: JsonRequestOptions = {}
 358 ): Promise<T> {
 359+  const headers = new Headers(options.headers);
 360+  headers.set("accept", "application/json");
 361+  headers.set("content-type", "application/json");
 362+
 363   const response = await fetchImpl(`${normalizeBaseUrl(baseUrl)}${path}`, {
 364     method: "POST",
 365-    headers: {
 366-      "content-type": "application/json"
 367-    },
 368+    headers,
 369     body: JSON.stringify(body)
 370   });
 371   const text = await response.text();
 372+  const parsedBody = text === "" ? undefined : parseResponseJson(text, path);
 373 
 374   if (!response.ok) {
 375-    const detail = text === "" ? response.statusText : text;
 376-    throw new Error(`Control API ${response.status} ${path}: ${detail}`);
 377+    throw buildControlApiError(path, response.status, response.statusText, parsedBody, text);
 378   }
 379 
 380-  if (text === "") {
 381+  if (parsedBody === undefined) {
 382     return undefined as T;
 383   }
 384 
 385-  return JSON.parse(text) as T;
 386+  if (isControlApiSuccessEnvelope<T>(parsedBody)) {
 387+    return parsedBody.data;
 388+  }
 389+
 390+  if (isControlApiErrorEnvelope(parsedBody)) {
 391+    throw new Error(`Control API ${path}: ${parsedBody.error}: ${parsedBody.message}`);
 392+  }
 393+
 394+  return parsedBody as T;
 395+}
 396+
 397+function normalizeLeaseRecord(
 398+  value: unknown,
 399+  fallback: Pick<LeaderLeaseRecord, "holderHost" | "holderId" | "leaseExpiresAt" | "renewedAt" | "term">
 400+): LeaderLeaseRecord {
 401+  const record = asRecord(value) ?? {};
 402+
 403+  return {
 404+    leaseName: readFirstString(record, ["leaseName", "lease_name"]) ?? "global",
 405+    holderId: readFirstString(record, ["holderId", "holder_id"]) ?? fallback.holderId,
 406+    holderHost: readFirstString(record, ["holderHost", "holder_host"]) ?? fallback.holderHost,
 407+    term: readFirstNumber(record, ["term"]) ?? fallback.term,
 408+    leaseExpiresAt:
 409+      readFirstNumber(record, ["leaseExpiresAt", "lease_expires_at"]) ?? fallback.leaseExpiresAt,
 410+    renewedAt: readFirstNumber(record, ["renewedAt", "renewed_at"]) ?? fallback.renewedAt,
 411+    preferredHolderId:
 412+      readFirstNullableString(record, ["preferredHolderId", "preferred_holder_id"]) ?? null,
 413+    metadataJson: readFirstNullableString(record, ["metadataJson", "metadata_json"]) ?? null
 414+  };
 415+}
 416+
 417+function normalizeAcquireLeaderLeaseResult(
 418+  value: unknown,
 419+  input: LeaderLeaseAcquireInput
 420+): LeaderLeaseAcquireResult {
 421+  const record = asRecord(value);
 422+
 423+  if (!record) {
 424+    throw new Error("Control API /v1/leader/acquire returned a non-object payload.");
 425+  }
 426+
 427+  const holderId = readFirstString(record, ["holderId", "holder_id"]);
 428+  const term = readFirstNumber(record, ["term"]);
 429+  const leaseExpiresAt = readFirstNumber(record, ["leaseExpiresAt", "lease_expires_at"]);
 430+
 431+  if (!holderId || term == null || leaseExpiresAt == null) {
 432+    throw new Error("Control API /v1/leader/acquire response is missing lease identity fields.");
 433+  }
 434+
 435+  const holderHost =
 436+    readFirstString(record, ["holderHost", "holder_host"]) ??
 437+    (holderId === input.controllerId ? input.host : "unknown");
 438+  const renewedAt =
 439+    readFirstNumber(record, ["renewedAt", "renewed_at"]) ?? input.now ?? defaultNowUnixSeconds();
 440+  const isLeader =
 441+    readFirstBoolean(record, ["isLeader", "is_leader"]) ?? holderId === input.controllerId;
 442+  const rawOperation = readFirstString(record, ["operation"]);
 443+  const operation: LeaderLeaseOperation = rawOperation === "renew" ? "renew" : "acquire";
 444+
 445+  return {
 446+    holderId,
 447+    holderHost,
 448+    term,
 449+    leaseExpiresAt,
 450+    renewedAt,
 451+    isLeader,
 452+    operation,
 453+    lease: normalizeLeaseRecord(record.lease, {
 454+      holderHost,
 455+      holderId,
 456+      leaseExpiresAt,
 457+      renewedAt,
 458+      term
 459+    })
 460+  };
 461+}
 462+
 463+function resolveFetchClientOptions(
 464+  fetchOrOptions: typeof fetch | ConductorControlApiClientOptions | undefined,
 465+  maybeOptions: ConductorControlApiClientOptions
 466+): { fetchImpl: typeof fetch; options: ConductorControlApiClientOptions } {
 467+  if (typeof fetchOrOptions === "function") {
 468+    return {
 469+      fetchImpl: fetchOrOptions,
 470+      options: maybeOptions
 471+    };
 472+  }
 473+
 474+  return {
 475+    fetchImpl: fetchOrOptions?.fetchImpl ?? globalThis.fetch,
 476+    options: fetchOrOptions ?? {}
 477+  };
 478 }
 479 
 480 export function createFetchControlApiClient(
 481   baseUrl: string,
 482-  fetchImpl: typeof fetch = globalThis.fetch
 483+  fetchOrOptions: typeof fetch | ConductorControlApiClientOptions = globalThis.fetch,
 484+  maybeOptions: ConductorControlApiClientOptions = {}
 485 ): ConductorControlApiClient {
 486+  const { fetchImpl, options } = resolveFetchClientOptions(fetchOrOptions, maybeOptions);
 487+  const requestHeaders = { ...(options.defaultHeaders ?? {}) };
 488+
 489+  if (options.bearerToken) {
 490+    requestHeaders.authorization = `Bearer ${options.bearerToken}`;
 491+  }
 492+
 493   return {
 494     async acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult> {
 495-      return postJson<LeaderLeaseAcquireResult>(fetchImpl, baseUrl, "/v1/leader/acquire", input);
 496+      const payload = compactRecord({
 497+        controller_id: input.controllerId,
 498+        host: input.host,
 499+        ttl_sec: input.ttlSec,
 500+        preferred: input.preferred ?? false,
 501+        metadata_json: input.metadataJson ?? undefined,
 502+        now: input.now
 503+      });
 504+      const response = await postJson<unknown>(
 505+        fetchImpl,
 506+        baseUrl,
 507+        "/v1/leader/acquire",
 508+        payload,
 509+        {
 510+          headers: requestHeaders
 511+        }
 512+      );
 513+
 514+      return normalizeAcquireLeaderLeaseResult(response, input);
 515     },
 516     async sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void> {
 517-      await postJson(fetchImpl, baseUrl, "/v1/controllers/heartbeat", input);
 518+      const payload = compactRecord({
 519+        controller_id: input.controllerId,
 520+        host: input.host,
 521+        role: input.role,
 522+        priority: input.priority,
 523+        status: input.status,
 524+        version: input.version ?? undefined,
 525+        heartbeat_at: input.heartbeatAt,
 526+        started_at: input.startedAt
 527+      });
 528+
 529+      await postJson(fetchImpl, baseUrl, "/v1/controllers/heartbeat", payload, {
 530+        headers: requestHeaders
 531+      });
 532     }
 533   };
 534 }
 535@@ -200,38 +621,39 @@ export class ConductorDaemon {
 536     leaseState: "standby"
 537   };
 538 
 539-  constructor(
 540-    config: ConductorConfig,
 541-    options: ConductorDaemonOptions = {}
 542-  ) {
 543+  constructor(config: ConductorConfig, options: ConductorDaemonOptions = {}) {
 544     this.config = config;
 545     this.autoStartLoops = options.autoStartLoops ?? true;
 546     this.clearIntervalImpl =
 547       options.clearIntervalImpl ?? ((handle) => globalThis.clearInterval(handle));
 548     this.client =
 549-      options.client ?? createFetchControlApiClient(config.controlApiBase, options.fetchImpl);
 550+      options.client ??
 551+      createFetchControlApiClient(config.controlApiBase, {
 552+        bearerToken: options.controlApiBearerToken,
 553+        fetchImpl: options.fetchImpl
 554+      });
 555     this.hooks = options.hooks;
 556-    this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
 557+    this.now = options.now ?? defaultNowUnixSeconds;
 558     this.setIntervalImpl =
 559       options.setIntervalImpl ?? ((handler, intervalMs) => globalThis.setInterval(handler, intervalMs));
 560     this.startedAt = config.startedAt ?? this.now();
 561   }
 562 
 563   getStartupChecklist(): StartupChecklistItem[] {
 564-    return [
 565-      { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
 566-      { key: "start-heartbeat-loop", description: "启动 controller heartbeat loop" },
 567-      { key: "acquire-lease", description: "尝试获取或续租 leader lease" },
 568-      { key: "load-runs", description: "扫描本地未完成 runs" },
 569-      { key: "reconcile-runs", description: "对账本地过期 runs 与控制平面状态" },
 570-      { key: "start-scheduler", description: "仅 leader 进入 scheduler loop,standby 拒绝调度" }
 571-    ];
 572+    return getDefaultStartupChecklist();
 573   }
 574 
 575   describeIdentity(): string {
 576     return `${this.config.nodeId}@${this.config.host}(${this.config.role})`;
 577   }
 578 
 579+  getLoopStatus(): { heartbeat: boolean; lease: boolean } {
 580+    return {
 581+      heartbeat: this.heartbeatTimer != null,
 582+      lease: this.leaseTimer != null
 583+    };
 584+  }
 585+
 586   getStatusSnapshot(now: number = this.now()): ConductorStatusSnapshot {
 587     return {
 588       nodeId: this.config.nodeId,
 589@@ -275,10 +697,6 @@ export class ConductorDaemon {
 590       await this.transitionTo("degraded");
 591     }
 592 
 593-    if (this.autoStartLoops) {
 594-      this.startLoops();
 595-    }
 596-
 597     try {
 598       await this.runLeaseCycle();
 599     } catch {
 600@@ -288,6 +706,10 @@ export class ConductorDaemon {
 601     await this.hooks?.loadLocalRuns?.();
 602     await this.hooks?.reconcileLocalRuns?.();
 603 
 604+    if (this.autoStartLoops) {
 605+      this.startLoops();
 606+    }
 607+
 608     return this.state.leaseState;
 609   }
 610 
 611@@ -439,3 +861,692 @@ export class ConductorDaemon {
 612     await this.hooks?.onLeaseStateChange?.(this.getStatusSnapshot());
 613   }
 614 }
 615+
 616+function createDefaultNodeId(host: string, role: ConductorRole): string {
 617+  return `${host}-${role === "primary" ? "main" : "standby"}`;
 618+}
 619+
 620+function parseConductorRole(name: string, value: string | null): ConductorRole {
 621+  if (value === "primary" || value === "standby") {
 622+    return value;
 623+  }
 624+
 625+  throw new Error(`${name} must be either "primary" or "standby".`);
 626+}
 627+
 628+function parseBooleanValue(name: string, value: string | boolean | null | undefined): boolean | undefined {
 629+  if (typeof value === "boolean") {
 630+    return value;
 631+  }
 632+
 633+  if (value == null) {
 634+    return undefined;
 635+  }
 636+
 637+  switch (value.trim().toLowerCase()) {
 638+    case "1":
 639+    case "on":
 640+    case "true":
 641+    case "yes":
 642+      return true;
 643+    case "0":
 644+    case "false":
 645+    case "no":
 646+    case "off":
 647+      return false;
 648+    default:
 649+      throw new Error(`${name} must be a boolean flag.`);
 650+  }
 651+}
 652+
 653+function parseIntegerValue(
 654+  name: string,
 655+  value: string | null | undefined,
 656+  options: { minimum?: number } = {}
 657+): number | undefined {
 658+  if (value == null) {
 659+    return undefined;
 660+  }
 661+
 662+  const normalized = value.trim();
 663+
 664+  if (!/^-?\d+$/u.test(normalized)) {
 665+    throw new Error(`${name} must be an integer.`);
 666+  }
 667+
 668+  const parsed = Number(normalized);
 669+
 670+  if (!Number.isSafeInteger(parsed)) {
 671+    throw new Error(`${name} is outside the supported integer range.`);
 672+  }
 673+
 674+  if (options.minimum != null && parsed < options.minimum) {
 675+    throw new Error(`${name} must be >= ${options.minimum}.`);
 676+  }
 677+
 678+  return parsed;
 679+}
 680+
 681+function resolvePathConfig(paths?: Partial<ConductorRuntimePaths>): ConductorRuntimePaths {
 682+  return {
 683+    logsDir: normalizeOptionalString(paths?.logsDir),
 684+    runsDir: normalizeOptionalString(paths?.runsDir),
 685+    stateDir: normalizeOptionalString(paths?.stateDir),
 686+    tmpDir: normalizeOptionalString(paths?.tmpDir),
 687+    worktreesDir: normalizeOptionalString(paths?.worktreesDir)
 688+  };
 689+}
 690+
 691+export function resolveConductorRuntimeConfig(
 692+  config: ConductorRuntimeConfig
 693+): ResolvedConductorRuntimeConfig {
 694+  const nodeId = normalizeOptionalString(config.nodeId);
 695+  const host = normalizeOptionalString(config.host);
 696+  const controlApiBase = normalizeOptionalString(config.controlApiBase);
 697+
 698+  if (!nodeId) {
 699+    throw new Error("Conductor config requires a non-empty nodeId.");
 700+  }
 701+
 702+  if (!host) {
 703+    throw new Error("Conductor config requires a non-empty host.");
 704+  }
 705+
 706+  if (!controlApiBase) {
 707+    throw new Error("Conductor config requires a non-empty controlApiBase.");
 708+  }
 709+
 710+  const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
 711+  const leaseRenewIntervalMs = config.leaseRenewIntervalMs ?? DEFAULT_LEASE_RENEW_INTERVAL_MS;
 712+  const leaseTtlSec = config.leaseTtlSec ?? DEFAULT_LEASE_TTL_SEC;
 713+  const renewFailureThreshold = config.renewFailureThreshold ?? DEFAULT_RENEW_FAILURE_THRESHOLD;
 714+  const priority = config.priority ?? (config.role === "primary" ? 100 : 50);
 715+
 716+  if (heartbeatIntervalMs <= 0) {
 717+    throw new Error("Conductor heartbeatIntervalMs must be > 0.");
 718+  }
 719+
 720+  if (leaseRenewIntervalMs <= 0) {
 721+    throw new Error("Conductor leaseRenewIntervalMs must be > 0.");
 722+  }
 723+
 724+  if (leaseTtlSec <= 0) {
 725+    throw new Error("Conductor leaseTtlSec must be > 0.");
 726+  }
 727+
 728+  if (renewFailureThreshold <= 0) {
 729+    throw new Error("Conductor renewFailureThreshold must be > 0.");
 730+  }
 731+
 732+  return {
 733+    ...config,
 734+    nodeId,
 735+    host,
 736+    role: parseConductorRole("Conductor role", config.role),
 737+    controlApiBase: normalizeBaseUrl(controlApiBase),
 738+    heartbeatIntervalMs,
 739+    leaseRenewIntervalMs,
 740+    leaseTtlSec,
 741+    localApiBase: normalizeOptionalString(config.localApiBase),
 742+    paths: resolvePathConfig(config.paths),
 743+    preferred: config.preferred ?? config.role === "primary",
 744+    priority,
 745+    renewFailureThreshold,
 746+    sharedToken: normalizeOptionalString(config.sharedToken),
 747+    version: normalizeOptionalString(config.version)
 748+  };
 749+}
 750+
 751+function readOptionValue(argv: string[], option: string, index: number): string {
 752+  const value = argv[index + 1];
 753+
 754+  if (!value || value.startsWith("--")) {
 755+    throw new Error(`${option} requires a value.`);
 756+  }
 757+
 758+  return value;
 759+}
 760+
 761+function isCliAction(value: string): value is ConductorCliAction {
 762+  return value === "checklist" || value === "config" || value === "help" || value === "start";
 763+}
 764+
 765+function resolveRuntimeConfigFromSources(
 766+  env: ConductorEnvironment,
 767+  overrides: CliValueOverrides
 768+): ResolvedConductorRuntimeConfig {
 769+  const role = parseConductorRole(
 770+    "Conductor role",
 771+    normalizeOptionalString(overrides.role ?? env.BAA_CONDUCTOR_ROLE) ?? "primary"
 772+  );
 773+  const host = normalizeOptionalString(overrides.host ?? env.BAA_CONDUCTOR_HOST) ?? "localhost";
 774+  const nodeId =
 775+    normalizeOptionalString(overrides.nodeId ?? env.BAA_NODE_ID) ?? createDefaultNodeId(host, role);
 776+  const controlApiBase = normalizeOptionalString(overrides.controlApiBase ?? env.BAA_CONTROL_API_BASE);
 777+
 778+  if (!controlApiBase) {
 779+    throw new Error("Missing control API base URL. Use --control-api-base or BAA_CONTROL_API_BASE.");
 780+  }
 781+
 782+  return resolveConductorRuntimeConfig({
 783+    nodeId,
 784+    host,
 785+    role,
 786+    controlApiBase,
 787+    priority: parseIntegerValue("Conductor priority", overrides.priority ?? env.BAA_CONDUCTOR_PRIORITY, {
 788+      minimum: 0
 789+    }),
 790+    version: normalizeOptionalString(overrides.version ?? env.BAA_CONDUCTOR_VERSION),
 791+    preferred:
 792+      parseBooleanValue(
 793+        "Conductor preferred flag",
 794+        overrides.preferred ?? normalizeOptionalString(env.BAA_CONDUCTOR_PREFERRED)
 795+      ) ?? role === "primary",
 796+    heartbeatIntervalMs: parseIntegerValue(
 797+      "Conductor heartbeat interval",
 798+      overrides.heartbeatIntervalMs ?? env.BAA_CONDUCTOR_HEARTBEAT_INTERVAL_MS,
 799+      { minimum: 1 }
 800+    ),
 801+    leaseRenewIntervalMs: parseIntegerValue(
 802+      "Conductor lease renew interval",
 803+      overrides.leaseRenewIntervalMs ?? env.BAA_CONDUCTOR_LEASE_RENEW_INTERVAL_MS,
 804+      { minimum: 1 }
 805+    ),
 806+    leaseTtlSec: parseIntegerValue(
 807+      "Conductor lease ttl",
 808+      overrides.leaseTtlSec ?? env.BAA_CONDUCTOR_LEASE_TTL_SEC,
 809+      { minimum: 1 }
 810+    ),
 811+    renewFailureThreshold: parseIntegerValue(
 812+      "Conductor renew failure threshold",
 813+      overrides.renewFailureThreshold ?? env.BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD,
 814+      { minimum: 1 }
 815+    ),
 816+    localApiBase: normalizeOptionalString(overrides.localApiBase ?? env.BAA_CONDUCTOR_LOCAL_API),
 817+    sharedToken: normalizeOptionalString(overrides.sharedToken ?? env.BAA_SHARED_TOKEN),
 818+    paths: {
 819+      logsDir: normalizeOptionalString(overrides.logsDir ?? env.BAA_LOGS_DIR),
 820+      runsDir: normalizeOptionalString(overrides.runsDir ?? env.BAA_RUNS_DIR),
 821+      stateDir: normalizeOptionalString(overrides.stateDir ?? env.BAA_STATE_DIR),
 822+      tmpDir: normalizeOptionalString(overrides.tmpDir ?? env.BAA_TMP_DIR),
 823+      worktreesDir: normalizeOptionalString(overrides.worktreesDir ?? env.BAA_WORKTREES_DIR)
 824+    }
 825+  });
 826+}
 827+
 828+export function parseConductorCliRequest(
 829+  argv: readonly string[],
 830+  env: ConductorEnvironment = {}
 831+): ConductorCliRequest {
 832+  const tokens = [...argv];
 833+  let action: ConductorCliAction = "start";
 834+
 835+  if (tokens[0] && !tokens[0].startsWith("--")) {
 836+    const maybeAction = tokens.shift();
 837+
 838+    if (!maybeAction || !isCliAction(maybeAction)) {
 839+      throw new Error(`Unknown conductor command: ${maybeAction ?? "<empty>"}.`);
 840+    }
 841+
 842+    action = maybeAction;
 843+  }
 844+
 845+  let printJson = false;
 846+  let showHelp = action === "help";
 847+  const overrides: CliValueOverrides = {
 848+    runOnce: false
 849+  };
 850+
 851+  for (let index = 0; index < tokens.length; index += 1) {
 852+    const token = tokens[index];
 853+
 854+    switch (token) {
 855+      case "--help":
 856+        showHelp = true;
 857+        break;
 858+      case "--json":
 859+        printJson = true;
 860+        break;
 861+      case "--run-once":
 862+        overrides.runOnce = true;
 863+        break;
 864+      case "--preferred":
 865+        overrides.preferred = true;
 866+        break;
 867+      case "--no-preferred":
 868+        overrides.preferred = false;
 869+        break;
 870+      case "--node-id":
 871+        overrides.nodeId = readOptionValue(tokens, token, index);
 872+        index += 1;
 873+        break;
 874+      case "--host":
 875+        overrides.host = readOptionValue(tokens, token, index);
 876+        index += 1;
 877+        break;
 878+      case "--role":
 879+        overrides.role = readOptionValue(tokens, token, index);
 880+        index += 1;
 881+        break;
 882+      case "--control-api-base":
 883+        overrides.controlApiBase = readOptionValue(tokens, token, index);
 884+        index += 1;
 885+        break;
 886+      case "--local-api":
 887+        overrides.localApiBase = readOptionValue(tokens, token, index);
 888+        index += 1;
 889+        break;
 890+      case "--shared-token":
 891+        overrides.sharedToken = readOptionValue(tokens, token, index);
 892+        index += 1;
 893+        break;
 894+      case "--priority":
 895+        overrides.priority = readOptionValue(tokens, token, index);
 896+        index += 1;
 897+        break;
 898+      case "--version":
 899+        overrides.version = readOptionValue(tokens, token, index);
 900+        index += 1;
 901+        break;
 902+      case "--heartbeat-interval-ms":
 903+        overrides.heartbeatIntervalMs = readOptionValue(tokens, token, index);
 904+        index += 1;
 905+        break;
 906+      case "--lease-renew-interval-ms":
 907+        overrides.leaseRenewIntervalMs = readOptionValue(tokens, token, index);
 908+        index += 1;
 909+        break;
 910+      case "--lease-ttl-sec":
 911+        overrides.leaseTtlSec = readOptionValue(tokens, token, index);
 912+        index += 1;
 913+        break;
 914+      case "--renew-failure-threshold":
 915+        overrides.renewFailureThreshold = readOptionValue(tokens, token, index);
 916+        index += 1;
 917+        break;
 918+      case "--runs-dir":
 919+        overrides.runsDir = readOptionValue(tokens, token, index);
 920+        index += 1;
 921+        break;
 922+      case "--worktrees-dir":
 923+        overrides.worktreesDir = readOptionValue(tokens, token, index);
 924+        index += 1;
 925+        break;
 926+      case "--logs-dir":
 927+        overrides.logsDir = readOptionValue(tokens, token, index);
 928+        index += 1;
 929+        break;
 930+      case "--tmp-dir":
 931+        overrides.tmpDir = readOptionValue(tokens, token, index);
 932+        index += 1;
 933+        break;
 934+      case "--state-dir":
 935+        overrides.stateDir = readOptionValue(tokens, token, index);
 936+        index += 1;
 937+        break;
 938+      default:
 939+        throw new Error(`Unknown conductor option: ${token}.`);
 940+    }
 941+  }
 942+
 943+  if (showHelp) {
 944+    return { action: "help" };
 945+  }
 946+
 947+  if (action === "checklist") {
 948+    return {
 949+      action,
 950+      printJson
 951+    };
 952+  }
 953+
 954+  const config = resolveRuntimeConfigFromSources(env, overrides);
 955+
 956+  if (action === "config") {
 957+    return {
 958+      action,
 959+      config,
 960+      printJson
 961+    };
 962+  }
 963+
 964+  return {
 965+    action: "start",
 966+    config,
 967+    printJson,
 968+    runOnce: overrides.runOnce
 969+  };
 970+}
 971+
 972+function usesPlaceholderToken(token: string | null): boolean {
 973+  return token === "replace-me";
 974+}
 975+
 976+function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[] {
 977+  const warnings: string[] = [];
 978+
 979+  if (config.sharedToken == null) {
 980+    warnings.push("BAA_SHARED_TOKEN is not configured; authenticated Control API requests may fail.");
 981+  } else if (usesPlaceholderToken(config.sharedToken)) {
 982+    warnings.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before production rollout.");
 983+  }
 984+
 985+  if (config.localApiBase == null) {
 986+    warnings.push("BAA_CONDUCTOR_LOCAL_API is not configured; only the in-process snapshot interface is available.");
 987+  }
 988+
 989+  if (config.leaseRenewIntervalMs >= config.leaseTtlSec * 1_000) {
 990+    warnings.push("lease renew interval is >= lease TTL; leader renewals may race with lease expiry.");
 991+  }
 992+
 993+  return warnings;
 994+}
 995+
 996+function getProcessLike(): ConductorProcessLike | undefined {
 997+  return (globalThis as { process?: ConductorProcessLike }).process;
 998+}
 999+
1000+function writeLine(writer: ConductorOutputWriter, line: string): void {
1001+  if ("write" in writer) {
1002+    writer.write(`${line}\n`);
1003+    return;
1004+  }
1005+
1006+  writer.log(line);
1007+}
1008+
1009+function formatChecklistText(checklist: StartupChecklistItem[]): string {
1010+  return checklist.map((item, index) => `${index + 1}. ${item.key}: ${item.description}`).join("\n");
1011+}
1012+
1013+function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
1014+  return [
1015+    `identity: ${config.nodeId}@${config.host}(${config.role})`,
1016+    `control_api_base: ${config.controlApiBase}`,
1017+    `local_api_base: ${config.localApiBase ?? "not-configured"}`,
1018+    `priority: ${config.priority}`,
1019+    `preferred: ${String(config.preferred)}`,
1020+    `heartbeat_interval_ms: ${config.heartbeatIntervalMs}`,
1021+    `lease_renew_interval_ms: ${config.leaseRenewIntervalMs}`,
1022+    `lease_ttl_sec: ${config.leaseTtlSec}`,
1023+    `renew_failure_threshold: ${config.renewFailureThreshold}`,
1024+    `shared_token_configured: ${String(config.sharedToken != null)}`,
1025+    `runs_dir: ${config.paths.runsDir ?? "not-configured"}`,
1026+    `worktrees_dir: ${config.paths.worktreesDir ?? "not-configured"}`,
1027+    `logs_dir: ${config.paths.logsDir ?? "not-configured"}`,
1028+    `tmp_dir: ${config.paths.tmpDir ?? "not-configured"}`,
1029+    `state_dir: ${config.paths.stateDir ?? "not-configured"}`
1030+  ].join("\n");
1031+}
1032+
1033+function formatRuntimeSummary(snapshot: ConductorRuntimeSnapshot): string {
1034+  return [
1035+    `identity=${snapshot.identity}`,
1036+    `lease=${snapshot.daemon.leaseState}`,
1037+    `leader=${snapshot.daemon.currentLeaderId ?? "none"}`,
1038+    `term=${snapshot.daemon.currentTerm ?? "none"}`,
1039+    `scheduler=${snapshot.daemon.schedulerEnabled ? "enabled" : "disabled"}`,
1040+    `heartbeat_loop=${snapshot.loops.heartbeat ? "running" : "stopped"}`,
1041+    `lease_loop=${snapshot.loops.lease ? "running" : "stopped"}`
1042+  ].join(" ");
1043+}
1044+
1045+function getUsageText(): string {
1046+  return [
1047+    "Usage:",
1048+    "  node apps/conductor-daemon/dist/index.js [start] [options]",
1049+    "  node --experimental-strip-types apps/conductor-daemon/src/index.ts [start] [options]",
1050+    "  node .../index.js config [options]",
1051+    "  node .../index.js checklist [--json]",
1052+    "  node .../index.js help",
1053+    "",
1054+    "Options:",
1055+    "  --node-id <id>",
1056+    "  --host <host>",
1057+    "  --role <primary|standby>",
1058+    "  --control-api-base <url>",
1059+    "  --local-api <url>",
1060+    "  --shared-token <token>",
1061+    "  --priority <integer>",
1062+    "  --version <string>",
1063+    "  --preferred | --no-preferred",
1064+    "  --heartbeat-interval-ms <integer>",
1065+    "  --lease-renew-interval-ms <integer>",
1066+    "  --lease-ttl-sec <integer>",
1067+    "  --renew-failure-threshold <integer>",
1068+    "  --runs-dir <path>",
1069+    "  --worktrees-dir <path>",
1070+    "  --logs-dir <path>",
1071+    "  --tmp-dir <path>",
1072+    "  --state-dir <path>",
1073+    "  --run-once",
1074+    "  --json",
1075+    "  --help",
1076+    "",
1077+    "Environment:",
1078+    "  BAA_NODE_ID",
1079+    "  BAA_CONDUCTOR_HOST",
1080+    "  BAA_CONDUCTOR_ROLE",
1081+    "  BAA_CONTROL_API_BASE",
1082+    "  BAA_CONDUCTOR_LOCAL_API",
1083+    "  BAA_SHARED_TOKEN",
1084+    "  BAA_CONDUCTOR_PRIORITY",
1085+    "  BAA_CONDUCTOR_VERSION",
1086+    "  BAA_CONDUCTOR_PREFERRED",
1087+    "  BAA_CONDUCTOR_HEARTBEAT_INTERVAL_MS",
1088+    "  BAA_CONDUCTOR_LEASE_RENEW_INTERVAL_MS",
1089+    "  BAA_CONDUCTOR_LEASE_TTL_SEC",
1090+    "  BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD",
1091+    "  BAA_RUNS_DIR",
1092+    "  BAA_WORKTREES_DIR",
1093+    "  BAA_LOGS_DIR",
1094+    "  BAA_TMP_DIR",
1095+    "  BAA_STATE_DIR"
1096+  ].join("\n");
1097+}
1098+
1099+export class ConductorRuntime {
1100+  private readonly config: ResolvedConductorRuntimeConfig;
1101+  private readonly daemon: ConductorDaemon;
1102+  private started = false;
1103+
1104+  constructor(config: ConductorRuntimeConfig, options: ConductorRuntimeOptions = {}) {
1105+    const now = options.now ?? defaultNowUnixSeconds;
1106+    const startedAt = config.startedAt ?? now();
1107+
1108+    this.config = resolveConductorRuntimeConfig({
1109+      ...config,
1110+      startedAt
1111+    });
1112+    this.daemon = new ConductorDaemon(this.config, {
1113+      ...options,
1114+      controlApiBearerToken: options.controlApiBearerToken ?? this.config.sharedToken,
1115+      now
1116+    });
1117+  }
1118+
1119+  async start(): Promise<ConductorRuntimeSnapshot> {
1120+    if (!this.started) {
1121+      await this.daemon.start();
1122+      this.started = true;
1123+    }
1124+
1125+    return this.getRuntimeSnapshot();
1126+  }
1127+
1128+  stop(): ConductorRuntimeSnapshot {
1129+    this.daemon.stop();
1130+    this.started = false;
1131+    return this.getRuntimeSnapshot();
1132+  }
1133+
1134+  getRuntimeSnapshot(now: number = defaultNowUnixSeconds()): ConductorRuntimeSnapshot {
1135+    return {
1136+      daemon: this.daemon.getStatusSnapshot(now),
1137+      identity: this.daemon.describeIdentity(),
1138+      loops: this.daemon.getLoopStatus(),
1139+      paths: { ...this.config.paths },
1140+      controlApi: {
1141+        baseUrl: this.config.controlApiBase,
1142+        localApiBase: this.config.localApiBase,
1143+        hasSharedToken: this.config.sharedToken != null,
1144+        usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
1145+      },
1146+      runtime: {
1147+        pid: getProcessLike()?.pid ?? null,
1148+        started: this.started,
1149+        startedAt: this.config.startedAt ?? now
1150+      },
1151+      startupChecklist: this.daemon.getStartupChecklist(),
1152+      warnings: buildRuntimeWarnings(this.config)
1153+    };
1154+  }
1155+}
1156+
1157+async function waitForShutdownSignal(processLike: ConductorProcessLike | undefined): Promise<string | null> {
1158+  const subscribe = processLike?.on;
1159+
1160+  if (!subscribe || !processLike) {
1161+    return null;
1162+  }
1163+
1164+  return new Promise((resolve) => {
1165+    const signals = ["SIGINT", "SIGTERM"] as const;
1166+    const listeners: Partial<Record<(typeof signals)[number], () => void>> = {};
1167+    const cleanup = () => {
1168+      if (!processLike.off) {
1169+        return;
1170+      }
1171+
1172+      for (const signal of signals) {
1173+        const listener = listeners[signal];
1174+
1175+        if (listener) {
1176+          processLike.off(signal, listener);
1177+        }
1178+      }
1179+    };
1180+
1181+    for (const signal of signals) {
1182+      const listener = () => {
1183+        cleanup();
1184+        resolve(signal);
1185+      };
1186+      listeners[signal] = listener;
1187+      subscribe.call(processLike, signal, listener);
1188+    }
1189+  });
1190+}
1191+
1192+export async function runConductorCli(options: RunConductorCliOptions = {}): Promise<number> {
1193+  const processLike = options.processLike ?? getProcessLike();
1194+  const stdout = options.stdout ?? console;
1195+  const stderr = options.stderr ?? console;
1196+  const argv = options.argv ?? processLike?.argv?.slice(2) ?? [];
1197+  const env = options.env ?? processLike?.env ?? {};
1198+  const request = parseConductorCliRequest(argv, env);
1199+
1200+  if (request.action === "help") {
1201+    writeLine(stdout, getUsageText());
1202+    return 0;
1203+  }
1204+
1205+  if (request.action === "checklist") {
1206+    const checklist = getDefaultStartupChecklist();
1207+
1208+    if (request.printJson) {
1209+      writeLine(stdout, JSON.stringify(checklist, null, 2));
1210+    } else {
1211+      writeLine(stdout, formatChecklistText(checklist));
1212+    }
1213+
1214+    return 0;
1215+  }
1216+
1217+  if (request.action === "config") {
1218+    if (request.printJson) {
1219+      writeLine(stdout, JSON.stringify(request.config, null, 2));
1220+    } else {
1221+      writeLine(stdout, formatConfigText(request.config));
1222+    }
1223+
1224+    for (const warning of buildRuntimeWarnings(request.config)) {
1225+      writeLine(stderr, `warning: ${warning}`);
1226+    }
1227+
1228+    return 0;
1229+  }
1230+
1231+  const runtime = new ConductorRuntime(request.config, {
1232+    autoStartLoops: !request.runOnce,
1233+    fetchImpl: options.fetchImpl
1234+  });
1235+  const snapshot = await runtime.start();
1236+
1237+  if (request.printJson || request.runOnce) {
1238+    writeLine(stdout, JSON.stringify(snapshot, null, 2));
1239+  } else {
1240+    writeLine(stdout, formatRuntimeSummary(snapshot));
1241+  }
1242+
1243+  for (const warning of snapshot.warnings) {
1244+    writeLine(stderr, `warning: ${warning}`);
1245+  }
1246+
1247+  if (request.runOnce) {
1248+    runtime.stop();
1249+    return 0;
1250+  }
1251+
1252+  const signal = await waitForShutdownSignal(processLike);
1253+  runtime.stop();
1254+
1255+  if (!request.printJson) {
1256+    writeLine(stdout, `conductor stopped${signal ? ` after ${signal}` : ""}`);
1257+  }
1258+
1259+  return 0;
1260+}
1261+
1262+function normalizeFilePath(path: string): string {
1263+  return path.replace(/\\/gu, "/");
1264+}
1265+
1266+function fileUrlToPath(fileUrl: string): string {
1267+  if (!fileUrl.startsWith("file://")) {
1268+    return fileUrl;
1269+  }
1270+
1271+  const decoded = decodeURIComponent(fileUrl.slice("file://".length));
1272+  return decoded.startsWith("/") && /^[A-Za-z]:/u.test(decoded.slice(1)) ? decoded.slice(1) : decoded;
1273+}
1274+
1275+function isMainModule(metaUrl: string, argv: readonly string[]): boolean {
1276+  const entryPoint = argv[1];
1277+
1278+  if (!entryPoint) {
1279+    return false;
1280+  }
1281+
1282+  return normalizeFilePath(entryPoint) === normalizeFilePath(fileUrlToPath(metaUrl));
1283+}
1284+
1285+if (isMainModule(import.meta.url, getProcessLike()?.argv ?? [])) {
1286+  void runConductorCli().then(
1287+    (exitCode) => {
1288+      const processLike = getProcessLike();
1289+
1290+      if (processLike) {
1291+        processLike.exitCode = exitCode;
1292+      }
1293+    },
1294+    (error: unknown) => {
1295+      console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1296+      const processLike = getProcessLike();
1297+
1298+      if (processLike) {
1299+        processLike.exitCode = 1;
1300+      }
1301+    }
1302+  );
1303+}
M coordination/tasks/T-015-conductor-runtime.md
+22, -9
 1@@ -1,15 +1,15 @@
 2 ---
 3 task_id: T-015
 4 title: Conductor 运行时接线
 5-status: todo
 6+status: review
 7 branch: feat/T-015-conductor-runtime
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12   - T-004
13 write_scope:
14   - apps/conductor-daemon/**
15-updated_at: 2026-03-21
16+updated_at: 2026-03-22
17 ---
18 
19 # T-015 Conductor 运行时接线
20@@ -55,25 +55,38 @@ updated_at: 2026-03-21
21 
22 ## files_changed
23 
24-- 待填写
25+- `apps/conductor-daemon/package.json`
26+- `apps/conductor-daemon/src/index.ts`
27+- `apps/conductor-daemon/src/index.test.js`
28 
29 ## commands_run
30 
31-- 待填写
32+- `npx --yes pnpm install`
33+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
34+- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
35+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
36+- `node apps/conductor-daemon/dist/index.js start --node-id mini-main --host mini --role primary --control-api-base http://127.0.0.1:9 --run-once --json`
37+- `git diff --check`
38 
39 ## result
40 
41-- 待填写
42+- `apps/conductor-daemon` 现在有可执行的 CLI/runtime 入口,支持 `start`、`config`、`checklist`,并能从 env/CLI 解析节点身份、control-api 地址、共享 token 与本地目录配置。
43+- control-api client 已按第三波 contract 补上 Bearer 认证、统一 envelope 解包,以及 `snake_case` 请求/响应兼容,能对接 `/v1/controllers/heartbeat` 和 `/v1/leader/acquire`。
44+- runtime 侧新增最小快照接口 `ConductorRuntime.getRuntimeSnapshot()`,并把启动流程整理为:初始 heartbeat、初始 lease cycle、本地 runs hook、后台 loop 启动;同时补了 CLI/config/client/runtime 相关测试。
45+- `conductor-daemon` 自身 `build` 脚本现在会产出 `dist/index.js`,验证过构建后的入口可以直接启动并在 control-api 不可达时稳定落到 `degraded` 快照。
46 
47 ## risks
48 
49-- 待填写
50+- `apps/control-api-worker` 当前主线仍以占位 handler 为主;虽然 client 侧协议已对齐,但真实 heartbeat / lease 持久化效果仍依赖 `T-014` 把 handler 接到 repository。
51+- 最小 runtime 接口当前是进程内快照与 CLI 输出,还没有单独的 loopback HTTP 服务;如果后续需要让 status-api 或 launchd 直接探活,可能还要在 `apps/conductor-daemon/**` 内补本地只读端点。
52+- `conductor-daemon` 的 app 级 build 现已可产出 dist,但根级统一构建链路仍需 `T-013` 做最终收口,避免后续在脚本层重复调整。
53 
54 ## next_handoff
55 
56-- 待填写
57+- `T-014` 可以直接用当前 client 期望的 Bearer + envelope + `snake_case` 约定实现真实 handler,并把 `/v1/controllers/heartbeat`、`/v1/leader/acquire` 返回值收敛到已测试的兼容形状。
58+- 后续 scheduler / status 相关任务可以直接复用 `ConductorRuntime` 与 `getRuntimeSnapshot()` 作为启动态、主备态和配置态的最小读取接口。
59+- `T-013` 如需统一根级 build/launchd 行为,应保留 `apps/conductor-daemon/dist/index.js` 作为稳定入口路径。
60 
61 ## notes
62 
63 - `2026-03-21`: 创建第三波任务卡
64-