- commit
- da004c8
- parent
- 67c5482
- author
- im_wower
- date
- 2026-03-22 00:37:43 +0800 CST
Merge remote-tracking branch 'origin/feat/T-015-conductor-runtime' into integration/third-wave-20260322 # Conflicts: # apps/conductor-daemon/package.json
4 files changed,
+1317,
-39
+3,
-0
1@@ -5,6 +5,9 @@
2 "main": "dist/index.js",
3 "scripts": {
4 "build": "pnpm exec tsc -p tsconfig.json",
5+ "dev": "node --experimental-strip-types src/index.ts",
6+ "start": "node dist/index.js",
7+ "test": "node --test --experimental-strip-types src/index.test.js",
8 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
9 }
10 }
+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+});
+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+}
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-