baa-conductor

git clone 

commit
535f75c
parent
7138a3e
author
im_wower
date
2026-03-22 00:39:18 +0800 CST
Merge branch 'integration/third-wave-20260322'
31 files changed,  +2761, -253
M apps/conductor-daemon/package.json
+5, -1
 1@@ -2,8 +2,12 @@
 2   "name": "@baa-conductor/conductor-daemon",
 3   "private": true,
 4   "type": "module",
 5+  "main": "dist/index.js",
 6   "scripts": {
 7-    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
 8+    "build": "pnpm exec tsc -p tsconfig.json",
 9+    "dev": "node --experimental-strip-types src/index.ts",
10+    "start": "node dist/index.js",
11+    "test": "node --test --experimental-strip-types src/index.test.js",
12     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
13   }
14 }
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 apps/control-api-worker/package.json
+2, -1
 1@@ -2,8 +2,9 @@
 2   "name": "@baa-conductor/control-api-worker",
 3   "private": true,
 4   "type": "module",
 5+  "main": "dist/index.js",
 6   "scripts": {
 7-    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
 8+    "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/control-api-worker/dist BAA_DIST_ENTRY=apps/control-api-worker/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/auth=../../../packages/auth/src/index.js;@baa-conductor/db=../../../packages/db/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true BAA_EXPORT_DEFAULT=true pnpm -C ../.. run build:runtime-postprocess",
 9     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
10   }
11 }
M apps/control-api-worker/src/contracts.ts
+25, -1
 1@@ -35,6 +35,13 @@ export interface ControlApiRouteSchemaDescriptor {
 2 export interface ControlApiEnv {
 3   CONTROL_DB?: D1DatabaseLike;
 4   CONTROL_API_VERSION?: string;
 5+  CONTROL_API_AUTH_REQUIRED?: string;
 6+  CONTROL_API_BROWSER_ADMIN_TOKEN?: string;
 7+  CONTROL_API_CONTROLLER_TOKEN?: string;
 8+  CONTROL_API_OPS_ADMIN_TOKEN?: string;
 9+  CONTROL_API_READONLY_TOKEN?: string;
10+  CONTROL_API_WORKER_TOKEN?: string;
11+  BAA_SHARED_TOKEN?: string;
12 }
13 
14 export interface ControlApiExecutionContext {
15@@ -43,8 +50,9 @@ export interface ControlApiExecutionContext {
16 }
17 
18 export interface ControlApiServices {
19+  authHook: ControlApiRequestAuthHook | null;
20+  now: () => number;
21   repository: ControlPlaneRepository | null;
22-  tokenVerifier?: AuthTokenVerifier;
23 }
24 
25 export interface ControlApiOwnershipResolverInput {
26@@ -59,6 +67,20 @@ export interface ControlApiRouteAuthorization {
27   skipReason?: string;
28 }
29 
30+export interface ControlApiRequestAuthInput {
31+  body: JsonValue;
32+  params: Record<string, string>;
33+  request: Request;
34+  route: ControlApiRouteDefinition;
35+  url: URL;
36+}
37+
38+export interface ControlApiRequestAuthHook {
39+  authorize(
40+    input: ControlApiRequestAuthInput
41+  ): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure>;
42+}
43+
44 export interface ControlApiRouteContext {
45   request: Request;
46   env: ControlApiEnv;
47@@ -124,6 +146,8 @@ export interface ControlApiErrorEnvelope {
48 }
49 
50 export interface ControlApiWorkerOptions {
51+  authHook?: ControlApiRequestAuthHook;
52+  now?: () => number;
53   tokenVerifier?: AuthTokenVerifier;
54   repository?: ControlPlaneRepository;
55   repositoryFactory?: (db: D1DatabaseLike) => ControlPlaneRepository;
M apps/control-api-worker/src/handlers.ts
+595, -15
  1@@ -1,13 +1,25 @@
  2 import { findControlApiAuthRule } from "@baa-conductor/auth";
  3-import type { JsonValue } from "@baa-conductor/db";
  4+import {
  5+  DEFAULT_AUTOMATION_MODE,
  6+  stringifyJson,
  7+  type AutomationMode,
  8+  type JsonObject,
  9+  type JsonValue
 10+} from "@baa-conductor/db";
 11 import type {
 12   ControlApiHandlerFailure,
 13+  ControlApiHandlerResult,
 14   ControlApiOwnershipResolverInput,
 15+  ControlApiRouteContext,
 16   ControlApiRouteDefinition,
 17   ControlApiRouteHandler,
 18   ControlApiRouteMethod
 19 } from "./contracts.js";
 20-import { CONTROL_API_ROUTE_SCHEMAS } from "./schemas.js";
 21+import {
 22+  CONTROL_API_ROUTE_SCHEMAS
 23+} from "./schemas.js";
 24+
 25+const DEFAULT_TASK_PRIORITY = 50;
 26 
 27 function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
 28   const authRule = findControlApiAuthRule(method, pathPattern);
 29@@ -19,16 +31,16 @@ function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
 30   return authRule;
 31 }
 32 
 33-function asJsonObject(value: JsonValue): Record<string, JsonValue> | null {
 34+function asJsonObject(value: JsonValue): JsonObject | null {
 35   if (value === null || Array.isArray(value) || typeof value !== "object") {
 36     return null;
 37   }
 38 
 39-  return value as Record<string, JsonValue>;
 40+  return value as JsonObject;
 41 }
 42 
 43-function readNonEmptyStringField(body: JsonValue, fieldName: string): string | undefined {
 44-  const object = asJsonObject(body);
 45+function readNonEmptyStringField(source: JsonValue | JsonObject, fieldName: string): string | undefined {
 46+  const object = asJsonObject(source);
 47   const value = object?.[fieldName];
 48 
 49   if (typeof value !== "string") {
 50@@ -39,6 +51,170 @@ function readNonEmptyStringField(body: JsonValue, fieldName: string): string | u
 51   return normalized.length > 0 ? normalized : undefined;
 52 }
 53 
 54+function readRequiredStringField(
 55+  context: ControlApiRouteContext,
 56+  body: JsonObject,
 57+  fieldName: string
 58+): string | ControlApiHandlerFailure {
 59+  const value = readNonEmptyStringField(body, fieldName);
 60+
 61+  if (value) {
 62+    return value;
 63+  }
 64+
 65+  return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a non-empty string.`, {
 66+    field: fieldName
 67+  });
 68+}
 69+
 70+function readOptionalStringField(
 71+  context: ControlApiRouteContext,
 72+  body: JsonObject,
 73+  fieldName: string
 74+): string | undefined | ControlApiHandlerFailure {
 75+  const rawValue = body[fieldName];
 76+
 77+  if (rawValue == null) {
 78+    return undefined;
 79+  }
 80+
 81+  if (typeof rawValue !== "string") {
 82+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a string when provided.`, {
 83+      field: fieldName
 84+    });
 85+  }
 86+
 87+  const normalized = rawValue.trim();
 88+  return normalized.length > 0 ? normalized : undefined;
 89+}
 90+
 91+function readRequiredIntegerField(
 92+  context: ControlApiRouteContext,
 93+  body: JsonObject,
 94+  fieldName: string,
 95+  minimum: number = 0
 96+): number | ControlApiHandlerFailure {
 97+  const rawValue = body[fieldName];
 98+
 99+  if (typeof rawValue !== "number" || !Number.isInteger(rawValue) || rawValue < minimum) {
100+    return buildInvalidRequestFailure(
101+      context,
102+      `Field "${fieldName}" must be an integer greater than or equal to ${minimum}.`,
103+      {
104+        field: fieldName,
105+        minimum
106+      }
107+    );
108+  }
109+
110+  return rawValue;
111+}
112+
113+function readOptionalIntegerField(
114+  context: ControlApiRouteContext,
115+  body: JsonObject,
116+  fieldName: string,
117+  minimum: number = 0
118+): number | undefined | ControlApiHandlerFailure {
119+  const rawValue = body[fieldName];
120+
121+  if (rawValue == null) {
122+    return undefined;
123+  }
124+
125+  if (typeof rawValue !== "number" || !Number.isInteger(rawValue) || rawValue < minimum) {
126+    return buildInvalidRequestFailure(
127+      context,
128+      `Field "${fieldName}" must be an integer greater than or equal to ${minimum} when provided.`,
129+      {
130+        field: fieldName,
131+        minimum
132+      }
133+    );
134+  }
135+
136+  return rawValue;
137+}
138+
139+function readOptionalBooleanField(
140+  context: ControlApiRouteContext,
141+  body: JsonObject,
142+  fieldName: string
143+): boolean | undefined | ControlApiHandlerFailure {
144+  const rawValue = body[fieldName];
145+
146+  if (rawValue == null) {
147+    return undefined;
148+  }
149+
150+  if (typeof rawValue !== "boolean") {
151+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a boolean when provided.`, {
152+      field: fieldName
153+    });
154+  }
155+
156+  return rawValue;
157+}
158+
159+function readOptionalJsonObjectField(
160+  context: ControlApiRouteContext,
161+  body: JsonObject,
162+  fieldName: string
163+): JsonObject | undefined | ControlApiHandlerFailure {
164+  const rawValue = body[fieldName];
165+
166+  if (rawValue == null) {
167+    return undefined;
168+  }
169+
170+  const object = asJsonObject(rawValue);
171+
172+  if (!object) {
173+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a JSON object when provided.`, {
174+      field: fieldName
175+    });
176+  }
177+
178+  return object;
179+}
180+
181+function readOptionalStringArrayField(
182+  context: ControlApiRouteContext,
183+  body: JsonObject,
184+  fieldName: string
185+): string[] | undefined | ControlApiHandlerFailure {
186+  const rawValue = body[fieldName];
187+
188+  if (rawValue == null) {
189+    return undefined;
190+  }
191+
192+  if (!Array.isArray(rawValue)) {
193+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be an array of strings when provided.`, {
194+      field: fieldName
195+    });
196+  }
197+
198+  const values: string[] = [];
199+
200+  for (const [index, value] of rawValue.entries()) {
201+    if (typeof value !== "string" || value.trim().length === 0) {
202+      return buildInvalidRequestFailure(
203+        context,
204+        `Field "${fieldName}[${index}]" must be a non-empty string.`,
205+        {
206+          field: fieldName,
207+          index
208+        }
209+      );
210+    }
211+
212+    values.push(value.trim());
213+  }
214+
215+  return values;
216+}
217+
218 function resolveControllerOwnership(
219   input: ControlApiOwnershipResolverInput
220 ): { controllerId: string } | undefined {
221@@ -53,7 +229,56 @@ function resolveWorkerOwnership(
222   return workerId ? { workerId } : undefined;
223 }
224 
225-function buildNotImplementedFailure(context: Parameters<ControlApiRouteHandler>[0]): ControlApiHandlerFailure {
226+function buildInvalidRequestFailure(
227+  context: ControlApiRouteContext,
228+  message: string,
229+  details?: JsonValue
230+): ControlApiHandlerFailure {
231+  const normalizedDetails = asJsonObject(details ?? null);
232+
233+  return {
234+    ok: false,
235+    status: 400,
236+    error: "invalid_request",
237+    message,
238+    details: {
239+      route_id: context.route.id,
240+      ...(normalizedDetails ?? {})
241+    }
242+  };
243+}
244+
245+function buildRepositoryNotConfiguredFailure(context: ControlApiRouteContext): ControlApiHandlerFailure {
246+  return {
247+    ok: false,
248+    status: 503,
249+    error: "repository_not_configured",
250+    message: `Route ${context.route.id} requires CONTROL_DB or an injected repository.`,
251+    details: {
252+      route_id: context.route.id
253+    }
254+  };
255+}
256+
257+function buildNotFoundFailure(
258+  context: ControlApiRouteContext,
259+  resourceType: string,
260+  resourceId: string
261+): ControlApiHandlerFailure {
262+  return {
263+    ok: false,
264+    status: 404,
265+    error: `${resourceType}_not_found`,
266+    message: `${resourceType} "${resourceId}" was not found.`,
267+    details: {
268+      route_id: context.route.id,
269+      resource_id: resourceId,
270+      resource_type: resourceType
271+    }
272+  };
273+}
274+
275+function buildNotImplementedFailure(context: ControlApiRouteContext): ControlApiHandlerFailure {
276   return {
277     ok: false,
278     status: 501,
279@@ -82,6 +307,353 @@ function createPlaceholderHandler(): ControlApiRouteHandler {
280   return async (context) => buildNotImplementedFailure(context);
281 }
282 
283+function buildAppliedAck(summary: string): ControlApiHandlerResult {
284+  return {
285+    ok: true,
286+    status: 200,
287+    data: {
288+      accepted: true,
289+      status: "applied",
290+      summary
291+    } as JsonObject
292+  };
293+}
294+
295+function requireBodyObject(
296+  context: ControlApiRouteContext,
297+  allowNull = false
298+): JsonObject | ControlApiHandlerFailure {
299+  if (allowNull && context.body === null) {
300+    return {};
301+  }
302+
303+  const body = asJsonObject(context.body);
304+
305+  if (!body) {
306+    return buildInvalidRequestFailure(context, "Request body must be a JSON object.", {
307+      route_id: context.route.id
308+    });
309+  }
310+
311+  return body;
312+}
313+
314+function requireRepository(
315+  context: ControlApiRouteContext
316+): NonNullable<ControlApiRouteContext["services"]["repository"]> | ControlApiHandlerFailure {
317+  return context.services.repository ?? buildRepositoryNotConfiguredFailure(context);
318+}
319+
320+function isHandlerFailure(value: unknown): value is ControlApiHandlerFailure {
321+  return value != null && typeof value === "object" && "ok" in value && (value as { ok?: unknown }).ok === false;
322+}
323+
324+function findHandlerFailure(...values: unknown[]): ControlApiHandlerFailure | null {
325+  for (const value of values) {
326+    if (isHandlerFailure(value)) {
327+      return value;
328+    }
329+  }
330+
331+  return null;
332+}
333+
334+async function handleControllerHeartbeat(
335+  context: ControlApiRouteContext
336+): Promise<ControlApiHandlerResult> {
337+  const repository = requireRepository(context);
338+
339+  if (isHandlerFailure(repository)) {
340+    return repository;
341+  }
342+
343+  const body = requireBodyObject(context);
344+
345+  if (isHandlerFailure(body)) {
346+    return body;
347+  }
348+
349+  const controllerId = readRequiredStringField(context, body, "controller_id");
350+  const host = readRequiredStringField(context, body, "host");
351+  const role = readRequiredStringField(context, body, "role");
352+  const priority = readRequiredIntegerField(context, body, "priority");
353+  const status = readRequiredStringField(context, body, "status");
354+  const version = readOptionalStringField(context, body, "version");
355+  const metadata = readOptionalJsonObjectField(context, body, "metadata");
356+
357+  const failure = findHandlerFailure(controllerId, host, role, priority, status, version, metadata);
358+
359+  if (failure) {
360+    return failure;
361+  }
362+
363+  const controllerIdValue = controllerId as string;
364+  const hostValue = host as string;
365+  const roleValue = role as string;
366+  const priorityValue = priority as number;
367+  const statusValue = status as string;
368+  const versionValue = version as string | undefined;
369+  const metadataValue = metadata as JsonObject | undefined;
370+
371+  await repository.heartbeatController({
372+    controllerId: controllerIdValue,
373+    heartbeatAt: context.services.now(),
374+    host: hostValue,
375+    metadataJson: stringifyJson(metadataValue),
376+    priority: priorityValue,
377+    role: roleValue,
378+    status: statusValue,
379+    version: versionValue ?? null
380+  });
381+
382+  return buildAppliedAck(`Controller heartbeat recorded for ${controllerIdValue}.`);
383+}
384+
385+async function handleLeaderAcquire(
386+  context: ControlApiRouteContext
387+): Promise<ControlApiHandlerResult> {
388+  const repository = requireRepository(context);
389+
390+  if (isHandlerFailure(repository)) {
391+    return repository;
392+  }
393+
394+  const body = requireBodyObject(context);
395+
396+  if (isHandlerFailure(body)) {
397+    return body;
398+  }
399+
400+  const controllerId = readRequiredStringField(context, body, "controller_id");
401+  const host = readRequiredStringField(context, body, "host");
402+  const ttlSec = readRequiredIntegerField(context, body, "ttl_sec", 1);
403+  const preferred = readOptionalBooleanField(context, body, "preferred");
404+
405+  const failure = findHandlerFailure(controllerId, host, ttlSec, preferred);
406+
407+  if (failure) {
408+    return failure;
409+  }
410+
411+  const controllerIdValue = controllerId as string;
412+  const hostValue = host as string;
413+  const ttlSecValue = ttlSec as number;
414+  const preferredValue = preferred as boolean | undefined;
415+
416+  const result = await repository.acquireLeaderLease({
417+    controllerId: controllerIdValue,
418+    host: hostValue,
419+    preferred: preferredValue ?? false,
420+    ttlSec: ttlSecValue
421+  });
422+
423+  return {
424+    ok: true,
425+    status: 200,
426+    data: {
427+      holder_id: result.holderId,
428+      is_leader: result.isLeader,
429+      lease_expires_at: result.leaseExpiresAt,
430+      term: result.term
431+    }
432+  };
433+}
434+
435+async function handleTaskCreate(
436+  context: ControlApiRouteContext
437+): Promise<ControlApiHandlerResult> {
438+  const repository = requireRepository(context);
439+
440+  if (isHandlerFailure(repository)) {
441+    return repository;
442+  }
443+
444+  const body = requireBodyObject(context);
445+
446+  if (isHandlerFailure(body)) {
447+    return body;
448+  }
449+
450+  const repo = readRequiredStringField(context, body, "repo");
451+  const taskType = readRequiredStringField(context, body, "task_type");
452+  const title = readRequiredStringField(context, body, "title");
453+  const goal = readRequiredStringField(context, body, "goal");
454+  const priority = readOptionalIntegerField(context, body, "priority");
455+  const constraints = readOptionalJsonObjectField(context, body, "constraints");
456+  const acceptance = readOptionalStringArrayField(context, body, "acceptance");
457+  const metadata = readOptionalJsonObjectField(context, body, "metadata");
458+
459+  const failure = findHandlerFailure(
460+    repo,
461+    taskType,
462+    title,
463+    goal,
464+    priority,
465+    constraints,
466+    acceptance,
467+    metadata
468+  );
469+
470+  if (failure) {
471+    return failure;
472+  }
473+
474+  const repoValue = repo as string;
475+  const taskTypeValue = taskType as string;
476+  const titleValue = title as string;
477+  const goalValue = goal as string;
478+  const priorityValue = priority as number | undefined;
479+  const constraintsValue = constraints as JsonObject | undefined;
480+  const acceptanceValue = acceptance as string[] | undefined;
481+  const metadataValue = metadata as JsonObject | undefined;
482+
483+  const now = context.services.now();
484+  const taskId = `task_${crypto.randomUUID()}`;
485+  const source = readNonEmptyStringField(metadataValue ?? {}, "requested_by") ?? "control_api";
486+  const targetHost = readNonEmptyStringField(constraintsValue ?? {}, "target_host") ?? null;
487+
488+  await repository.insertTask({
489+    acceptanceJson: stringifyJson(acceptanceValue),
490+    assignedControllerId: null,
491+    baseRef: null,
492+    branchName: null,
493+    constraintsJson: stringifyJson(constraintsValue),
494+    createdAt: now,
495+    currentStepIndex: 0,
496+    errorText: null,
497+    finishedAt: null,
498+    goal: goalValue,
499+    metadataJson: stringifyJson(metadataValue),
500+    plannerProvider: null,
501+    planningStrategy: null,
502+    priority: priorityValue ?? DEFAULT_TASK_PRIORITY,
503+    repo: repoValue,
504+    resultJson: null,
505+    resultSummary: null,
506+    source,
507+    startedAt: null,
508+    status: "queued",
509+    targetHost,
510+    taskId,
511+    taskType: taskTypeValue,
512+    title: titleValue,
513+    updatedAt: now
514+  });
515+
516+  return {
517+    ok: true,
518+    status: 201,
519+    data: {
520+      base_ref: null,
521+      branch_name: null,
522+      status: "queued",
523+      task_id: taskId
524+    }
525+  };
526+}
527+
528+function createSystemMutationHandler(mode: AutomationMode): ControlApiRouteHandler {
529+  return async (context) => {
530+    const repository = requireRepository(context);
531+
532+    if (isHandlerFailure(repository)) {
533+      return repository;
534+    }
535+
536+    const body = requireBodyObject(context, true);
537+
538+    if (isHandlerFailure(body)) {
539+      return body;
540+    }
541+
542+    const reason = readOptionalStringField(context, body, "reason");
543+    const requestedBy = readOptionalStringField(context, body, "requested_by");
544+
545+    const failure = findHandlerFailure(reason, requestedBy);
546+
547+    if (failure) {
548+      return failure;
549+    }
550+
551+    const reasonValue = reason as string | undefined;
552+    const requestedByValue = requestedBy as string | undefined;
553+
554+    await repository.setAutomationMode(mode, context.services.now());
555+
556+    const summarySuffix = [
557+      requestedByValue ? `requested by ${requestedByValue}` : null,
558+      reasonValue ? `reason: ${reasonValue}` : null
559+    ].filter((value) => value !== null);
560+
561+    return buildAppliedAck(
562+      summarySuffix.length > 0
563+        ? `Automation mode set to ${mode}; ${summarySuffix.join("; ")}.`
564+        : `Automation mode set to ${mode}.`
565+    );
566+  };
567+}
568+
569+async function handleSystemStateRead(
570+  context: ControlApiRouteContext
571+): Promise<ControlApiHandlerResult> {
572+  const repository = requireRepository(context);
573+
574+  if (isHandlerFailure(repository)) {
575+    return repository;
576+  }
577+
578+  const [automationState, lease] = await Promise.all([
579+    repository.getAutomationState(),
580+    repository.getCurrentLease()
581+  ]);
582+
583+  return {
584+    ok: true,
585+    status: 200,
586+    data: {
587+      holder_id: lease?.holderId ?? null,
588+      lease_expires_at: lease?.leaseExpiresAt ?? null,
589+      mode: automationState?.mode ?? DEFAULT_AUTOMATION_MODE,
590+      term: lease?.term ?? null
591+    }
592+  };
593+}
594+
595+async function handleTaskRead(
596+  context: ControlApiRouteContext
597+): Promise<ControlApiHandlerResult> {
598+  const repository = requireRepository(context);
599+
600+  if (isHandlerFailure(repository)) {
601+    return repository;
602+  }
603+
604+  const taskId = context.params.task_id;
605+
606+  if (!taskId) {
607+    return buildInvalidRequestFailure(context, "Route parameter \"task_id\" is required.", {
608+      field: "task_id"
609+    });
610+  }
611+
612+  const task = await repository.getTask(taskId);
613+
614+  if (!task) {
615+    return buildNotFoundFailure(context, "task", taskId);
616+  }
617+
618+  return {
619+    ok: true,
620+    status: 200,
621+    data: {
622+      current_step_index: task.currentStepIndex,
623+      status: task.status,
624+      task_id: task.taskId,
625+      title: task.title
626+    }
627+  };
628+}
629+
630 function defineRoute(
631   definition: Omit<ControlApiRouteDefinition, "authRule" | "handler"> & {
632     handler?: ControlApiRouteHandler;
633@@ -101,7 +673,8 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
634     pathPattern: "/v1/controllers/heartbeat",
635     summary: "controller 心跳",
636     schema: CONTROL_API_ROUTE_SCHEMAS["controllers.heartbeat"],
637-    ownershipResolver: resolveControllerOwnership
638+    ownershipResolver: resolveControllerOwnership,
639+    handler: handleControllerHeartbeat
640   }),
641   defineRoute({
642     id: "leader.acquire",
643@@ -109,14 +682,16 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
644     pathPattern: "/v1/leader/acquire",
645     summary: "获取或续租 leader lease",
646     schema: CONTROL_API_ROUTE_SCHEMAS["leader.acquire"],
647-    ownershipResolver: resolveControllerOwnership
648+    ownershipResolver: resolveControllerOwnership,
649+    handler: handleLeaderAcquire
650   }),
651   defineRoute({
652     id: "tasks.create",
653     method: "POST",
654     pathPattern: "/v1/tasks",
655     summary: "创建 task",
656-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.create"]
657+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.create"],
658+    handler: handleTaskCreate
659   }),
660   defineRoute({
661     id: "tasks.plan",
662@@ -171,35 +746,40 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
663     method: "POST",
664     pathPattern: "/v1/system/pause",
665     summary: "暂停自动化",
666-    schema: CONTROL_API_ROUTE_SCHEMAS["system.pause"]
667+    schema: CONTROL_API_ROUTE_SCHEMAS["system.pause"],
668+    handler: createSystemMutationHandler("paused")
669   }),
670   defineRoute({
671     id: "system.resume",
672     method: "POST",
673     pathPattern: "/v1/system/resume",
674     summary: "恢复自动化",
675-    schema: CONTROL_API_ROUTE_SCHEMAS["system.resume"]
676+    schema: CONTROL_API_ROUTE_SCHEMAS["system.resume"],
677+    handler: createSystemMutationHandler("running")
678   }),
679   defineRoute({
680     id: "system.drain",
681     method: "POST",
682     pathPattern: "/v1/system/drain",
683     summary: "drain 自动化",
684-    schema: CONTROL_API_ROUTE_SCHEMAS["system.drain"]
685+    schema: CONTROL_API_ROUTE_SCHEMAS["system.drain"],
686+    handler: createSystemMutationHandler("draining")
687   }),
688   defineRoute({
689     id: "system.state",
690     method: "GET",
691     pathPattern: "/v1/system/state",
692     summary: "读取系统状态",
693-    schema: CONTROL_API_ROUTE_SCHEMAS["system.state"]
694+    schema: CONTROL_API_ROUTE_SCHEMAS["system.state"],
695+    handler: handleSystemStateRead
696   }),
697   defineRoute({
698     id: "tasks.read",
699     method: "GET",
700     pathPattern: "/v1/tasks/:task_id",
701     summary: "读取 task 详情",
702-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.read"]
703+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.read"],
704+    handler: handleTaskRead
705   }),
706   defineRoute({
707     id: "tasks.logs.read",
M apps/control-api-worker/src/index.ts
+1, -0
1@@ -1,6 +1,7 @@
2 export * from "./contracts.js";
3 export * from "./handlers.js";
4 export * from "./router.js";
5+export * from "./runtime.js";
6 export * from "./schemas.js";
7 
8 import { createControlApiWorker } from "./router.js";
M apps/control-api-worker/src/router.ts
+10, -72
  1@@ -1,5 +1,4 @@
  2-import { authorizeControlApiRoute, extractBearerToken } from "@baa-conductor/auth";
  3-import { createD1ControlPlaneRepository, type JsonValue } from "@baa-conductor/db";
  4+import type { JsonValue } from "@baa-conductor/db";
  5 import type {
  6   ControlApiErrorEnvelope,
  7   ControlApiExecutionContext,
  8@@ -15,6 +14,7 @@ import type {
  9   ControlApiEnv
 10 } from "./contracts.js";
 11 import { CONTROL_API_ROUTES } from "./handlers.js";
 12+import { createControlApiServices } from "./runtime.js";
 13 
 14 const SUPPORTED_METHODS: ControlApiRouteMethod[] = ["GET", "POST"];
 15 
 16@@ -84,7 +84,7 @@ export async function handleControlApiRequest(
 17     return errorResponse(requestId, bodyResult);
 18   }
 19 
 20-  const services = resolveServices(env, options);
 21+  const services = createControlApiServices(env, options);
 22   const authorization = await resolveAuthorization(
 23     request,
 24     url,
 25@@ -113,29 +113,6 @@ export async function handleControlApiRequest(
 26   return routeResultToResponse(requestId, result);
 27 }
 28 
 29-function resolveServices(env: ControlApiEnv, options: ControlApiWorkerOptions): ControlApiServices {
 30-  if (options.repository) {
 31-    return {
 32-      repository: options.repository,
 33-      tokenVerifier: options.tokenVerifier
 34-    };
 35-  }
 36-
 37-  if (!env.CONTROL_DB) {
 38-    return {
 39-      repository: null,
 40-      tokenVerifier: options.tokenVerifier
 41-    };
 42-  }
 43-
 44-  const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
 45-
 46-  return {
 47-    repository: repositoryFactory(env.CONTROL_DB),
 48-    tokenVerifier: options.tokenVerifier
 49-  };
 50-}
 51-
 52 function resolveRequestId(request: Request, options: ControlApiWorkerOptions): string {
 53   const headerValue = request.headers.get("x-request-id")?.trim();
 54 
 55@@ -198,7 +175,7 @@ async function resolveAuthorization(
 56 ): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
 57   const rule = matchedRoute.route.authRule;
 58 
 59-  if (!services.tokenVerifier) {
 60+  if (!services.authHook) {
 61     return {
 62       mode: "skipped",
 63       rule,
 64@@ -206,52 +183,13 @@ async function resolveAuthorization(
 65     };
 66   }
 67 
 68-  const tokenResult = extractBearerToken(request.headers.get("authorization") ?? undefined);
 69-
 70-  if (!tokenResult.ok) {
 71-    return {
 72-      ok: false,
 73-      status: 401,
 74-      error: tokenResult.reason,
 75-      message: "Authorization header must use Bearer token syntax for Control API requests."
 76-    };
 77-  }
 78-
 79-  const verification = await services.tokenVerifier.verifyBearerToken(tokenResult.token);
 80-
 81-  if (!verification.ok) {
 82-    return {
 83-      ok: false,
 84-      status: verification.statusCode,
 85-      error: verification.reason,
 86-      message: `Bearer token verification failed: ${verification.reason}.`
 87-    };
 88-  }
 89-
 90-  const authorization = authorizeControlApiRoute({
 91-    method: matchedRoute.route.method,
 92-    path: url.pathname,
 93-    principal: verification.principal,
 94-    resource: matchedRoute.route.ownershipResolver?.({
 95-      params: matchedRoute.params,
 96-      body
 97-    })
 98+  return services.authHook.authorize({
 99+    body,
100+    params: matchedRoute.params,
101+    request,
102+    route: matchedRoute.route,
103+    url
104   });
105-
106-  if (!authorization.ok) {
107-    return {
108-      ok: false,
109-      status: authorization.statusCode,
110-      error: authorization.reason,
111-      message: `Authenticated principal is not allowed to access ${matchedRoute.route.method} ${matchedRoute.route.pathPattern}.`
112-    };
113-  }
114-
115-  return {
116-    mode: "verified",
117-    rule: authorization.matchedRule ?? rule,
118-    principal: verification.principal
119-  };
120 }
121 
122 function isHandlerFailure(
A apps/control-api-worker/src/runtime.ts
+277, -0
  1@@ -0,0 +1,277 @@
  2+import {
  3+  authorizeControlApiRoute,
  4+  extractBearerToken,
  5+  type AuthPrincipal,
  6+  type AuthResourceOwnership,
  7+  type AuthTokenVerifier,
  8+  type AuthVerificationResult,
  9+  DEFAULT_AUTH_AUDIENCE
 10+} from "@baa-conductor/auth";
 11+import { createD1ControlPlaneRepository } from "@baa-conductor/db";
 12+import type {
 13+  ControlApiEnv,
 14+  ControlApiHandlerFailure,
 15+  ControlApiRequestAuthHook,
 16+  ControlApiRouteAuthorization,
 17+  ControlApiServices,
 18+  ControlApiWorkerOptions
 19+} from "./contracts.js";
 20+
 21+const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
 22+
 23+export function createControlApiServices(
 24+  env: ControlApiEnv,
 25+  options: ControlApiWorkerOptions
 26+): ControlApiServices {
 27+  return {
 28+    authHook: options.authHook ?? createControlApiAuthHook(env, options.tokenVerifier),
 29+    now: options.now ?? (() => Math.floor(Date.now() / 1000)),
 30+    repository: resolveRepository(env, options)
 31+  };
 32+}
 33+
 34+function resolveRepository(
 35+  env: ControlApiEnv,
 36+  options: ControlApiWorkerOptions
 37+): ControlApiServices["repository"] {
 38+  if (options.repository) {
 39+    return options.repository;
 40+  }
 41+
 42+  if (!env.CONTROL_DB) {
 43+    return null;
 44+  }
 45+
 46+  const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
 47+  return repositoryFactory(env.CONTROL_DB);
 48+}
 49+
 50+export function createControlApiAuthHook(
 51+  env: ControlApiEnv,
 52+  tokenVerifier?: AuthTokenVerifier
 53+): ControlApiRequestAuthHook | null {
 54+  const hasEnvTokens = hasConfiguredEnvTokens(env);
 55+  const authRequired = parseBooleanEnv(env.CONTROL_API_AUTH_REQUIRED) ?? false;
 56+
 57+  if (!tokenVerifier && !hasEnvTokens && !authRequired) {
 58+    return null;
 59+  }
 60+
 61+  return {
 62+    async authorize(input): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
 63+      if (!tokenVerifier && !hasEnvTokens) {
 64+        return {
 65+          ok: false,
 66+          status: 503,
 67+          error: "auth_not_configured",
 68+          message: "Control API auth is required, but no token verifier or runtime tokens were configured."
 69+        };
 70+      }
 71+
 72+      const tokenResult = extractBearerToken(input.request.headers.get("authorization") ?? undefined);
 73+
 74+      if (!tokenResult.ok) {
 75+        return {
 76+          ok: false,
 77+          status: 401,
 78+          error: tokenResult.reason,
 79+          message: "Authorization header must use Bearer token syntax for Control API requests."
 80+        };
 81+      }
 82+
 83+      const resource = input.route.ownershipResolver?.({
 84+        params: input.params,
 85+        body: input.body
 86+      });
 87+
 88+      const verification = await verifyBearerToken(tokenResult.token, input.route.authRule.action, resource, env, tokenVerifier);
 89+
 90+      if (!verification.ok) {
 91+        return {
 92+          ok: false,
 93+          status: verification.statusCode,
 94+          error: verification.reason,
 95+          message: `Bearer token verification failed: ${verification.reason}.`
 96+        };
 97+      }
 98+
 99+      const authorization = authorizeControlApiRoute({
100+        method: input.route.method,
101+        path: input.url.pathname,
102+        principal: verification.principal,
103+        resource
104+      });
105+
106+      if (!authorization.ok) {
107+        return {
108+          ok: false,
109+          status: authorization.statusCode,
110+          error: authorization.reason,
111+          message: `Authenticated principal is not allowed to access ${input.route.method} ${input.route.pathPattern}.`
112+        };
113+      }
114+
115+      return {
116+        mode: "verified",
117+        rule: authorization.matchedRule ?? input.route.authRule,
118+        principal: verification.principal
119+      };
120+    }
121+  };
122+}
123+
124+async function verifyBearerToken(
125+  token: string,
126+  action: string,
127+  resource: AuthResourceOwnership | undefined,
128+  env: ControlApiEnv,
129+  tokenVerifier?: AuthTokenVerifier
130+): Promise<AuthVerificationResult> {
131+  if (tokenVerifier) {
132+    const result = await tokenVerifier.verifyBearerToken(token);
133+
134+    if (result.ok || result.reason !== "unknown_token") {
135+      return result;
136+    }
137+  }
138+
139+  return verifyEnvToken(token, action, resource, env);
140+}
141+
142+function verifyEnvToken(
143+  token: string,
144+  action: string,
145+  resource: AuthResourceOwnership | undefined,
146+  env: ControlApiEnv
147+): AuthVerificationResult {
148+  if (token === env.CONTROL_API_BROWSER_ADMIN_TOKEN) {
149+    return {
150+      ok: true,
151+      principal: buildStaticPrincipal("browser_admin")
152+    };
153+  }
154+
155+  if (token === env.CONTROL_API_READONLY_TOKEN) {
156+    return {
157+      ok: true,
158+      principal: buildStaticPrincipal("readonly")
159+    };
160+  }
161+
162+  if (token === env.CONTROL_API_OPS_ADMIN_TOKEN) {
163+    return {
164+      ok: true,
165+      principal: buildStaticPrincipal("ops_admin")
166+    };
167+  }
168+
169+  if (token === env.CONTROL_API_CONTROLLER_TOKEN) {
170+    return {
171+      ok: true,
172+      principal: buildServicePrincipal("controller", resource)
173+    };
174+  }
175+
176+  if (token === env.CONTROL_API_WORKER_TOKEN) {
177+    return {
178+      ok: true,
179+      principal: buildServicePrincipal("worker", resource)
180+    };
181+  }
182+
183+  if (token === env.BAA_SHARED_TOKEN) {
184+    const role = resolveServiceRole(action);
185+
186+    if (role) {
187+      return {
188+        ok: true,
189+        principal: buildServicePrincipal(role, resource)
190+      };
191+    }
192+  }
193+
194+  return {
195+    ok: false,
196+    reason: "unknown_token",
197+    statusCode: 401
198+  };
199+}
200+
201+function hasConfiguredEnvTokens(env: ControlApiEnv): boolean {
202+  return [
203+    env.BAA_SHARED_TOKEN,
204+    env.CONTROL_API_BROWSER_ADMIN_TOKEN,
205+    env.CONTROL_API_CONTROLLER_TOKEN,
206+    env.CONTROL_API_OPS_ADMIN_TOKEN,
207+    env.CONTROL_API_READONLY_TOKEN,
208+    env.CONTROL_API_WORKER_TOKEN
209+  ].some((value) => typeof value === "string" && value.trim().length > 0);
210+}
211+
212+function parseBooleanEnv(value: string | undefined): boolean | undefined {
213+  if (value == null) {
214+    return undefined;
215+  }
216+
217+  return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
218+}
219+
220+function resolveServiceRole(action: string): "controller" | "worker" | null {
221+  if (
222+    action === "controllers.heartbeat" ||
223+    action === "leader.acquire" ||
224+    action === "tasks.claim" ||
225+    action === "tasks.plan"
226+  ) {
227+    return "controller";
228+  }
229+
230+  if (
231+    action === "steps.heartbeat" ||
232+    action === "steps.checkpoint" ||
233+    action === "steps.complete" ||
234+    action === "steps.fail"
235+  ) {
236+    return "worker";
237+  }
238+
239+  return null;
240+}
241+
242+function buildStaticPrincipal(role: "browser_admin" | "ops_admin" | "readonly"): AuthPrincipal {
243+  return {
244+    audience: DEFAULT_AUTH_AUDIENCE,
245+    role,
246+    sessionId: role,
247+    subject: role,
248+    tokenKind: role === "ops_admin" ? "ops_session" : "browser_session"
249+  };
250+}
251+
252+function buildServicePrincipal(
253+  role: "controller" | "worker",
254+  resource: AuthResourceOwnership | undefined
255+): AuthPrincipal {
256+  if (role === "controller") {
257+    const controllerId = resource?.controllerId;
258+
259+    return {
260+      audience: DEFAULT_AUTH_AUDIENCE,
261+      controllerId,
262+      nodeId: controllerId,
263+      role,
264+      subject: controllerId ?? "controller",
265+      tokenKind: "service_hmac"
266+    };
267+  }
268+
269+  const workerId = resource?.workerId;
270+
271+  return {
272+    audience: DEFAULT_AUTH_AUDIENCE,
273+    role,
274+    subject: workerId ?? "worker",
275+    tokenKind: "service_hmac",
276+    workerId
277+  };
278+}
M apps/control-api-worker/src/schemas.ts
+1, -1
1@@ -119,7 +119,7 @@ export interface SystemMutationRequest {
2 
3 export interface ControlApiAckResponse {
4   accepted: boolean;
5-  status: "placeholder" | "queued";
6+  status: "placeholder" | "queued" | "applied";
7   summary: string;
8 }
9 
M apps/status-api/package.json
+6, -1
 1@@ -2,8 +2,13 @@
 2   "name": "@baa-conductor/status-api",
 3   "private": true,
 4   "type": "module",
 5+  "main": "dist/index.js",
 6+  "exports": {
 7+    ".": "./dist/index.js",
 8+    "./runtime": "./dist/apps/status-api/src/runtime.js"
 9+  },
10   "scripts": {
11-    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
12+    "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/status-api/dist BAA_DIST_ENTRY=apps/status-api/src/index.js BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
13     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
14   }
15 }
M apps/status-api/src/contracts.ts
+1, -0
1@@ -26,6 +26,7 @@ export interface StatusApiRoute {
2   path: string;
3   summary: string;
4   contentType: "application/json" | "text/html" | "text/plain";
5+  aliases?: string[];
6 }
7 
8 export interface StatusApiRequest {
M apps/status-api/src/index.ts
+1, -0
1@@ -1,4 +1,5 @@
2 export * from "./contracts.js";
3 export * from "./data-source.js";
4 export * from "./render.js";
5+export * from "./runtime.js";
6 export * from "./service.js";
A apps/status-api/src/runtime.ts
+51, -0
 1@@ -0,0 +1,51 @@
 2+import { StaticStatusSnapshotLoader } from "./data-source.js";
 3+import type { StatusApiHandler, StatusApiRequest, StatusApiResponse, StatusApiRoute, StatusSnapshotLoader } from "./contracts.js";
 4+import { createStatusApiHandler } from "./service.js";
 5+
 6+export interface StatusApiRuntime extends StatusApiHandler {
 7+  fetch(request: Request): Promise<Response>;
 8+}
 9+
10+export interface StatusApiRuntimeOptions {
11+  snapshotLoader?: StatusSnapshotLoader;
12+}
13+
14+export function createStatusApiRuntime(options: StatusApiRuntimeOptions = {}): StatusApiRuntime {
15+  const handler = createStatusApiHandler(options.snapshotLoader ?? new StaticStatusSnapshotLoader());
16+
17+  return {
18+    routes: handler.routes,
19+    handle: handler.handle,
20+    fetch: async (request) => toFetchResponse(await handler.handle(toStatusApiRequest(request)))
21+  };
22+}
23+
24+export function createStatusApiFetchHandler(snapshotLoader: StatusSnapshotLoader): (request: Request) => Promise<Response> {
25+  const handler = createStatusApiHandler(snapshotLoader);
26+
27+  return async (request) => toFetchResponse(await handler.handle(toStatusApiRequest(request)));
28+}
29+
30+export function toStatusApiRequest(request: Pick<Request, "method" | "url">): StatusApiRequest {
31+  return {
32+    method: request.method,
33+    path: request.url
34+  };
35+}
36+
37+export function toFetchResponse(response: StatusApiResponse): Response {
38+  return new Response(response.body, {
39+    status: response.status,
40+    headers: new Headers(response.headers)
41+  });
42+}
43+
44+export function describeStatusApiRuntimeSurface(runtime: Pick<StatusApiRuntime, "routes">): string[] {
45+  return runtime.routes.map(describeStatusApiRoute);
46+}
47+
48+function describeStatusApiRoute(route: StatusApiRoute): string {
49+  const paths = [route.path, ...(route.aliases ?? [])].join(", ");
50+
51+  return `${route.method} ${paths} (${route.contentType})`;
52+}
M apps/status-api/src/service.ts
+89, -32
  1@@ -22,15 +22,53 @@ const TEXT_HEADERS = {
  2   "cache-control": "no-store"
  3 } as const;
  4 
  5-export const STATUS_API_ROUTES: StatusApiRoute[] = [
  6-  { method: "GET", path: "/healthz", summary: "状态服务健康检查", contentType: "text/plain" },
  7-  { method: "GET", path: "/v1/status", summary: "读取全局自动化状态快照", contentType: "application/json" },
  8-  { method: "GET", path: "/v1/status/ui", summary: "读取最小 HTML 状态面板", contentType: "text/html" },
  9-  { method: "GET", path: "/", summary: "最小状态面板首页", contentType: "text/html" }
 10+type StatusApiRouteId = "healthz" | "status" | "ui";
 11+
 12+type StatusApiRouteDefinition = StatusApiRoute & {
 13+  id: StatusApiRouteId;
 14+};
 15+
 16+const STATUS_API_ROUTE_DEFINITIONS: ReadonlyArray<StatusApiRouteDefinition> = [
 17+  {
 18+    id: "healthz",
 19+    method: "GET",
 20+    path: "/healthz",
 21+    summary: "状态服务健康检查",
 22+    contentType: "text/plain"
 23+  },
 24+  {
 25+    id: "status",
 26+    method: "GET",
 27+    path: "/v1/status",
 28+    summary: "读取全局自动化状态快照",
 29+    contentType: "application/json"
 30+  },
 31+  {
 32+    id: "ui",
 33+    method: "GET",
 34+    path: "/v1/status/ui",
 35+    aliases: ["/", "/ui"],
 36+    summary: "读取最小 HTML 状态面板",
 37+    contentType: "text/html"
 38+  }
 39 ];
 40 
 41+const STATUS_API_ROUTE_LOOKUP = createStatusApiRouteLookup();
 42+
 43+export const STATUS_API_ROUTES: StatusApiRoute[] = STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
 44+  method: route.method,
 45+  path: route.path,
 46+  summary: route.summary,
 47+  contentType: route.contentType,
 48+  ...(route.aliases == null ? {} : { aliases: [...route.aliases] })
 49+}));
 50+
 51 export function describeStatusApiSurface(): string[] {
 52-  return STATUS_API_ROUTES.map((route) => `${route.method} ${route.path} - ${route.summary}`);
 53+  return STATUS_API_ROUTE_DEFINITIONS.map((route) => {
 54+    const paths = [route.path, ...(route.aliases ?? [])].join(", ");
 55+
 56+    return `${route.method} ${paths} - ${route.summary}`;
 57+  });
 58 }
 59 
 60 export function createStatusApiHandler(snapshotLoader: StatusSnapshotLoader): StatusApiHandler {
 61@@ -59,36 +97,37 @@ export async function handleStatusApiRequest(
 62     );
 63   }
 64 
 65-  if (path === "/healthz") {
 66-    return {
 67-      status: 200,
 68-      headers: { ...TEXT_HEADERS },
 69-      body: "ok"
 70-    };
 71-  }
 72-
 73-  const snapshot = await snapshotLoader.loadSnapshot();
 74+  const route = resolveStatusApiRoute(path);
 75 
 76-  if (path === "/" || path === "/ui" || path === "/v1/status/ui") {
 77-    return {
 78-      status: 200,
 79-      headers: { ...HTML_HEADERS },
 80-      body: renderStatusPage(snapshot)
 81-    };
 82-  }
 83-
 84-  if (path === "/v1/status") {
 85-    return jsonResponse(200, {
 86-      ok: true,
 87-      data: snapshot
 88+  if (route == null) {
 89+    return jsonResponse(404, {
 90+      ok: false,
 91+      error: "not_found",
 92+      message: `No status route matches "${path}".`
 93     });
 94   }
 95 
 96-  return jsonResponse(404, {
 97-    ok: false,
 98-    error: "not_found",
 99-    message: `No status route matches "${path}".`
100-  });
101+  switch (route.id) {
102+    case "healthz":
103+      return {
104+        status: 200,
105+        headers: { ...TEXT_HEADERS },
106+        body: "ok"
107+      };
108+
109+    case "status":
110+      return jsonResponse(200, {
111+        ok: true,
112+        data: await snapshotLoader.loadSnapshot()
113+      });
114+
115+    case "ui":
116+      return {
117+        status: 200,
118+        headers: { ...HTML_HEADERS },
119+        body: renderStatusPage(await snapshotLoader.loadSnapshot())
120+      };
121+  }
122 }
123 
124 function jsonResponse(
125@@ -113,3 +152,21 @@ function normalizePath(value: string): string {
126 
127   return normalized === "" ? "/" : normalized;
128 }
129+
130+function createStatusApiRouteLookup(): Map<string, StatusApiRouteDefinition> {
131+  const lookup = new Map<string, StatusApiRouteDefinition>();
132+
133+  for (const route of STATUS_API_ROUTE_DEFINITIONS) {
134+    lookup.set(route.path, route);
135+
136+    for (const alias of route.aliases ?? []) {
137+      lookup.set(alias, route);
138+    }
139+  }
140+
141+  return lookup;
142+}
143+
144+function resolveStatusApiRoute(path: string): StatusApiRouteDefinition | null {
145+  return STATUS_API_ROUTE_LOOKUP.get(path) ?? null;
146+}
M apps/status-api/tsconfig.json
+1, -0
1@@ -1,6 +1,7 @@
2 {
3   "extends": "../../tsconfig.base.json",
4   "compilerOptions": {
5+    "lib": ["ES2022", "DOM"],
6     "rootDir": "../..",
7     "outDir": "dist"
8   },
M apps/worker-runner/package.json
+2, -1
 1@@ -2,8 +2,9 @@
 2   "name": "@baa-conductor/worker-runner",
 3   "private": true,
 4   "type": "module",
 5+  "main": "dist/index.js",
 6   "scripts": {
 7-    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
 8+    "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/worker-runner/dist BAA_DIST_ENTRY=apps/worker-runner/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/logging=../../../packages/logging/src/index.js;@baa-conductor/checkpointing=../../../packages/checkpointing/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
 9     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
10   }
11 }
M apps/worker-runner/src/runner.ts
+114, -50
  1@@ -1,16 +1,26 @@
  2 import {
  3+  persistCheckpointRecord,
  4+  type CheckpointRecord
  5+} from "@baa-conductor/checkpointing";
  6+import {
  7+  appendLifecycleEntry,
  8   appendStreamChunk,
  9+  appendStreamEntry,
 10   createLocalRunLogSession,
 11   createLocalRunPaths,
 12   createRunMetadata,
 13   createRunStateSnapshot,
 14+  initializeLocalRunFiles,
 15   recordLifecycleEvent,
 16   summarizeLocalRunLogSession,
 17   updateRunState,
 18+  writeRunState,
 19   type LogLevel,
 20-  type StructuredData,
 21   type RunStatePatch,
 22   type RunStatus,
 23+  type StreamLogChannel,
 24+  type StructuredData,
 25+  type WorkerLifecycleEventInput,
 26   type WorkerLifecycleEventType
 27 } from "@baa-conductor/logging";
 28 import type {
 29@@ -38,6 +48,61 @@ function synchronizeRunState(run: PreparedStepRun, patch: RunStatePatch): void {
 30   });
 31 }
 32 
 33+async function persistRunState(run: PreparedStepRun): Promise<void> {
 34+  await writeRunState(run.logPaths, run.state);
 35+}
 36+
 37+async function synchronizeRunStatePersisted(
 38+  run: PreparedStepRun,
 39+  patch: RunStatePatch
 40+): Promise<void> {
 41+  synchronizeRunState(run, patch);
 42+  await persistRunState(run);
 43+}
 44+
 45+async function persistCheckpointRecordIfPresent(
 46+  run: PreparedStepRun,
 47+  record?: CheckpointRecord
 48+): Promise<void> {
 49+  if (record === undefined) {
 50+    return;
 51+  }
 52+
 53+  await persistCheckpointRecord(record);
 54+  await synchronizeRunStatePersisted(run, {
 55+    updatedAt: record.createdAt
 56+  });
 57+}
 58+
 59+async function recordLifecycleEventPersisted(
 60+  run: PreparedStepRun,
 61+  input: WorkerLifecycleEventInput,
 62+  statePatch: RunStatePatch = {}
 63+): Promise<void> {
 64+  const entry = recordLifecycleEvent(run.logSession, input);
 65+
 66+  await appendLifecycleEntry(run.logSession, entry);
 67+  await synchronizeRunStatePersisted(run, {
 68+    ...statePatch,
 69+    updatedAt: statePatch.updatedAt ?? entry.createdAt
 70+  });
 71+}
 72+
 73+async function appendStreamChunkPersisted(
 74+  run: PreparedStepRun,
 75+  channel: StreamLogChannel,
 76+  text: string
 77+): Promise<void> {
 78+  const entry = appendStreamChunk(run.logSession, channel, {
 79+    text
 80+  });
 81+
 82+  await appendStreamEntry(run.logSession, entry);
 83+  await synchronizeRunStatePersisted(run, {
 84+    updatedAt: entry.createdAt
 85+  });
 86+}
 87+
 88 function createDefaultArtifacts(run: PreparedStepRun): StepArtifact[] {
 89   return [
 90     {
 91@@ -145,7 +210,7 @@ function resolveExitCode(execution: WorkerExecutionOutcome): number {
 92   return execution.ok ? 0 : 1;
 93 }
 94 
 95-export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
 96+export async function prepareStepRun(request: StepExecutionRequest): Promise<PreparedStepRun> {
 97   const startedAt = request.createdAt ?? new Date().toISOString();
 98   const checkpointConfig = resolveCheckpointConfig(request.checkpoint);
 99   const logPaths = createLocalRunPaths({
100@@ -190,7 +255,10 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
101     checkpointManager
102   };
103 
104-  recordLifecycleEvent(run.logSession, {
105+  await initializeLocalRunFiles(run.logPaths, run.metadata, run.state);
106+  await persistCheckpointRecordIfPresent(run, preparationCheckpoint);
107+
108+  await recordLifecycleEventPersisted(run, {
109     type: "run_prepared",
110     level: "info",
111     createdAt: startedAt,
112@@ -249,7 +317,7 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
113       };
114     }
115 
116-    recordLifecycleEvent(run.logSession, {
117+    await recordLifecycleEventPersisted(run, {
118       type: "checkpoint_slot_reserved",
119       level: "info",
120       message: `Initialized checkpoint manager at sequence ${checkpoint.lastCheckpointSeq}.`,
121@@ -257,10 +325,6 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
122     });
123   }
124 
125-  synchronizeRunState(run, {
126-    updatedAt: startedAt
127-  });
128-
129   return run;
130 }
131 
132@@ -284,27 +348,29 @@ export async function runStep(
133   executor: WorkerExecutor = createPlaceholderWorkerExecutor()
134 ): Promise<StepExecutionResult> {
135   const startedAt = request.createdAt ?? new Date().toISOString();
136-  const run = prepareStepRun({
137+  const run = await prepareStepRun({
138     ...request,
139     createdAt: startedAt
140   });
141   const executionStartedAt = new Date().toISOString();
142 
143-  recordLifecycleEvent(run.logSession, {
144-    type: "worker_started",
145-    level: "info",
146-    createdAt: executionStartedAt,
147-    message: `Worker runner opened execution scope for ${request.workerKind} step ${request.stepId}.`,
148-    data: {
149-      stepKind: request.stepKind,
150-      timeoutSec: request.timeoutSec,
151-      worktreePath: request.runtime.worktreePath
152+  await recordLifecycleEventPersisted(
153+    run,
154+    {
155+      type: "worker_started",
156+      level: "info",
157+      createdAt: executionStartedAt,
158+      message: `Worker runner opened execution scope for ${request.workerKind} step ${request.stepId}.`,
159+      data: {
160+        stepKind: request.stepKind,
161+        timeoutSec: request.timeoutSec,
162+        worktreePath: request.runtime.worktreePath
163+      }
164+    },
165+    {
166+      status: "running"
167     }
168-  });
169-  synchronizeRunState(run, {
170-    status: "running",
171-    updatedAt: executionStartedAt
172-  });
173+  );
174 
175   const execution = await executor.execute(run);
176   const blocked = execution.blocked ?? execution.outcome === "blocked";
177@@ -312,19 +378,15 @@ export async function runStep(
178   const exitCode = resolveExitCode(execution);
179 
180   for (const line of execution.stdout ?? []) {
181-    appendStreamChunk(run.logSession, "stdout", {
182-      text: line
183-    });
184+    await appendStreamChunkPersisted(run, "stdout", line);
185   }
186 
187   for (const line of execution.stderr ?? []) {
188-    appendStreamChunk(run.logSession, "stderr", {
189-      text: line
190-    });
191+    await appendStreamChunkPersisted(run, "stderr", line);
192   }
193 
194   if (execution.outcome === "prepared") {
195-    recordLifecycleEvent(run.logSession, {
196+    await recordLifecycleEventPersisted(run, {
197       type: "worker_execution_deferred",
198       level: "info",
199       message: `Real ${request.workerKind} execution is intentionally deferred while checkpoint capture remains active.`,
200@@ -335,7 +397,7 @@ export async function runStep(
201     });
202   }
203 
204-  recordLifecycleEvent(run.logSession, {
205+  await recordLifecycleEventPersisted(run, {
206     type: "worker_exited",
207     level: exitCode === 0 ? "info" : "error",
208     message: `Worker runner closed execution scope with outcome ${execution.outcome}.`,
209@@ -347,34 +409,36 @@ export async function runStep(
210 
211   const finishedAt = new Date().toISOString();
212 
213-  recordLifecycleEvent(run.logSession, {
214-    type: mapOutcomeToTerminalEvent(execution.outcome),
215-    level: mapOutcomeToLevel(execution),
216-    createdAt: finishedAt,
217-    message: execution.summary,
218-    data: {
219-      ok: execution.ok,
220-      blocked,
221-      needsHuman,
222+  await recordLifecycleEventPersisted(
223+    run,
224+    {
225+      type: mapOutcomeToTerminalEvent(execution.outcome),
226+      level: mapOutcomeToLevel(execution),
227+      createdAt: finishedAt,
228+      message: execution.summary,
229+      data: {
230+        ok: execution.ok,
231+        blocked,
232+        needsHuman,
233+        exitCode
234+      }
235+    },
236+    {
237+      status: mapOutcomeToRunStatus(execution.outcome),
238+      finishedAt,
239+      summary: execution.summary,
240       exitCode
241     }
242-  });
243+  );
244 
245-  emitLogTailCheckpoint(
246+  const logTailCheckpoint = emitLogTailCheckpoint(
247     run.checkpointManager,
248     run.checkpoint,
249     run.logSession,
250     `Captured combined log tail after ${execution.outcome} for ${request.stepId}.`,
251     request.checkpoint?.logTailLines
252   );
253-
254-  synchronizeRunState(run, {
255-    status: mapOutcomeToRunStatus(execution.outcome),
256-    updatedAt: finishedAt,
257-    finishedAt,
258-    summary: execution.summary,
259-    exitCode
260-  });
261+  await persistCheckpointRecordIfPresent(run, logTailCheckpoint);
262 
263   const logSummary = summarizeLocalRunLogSession(run.logSession);
264   const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
M coordination/tasks/T-013-build-runtime.md
+29, -9
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-013
 4 title: Build 与 dist 产物
 5-status: todo
 6+status: review
 7 branch: feat/T-013-build-runtime
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12   - T-011
13 write_scope:
14@@ -19,7 +19,7 @@ write_scope:
15   - apps/worker-runner/package.json
16   - apps/worker-runner/tsconfig.json
17   - docs/runtime/**
18-updated_at: 2026-03-21
19+updated_at: 2026-03-22T00:09:20+0800
20 ---
21 
22 # T-013 Build 与 dist 产物
23@@ -69,25 +69,45 @@ updated_at: 2026-03-21
24 
25 ## files_changed
26 
27-- 待填写
28+- `coordination/tasks/T-013-build-runtime.md`
29+- `package.json`
30+- `apps/conductor-daemon/package.json`
31+- `apps/control-api-worker/package.json`
32+- `apps/status-api/package.json`
33+- `apps/worker-runner/package.json`
34+- `docs/runtime/README.md`
35+- `docs/runtime/launchd.md`
36 
37 ## commands_run
38 
39-- 待填写
40+- `git worktree add /Users/george/code/baa-conductor-T013 -b feat/T-013-build-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
41+- `npx --yes pnpm install`
42+- `npx --yes pnpm -r build`
43+- `node --input-type=module -e "await import('./apps/conductor-daemon/dist/index.js'); console.log('ok conductor-daemon');"`
44+- `node --input-type=module -e "await import('./apps/control-api-worker/dist/index.js'); console.log('ok control-api-worker');"`
45+- `node --input-type=module -e "await import('./apps/status-api/dist/index.js'); console.log('ok status-api');"`
46+- `node --input-type=module -e "await import('./apps/worker-runner/dist/index.js'); console.log('ok worker-runner');"`
47 
48 ## result
49 
50-- 待填写
51+- 四个 app 的 `build` 已从 `tsc --noEmit` 改成真实 emit,其中 `conductor-daemon` 直接输出 `dist/index.js`
52+- 为 `control-api-worker`、`status-api`、`worker-runner` 增加了统一 postbuild 处理:固定根入口到 `dist/index.js`,并在需要时改写编译后 import specifier
53+- `control-api-worker` 与 `worker-runner` 的编译产物已改写为引用同一 `dist/` 下同步生成的 package JS 文件,`worker-runner` 产物里的相对 import 也已补齐 `.js` 扩展
54+- `npx --yes pnpm -r build` 通过,四个 `apps/*/dist/index.js` 均存在,且可被 `node` 直接 `import(...)`
55+- `docs/runtime` 已更新为“先 build 再 launchd bootstrap”的当前约定,不再声明仓库缺少 `dist/index.js`
56 
57 ## risks
58 
59-- 待填写
60+- 目前 package 自身仍然只做 typecheck,没有形成独立发布级 `dist/`;`control-api-worker` 与 `worker-runner` 依赖 app build 阶段的产物改写
61+- 根 `package.json` 中的 postbuild helper 目前以内联脚本维护,后续如果构建规则继续变复杂,最好拆成独立脚本文件
62+- 本任务解决的是“产物路径稳定且可加载”,不是“服务运行时逻辑全部接好”;`T-014`、`T-015`、`T-017` 仍需补真正启动流程
63 
64 ## next_handoff
65 
66-- 待填写
67+- `T-014` 可以直接把 `apps/control-api-worker/dist/index.js` 当作稳定入口做运行时接线,不必再解决 bare workspace import 问题
68+- `T-015` 可以基于 `apps/conductor-daemon/dist/index.js` 继续补 CLI/daemon 启动逻辑,并复用文档里“先 build 再 bootstrap”的流程
69+- `T-017` 可以直接围绕 `apps/status-api/dist/index.js` 挂 HTTP 启动入口;若后续需要 package 级独立 dist,可另拆构建任务
70 
71 ## notes
72 
73 - `2026-03-21`: 创建第三波任务卡
74-
M coordination/tasks/T-014-control-api-runtime.md
+25, -9
 1@@ -1,16 +1,16 @@
 2 ---
 3 task_id: T-014
 4 title: Control API 运行时接线
 5-status: todo
 6+status: review
 7 branch: feat/T-014-control-api-runtime
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12   - T-003
13   - T-012
14 write_scope:
15   - apps/control-api-worker/**
16-updated_at: 2026-03-21
17+updated_at: 2026-03-22
18 ---
19 
20 # T-014 Control API 运行时接线
21@@ -59,25 +59,41 @@ updated_at: 2026-03-21
22 
23 ## files_changed
24 
25-- 待填写
26+- `apps/control-api-worker/src/contracts.ts`
27+- `apps/control-api-worker/src/handlers.ts`
28+- `apps/control-api-worker/src/index.ts`
29+- `apps/control-api-worker/src/router.ts`
30+- `apps/control-api-worker/src/runtime.ts`
31+- `apps/control-api-worker/src/schemas.ts`
32+- `coordination/tasks/T-014-control-api-runtime.md`
33 
34 ## commands_run
35 
36-- 待填写
37+- `git worktree add /Users/george/code/baa-conductor-T014 -b feat/T-014-control-api-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
38+- `npx --yes pnpm install`
39+- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
40+- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
41 
42 ## result
43 
44-- 待填写
45+- 明确了 `ControlApiEnv` 与运行时服务结构,新增 env 驱动的 auth/repository 装配层,并支持显式注入 `authHook`、`tokenVerifier`、`repository` 与 `now`。
46+- 把 auth hook 真正接入请求执行流;当配置了 runtime token 或外部 verifier 时,路由会执行 Bearer 提取、principal 解析和 `authorizeControlApiRoute(...)` 授权。
47+- 落地了可安全运行的 handler:`controllers.heartbeat`、`leader.acquire`、`tasks.create`、`system.pause` / `resume` / `drain`、`system.state`、`tasks.read` 已接到真实 repository 调用;其余未完成路由仍保持显式 `501`。
48+- 为已接线路由补了最小 request 校验和 `repository_not_configured` / `invalid_request` / `*_not_found` 失败路径,使运行时错误更明确。
49 
50 ## risks
51 
52-- 待填写
53+- `BAA_SHARED_TOKEN` 模式仍是最小实现:controller / worker 的身份会从当前请求资源推断,尚未做到签名 claim 或强身份绑定。
54+- `tasks.plan`、`tasks.claim`、step 回写、task logs、run detail 仍是占位实现;完整 durable 调度链路仍依赖后续任务继续接入。
55+- 如果没有配置 runtime token / verifier 且未显式打开 `CONTROL_API_AUTH_REQUIRED`,auth 仍会退化为跳过模式;生产部署时应显式配置鉴权输入。
56 
57 ## next_handoff
58 
59-- 待填写
60+- `T-015` / conductor 运行时可以直接复用新的 `leader.acquire`、`controllers.heartbeat` 路由和 env auth wiring。
61+- 后续任务可沿当前 `runtime.ts` 的装配点继续接 `tasks.plan`、`tasks.claim`、`steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` 的真实持久化逻辑。
62+- `T-017` 可直接消费 `GET /v1/system/state` 与 `GET /v1/tasks/:task_id`;如需日志与 run 详情,需要补本地 query/repository 读取路径。
63 
64 ## notes
65 
66 - `2026-03-21`: 创建第三波任务卡
67-
68+- `2026-03-22`: 完成 control-api 运行时接线并通过目标包 `typecheck` / `build`
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-
M coordination/tasks/T-016-worker-persistence.md
+23, -9
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-016
 4 title: Worker 本地持久化
 5-status: todo
 6+status: review
 7 branch: feat/T-016-worker-persistence
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12   - T-005
13   - T-006
14@@ -12,7 +12,7 @@ write_scope:
15   - apps/worker-runner/**
16   - packages/checkpointing/**
17   - packages/logging/**
18-updated_at: 2026-03-21
19+updated_at: 2026-03-22
20 ---
21 
22 # T-016 Worker 本地持久化
23@@ -60,25 +60,39 @@ updated_at: 2026-03-21
24 
25 ## files_changed
26 
27-- 待填写
28+- `apps/worker-runner/src/runner.ts`
29+- `packages/checkpointing/src/index.ts`
30+- `packages/checkpointing/src/node-shims.ts`
31+- `packages/logging/src/index.ts`
32+- `packages/logging/src/node-shims.ts`
33+- `packages/logging/src/persistence.ts`
34+- `coordination/tasks/T-016-worker-persistence.md`
35 
36 ## commands_run
37 
38-- 待填写
39+- `npx --yes pnpm install`
40+- `npx --yes pnpm --filter @baa-conductor/logging typecheck`
41+- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
42+- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
43+- `npx --yes pnpm exec tsc -p apps/worker-runner/tsconfig.json --outDir <tmp> --module commonjs`
44+- `DIST_ROOT=<tmp> RUNTIME_ROOT=<tmp> node <<'EOF' ... EOF`
45 
46 ## result
47 
48-- 待填写
49+- `worker-runner` 现在会创建本地 run 目录,并在初始化时写入 `meta.json`、`state.json`、空的 `worker.log` / `stdout.log` / `stderr.log`。
50+- 生命周期事件、stdout/stderr chunk、checkpoint 写入后都会同步刷新本地 `state.json`,不再只停留在内存态。
51+- `summary` 与 `log_tail` checkpoint 会真实落到 `checkpoints/`,并已通过临时 CommonJS 编译验证生成 `0001-summary.json`、`0002-log-tail.txt`。
52 
53 ## risks
54 
55-- 待填写
56+- 真实 Codex 子进程尚未接入,当前只验证了 placeholder/custom executor 路径下的本地持久化。
57+- `git_diff`、`test_output` 等更大 checkpoint 负载还没有实际生成端,当前仅补齐了文件写入基础设施。
58 
59 ## next_handoff
60 
61-- 待填写
62+- 将真实 worker 执行器接到 `appendStreamChunkPersisted` / `recordLifecycleEventPersisted`,保持 stdout/stderr 与状态文件持续落盘。
63+- 在后续 checkpoint 任务里直接复用 `persistCheckpointRecord` 扩展 `git_diff`、`test_output` 的实际产出。
64 
65 ## notes
66 
67 - `2026-03-21`: 创建第三波任务卡
68-
M coordination/tasks/T-017-status-runtime.md
+23, -9
 1@@ -1,15 +1,15 @@
 2 ---
 3 task_id: T-017
 4 title: Status API 运行时入口
 5-status: todo
 6+status: review
 7 branch: feat/T-017-status-runtime
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12   - T-010
13 write_scope:
14   - apps/status-api/**
15-updated_at: 2026-03-21
16+updated_at: 2026-03-22
17 ---
18 
19 # T-017 Status API 运行时入口
20@@ -55,25 +55,39 @@ updated_at: 2026-03-21
21 
22 ## files_changed
23 
24-- 待填写
25+- `apps/status-api/package.json`
26+- `apps/status-api/tsconfig.json`
27+- `apps/status-api/src/contracts.ts`
28+- `apps/status-api/src/index.ts`
29+- `apps/status-api/src/runtime.ts`
30+- `apps/status-api/src/service.ts`
31+- `coordination/tasks/T-017-status-runtime.md`
32 
33 ## commands_run
34 
35-- 待填写
36+- `npx --yes pnpm install`
37+- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
38+- `npx --yes pnpm --filter @baa-conductor/status-api build`
39+- `node --input-type=module -e "import { createStatusApiRuntime } from './apps/status-api/dist/apps/status-api/src/index.js'; ..."`
40 
41 ## result
42 
43-- 待填写
44+- 已将 status-api 从包内 handler 扩展为可挂载的 fetch 运行时入口,新增 `createStatusApiRuntime()` 与 `createStatusApiFetchHandler()`,可直接对接标准 `Request`/`Response`。
45+- 已整理对外路由面,明确以 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 为 canonical surface,并把 `/`、`/ui` 保留为 UI 别名。
46+- 已将 `build` 改为真实 `tsc` 发射,确认生成 `apps/status-api/dist/**` 产物,并用构建后的运行时代码冒烟验证三条 GET 路由。
47 
48 ## risks
49 
50-- 待填写
51+- 默认运行时仍使用 `StaticStatusSnapshotLoader`;真正接入 D1 或本地控制平面数据库仍需由后续整合步骤注入 `D1StatusSnapshotLoader`。
52+- 当前 dist 入口路径受 `rootDir: ../..` 影响为 `dist/apps/status-api/src/*.js`;如果后续统一构建任务收敛到平铺的 `dist/index.js`,需要同步调整 `package.json` 的 `main`/`exports`。
53+- fetch 运行时假设宿主环境提供标准 Fetch API;若后续必须在更旧的 Node 版本运行,需要额外补 polyfill 或改为 node server adapter。
54 
55 ## next_handoff
56 
57-- 待填写
58+- 在 status-api 的宿主进程中注入真实 `D1StatusSnapshotLoader`,把 `createStatusApiRuntime()` 挂到本地 HTTP server、launchd 进程或上层 router。
59+- 若仓库后续统一 dist 布局,顺手把 `@baa-conductor/status-api` 的导出路径更新到新的 build 产物位置。
60 
61 ## notes
62 
63 - `2026-03-21`: 创建第三波任务卡
64-
65+- `2026-03-22`: 从 `main@c5e007b` 建立独立 worktree,补齐 status-api fetch 运行时入口与 canonical route surface,完成验证并进入 review。
M docs/runtime/README.md
+4, -1
 1@@ -2,7 +2,9 @@
 2 
 3 本目录定义 `mini` 与 `mac` 上的本地 runtime 约定:目录布局、环境变量和 `launchd` 安装方式。
 4 
 5-当前仓库里的 app 仍是骨架实现,`pnpm build` 只做 TypeScript 校验,不生成 `apps/*/dist/index.js`。因此 `ops/launchd/*.plist` 现在是部署模板,先把路径和环境约定固定下来,等真实产物接上后继续沿用同一套安装步骤。
 6+当前仓库已经把 app 级 `build` 从单纯 typecheck 推进到真实 emit。执行 `npx --yes pnpm -r build` 后,`apps/*/dist/index.js` 会生成,`ops/launchd/*.plist` 里的入口路径也因此固定下来。
 7+
 8+这仍然不代表所有长期服务都已经完成运行时接线。当前阶段解决的是“产物路径存在且一致”,而不是“所有 daemon/worker 逻辑都已可直接上线”。
 9 
10 ## 内容
11 
12@@ -22,3 +24,4 @@
13 1. 先按 [`layout.md`](./layout.md) 初始化 runtime 根目录。
14 2. 再按 [`environment.md`](./environment.md) 准备共享变量和节点变量,特别是 `BAA_SHARED_TOKEN`。
15 3. 最后按 [`launchd.md`](./launchd.md) 复制、调整并加载 plist。
16+4. 每次准备加载或重载本地服务前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
M docs/runtime/launchd.md
+9, -1
 1@@ -150,4 +150,12 @@ tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conduct
 2 tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-worker-runner.err.log
 3 ```
 4 
 5-当前仓库还没有真实的 `dist/index.js` 产物,因此真正执行 `bootstrap` 后,服务进程仍可能因为入口文件不存在而启动失败。这不影响本任务交付的部署约定;后续只要服务构建产物落到约定路径,安装方式不需要再改。
 6+当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
 7+
 8+```bash
 9+npx --yes pnpm -r build
10+```
11+
12+这样可以确保 plist 指向的 `apps/*/dist/index.js` 已刷新到最新代码。
13+
14+需要注意的是,第三波后续任务还会继续补各服务的真正运行时接线,所以“入口文件存在”不等于“业务逻辑已经全部落地”。这里解决的是部署路径与构建产物的一致性。
M package.json
+1, -1
 1@@ -4,6 +4,7 @@
 2   "packageManager": "pnpm@10.6.0",
 3   "scripts": {
 4     "build": "pnpm -r build",
 5+    "build:runtime-postprocess": "node --input-type=module -e \"import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const distDir = process.env.BAA_DIST_DIR; const entry = process.env.BAA_DIST_ENTRY; if (!distDir || !entry) { throw new Error('BAA_DIST_DIR and BAA_DIST_ENTRY are required.'); } const aliasPairs = (process.env.BAA_IMPORT_ALIASES ?? '').split(';').filter(Boolean).map((pair) => { const separatorIndex = pair.indexOf('='); if (separatorIndex <= 0) { throw new Error('Invalid alias pair: ' + pair); } return [pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1)]; }); const fixRelativeExtensions = process.env.BAA_FIX_RELATIVE_EXTENSIONS === 'true'; const jsFiles = []; const collect = (directory) => { for (const entryName of readdirSync(directory)) { const entryPath = join(directory, entryName); const stats = statSync(entryPath); if (stats.isDirectory()) { collect(entryPath); continue; } if (entryPath.endsWith('.js')) { jsFiles.push(entryPath); } } }; collect(distDir); for (const filePath of jsFiles) { let source = readFileSync(filePath, 'utf8'); for (const [from, to] of aliasPairs) { source = source.replaceAll('\\\"' + from + '\\\"', '\\\"' + to + '\\\"').replaceAll(\\\"'\\\" + from + \\\"'\\\", \\\"'\\\" + to + \\\"'\\\"); } if (fixRelativeExtensions) { source = source.replace(/((?:from|import)\\s*[\\\"'])(\\.\\.?\\/[^\\\"'()]+?)([\\\"'])/g, (match, prefix, specifier, suffix) => /\\.[cm]?[jt]sx?$/.test(specifier) || specifier.endsWith('.json') ? match : prefix + specifier + '.js' + suffix); } writeFileSync(filePath, source); } mkdirSync(distDir, { recursive: true }); const entrySpecifier = './' + entry; const shimLines = ['export * from ' + JSON.stringify(entrySpecifier) + ';']; if (process.env.BAA_EXPORT_DEFAULT === 'true') { shimLines.push('export { default } from ' + JSON.stringify(entrySpecifier) + ';'); } writeFileSync(join(distDir, 'index.js'), shimLines.join('\\n') + '\\n');\"",
 6     "typecheck": "pnpm -r typecheck",
 7     "lint": "echo 'TODO: add linting'",
 8     "test": "echo 'TODO: add tests'",
 9@@ -13,4 +14,3 @@
10     "typescript": "^5.8.2"
11   }
12 }
13-
M packages/checkpointing/src/index.ts
+23, -0
 1@@ -1,3 +1,5 @@
 2+import { mkdir, writeFile } from "node:fs/promises";
 3+
 4 export type CheckpointType = "summary" | "git_diff" | "log_tail" | "test_output";
 5 export type CheckpointFileFormat = "json" | "text" | "patch";
 6 export type CheckpointReplayStrategy = "none" | "git_apply_binary";
 7@@ -268,6 +270,16 @@ function renderTextContent(contentText?: string): string {
 8   return contentText.endsWith("\n") ? contentText : `${contentText}\n`;
 9 }
10 
11+function getParentDirectory(filePath: string): string {
12+  const lastSlashIndex = filePath.lastIndexOf("/");
13+
14+  if (lastSlashIndex <= 0) {
15+    return ".";
16+  }
17+
18+  return filePath.slice(0, lastSlashIndex);
19+}
20+
21 export function createCheckpointFilename(
22   seq: number,
23   checkpointTypeOrSuffix: CheckpointType | string
24@@ -382,6 +394,17 @@ export function renderCheckpointFile(record: CheckpointRecord): RenderedCheckpoi
25   };
26 }
27 
28+export async function persistCheckpointRecord(
29+  record: CheckpointRecord
30+): Promise<RenderedCheckpointFile> {
31+  const rendered = renderCheckpointFile(record);
32+
33+  await mkdir(getParentDirectory(rendered.storagePath), { recursive: true });
34+  await writeFile(rendered.storagePath, rendered.content, "utf8");
35+
36+  return rendered;
37+}
38+
39 export function createCheckpointManager(input: CreateCheckpointManagerInput): CheckpointManager {
40   const state = createCheckpointManagerState(input);
41 
A packages/checkpointing/src/node-shims.ts
+23, -0
 1@@ -0,0 +1,23 @@
 2+declare module "node:fs/promises" {
 3+  export interface FileOperationOptions {
 4+    encoding?: string;
 5+    mode?: number;
 6+    flag?: string;
 7+  }
 8+
 9+  export interface MakeDirectoryOptions {
10+    recursive?: boolean;
11+    mode?: number;
12+  }
13+
14+  export function mkdir(
15+    path: string,
16+    options?: MakeDirectoryOptions
17+  ): Promise<string | undefined>;
18+
19+  export function writeFile(
20+    path: string,
21+    data: string,
22+    options?: FileOperationOptions | string
23+  ): Promise<void>;
24+}
M packages/logging/src/index.ts
+1, -0
1@@ -1,4 +1,5 @@
2 export * from "./contracts";
3 export * from "./paths";
4+export * from "./persistence";
5 export * from "./session";
6 export * from "./state";
A packages/logging/src/node-shims.ts
+29, -0
 1@@ -0,0 +1,29 @@
 2+declare module "node:fs/promises" {
 3+  export interface FileOperationOptions {
 4+    encoding?: string;
 5+    mode?: number;
 6+    flag?: string;
 7+  }
 8+
 9+  export interface MakeDirectoryOptions {
10+    recursive?: boolean;
11+    mode?: number;
12+  }
13+
14+  export function mkdir(
15+    path: string,
16+    options?: MakeDirectoryOptions
17+  ): Promise<string | undefined>;
18+
19+  export function writeFile(
20+    path: string,
21+    data: string,
22+    options?: FileOperationOptions | string
23+  ): Promise<void>;
24+
25+  export function appendFile(
26+    path: string,
27+    data: string,
28+    options?: FileOperationOptions | string
29+  ): Promise<void>;
30+}
A packages/logging/src/persistence.ts
+76, -0
 1@@ -0,0 +1,76 @@
 2+import { appendFile, mkdir, writeFile } from "node:fs/promises";
 3+import type {
 4+  LocalRunLogSession,
 5+  LocalRunPaths,
 6+  RunMetadata,
 7+  RunStateSnapshot,
 8+  StreamChunkLogEntry,
 9+  WorkerLifecycleLogEntry
10+} from "./contracts";
11+
12+function renderJsonFile(value: unknown): string {
13+  return `${JSON.stringify(value, null, 2)}\n`;
14+}
15+
16+function ensureTrailingNewline(value: string): string {
17+  if (value === "" || value.endsWith("\n")) {
18+    return value;
19+  }
20+
21+  return `${value}\n`;
22+}
23+
24+export async function ensureLocalRunLayout(paths: LocalRunPaths): Promise<void> {
25+  await Promise.all([
26+    mkdir(paths.taskRunsDir, { recursive: true }),
27+    mkdir(paths.runDir, { recursive: true }),
28+    mkdir(paths.checkpointsDir, { recursive: true }),
29+    mkdir(paths.artifactsDir, { recursive: true })
30+  ]);
31+}
32+
33+export async function writeRunMetadata(
34+  paths: LocalRunPaths,
35+  metadata: RunMetadata
36+): Promise<void> {
37+  await writeFile(paths.metaPath, renderJsonFile(metadata), "utf8");
38+}
39+
40+export async function writeRunState(
41+  paths: LocalRunPaths,
42+  state: RunStateSnapshot
43+): Promise<void> {
44+  await writeFile(paths.statePath, renderJsonFile(state), "utf8");
45+}
46+
47+export async function initializeLocalRunFiles(
48+  paths: LocalRunPaths,
49+  metadata: RunMetadata,
50+  state: RunStateSnapshot
51+): Promise<void> {
52+  await ensureLocalRunLayout(paths);
53+  await Promise.all([
54+    writeRunMetadata(paths, metadata),
55+    writeRunState(paths, state),
56+    writeFile(paths.workerLogPath, "", "utf8"),
57+    writeFile(paths.stdoutLogPath, "", "utf8"),
58+    writeFile(paths.stderrLogPath, "", "utf8")
59+  ]);
60+}
61+
62+export async function appendLifecycleEntry(
63+  session: LocalRunLogSession,
64+  entry: WorkerLifecycleLogEntry
65+): Promise<void> {
66+  await appendFile(session.worker.filePath, ensureTrailingNewline(entry.renderedLine), "utf8");
67+}
68+
69+export async function appendStreamEntry(
70+  session: LocalRunLogSession,
71+  entry: StreamChunkLogEntry
72+): Promise<void> {
73+  const filePath =
74+    entry.channel === "stdout" ? session.stdout.filePath : session.stderr.filePath;
75+
76+  await appendFile(filePath, ensureTrailingNewline(entry.text), "utf8");
77+}