@bakers-build/conductor 0.0.0-stage → 0.1.0-next.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/dist/src/bakers-project-context.d.ts +12 -0
  4. package/dist/src/bakers-project-context.js +59 -0
  5. package/dist/src/cli/config-plan.d.ts +35 -0
  6. package/dist/src/cli/config-plan.js +1264 -0
  7. package/dist/src/cli/config-schema.d.ts +137 -0
  8. package/dist/src/cli/config-schema.js +239 -0
  9. package/dist/src/cli/config-source-edit.d.ts +17 -0
  10. package/dist/src/cli/config-source-edit.js +95 -0
  11. package/dist/src/cli/config-templates.d.ts +43 -0
  12. package/dist/src/cli/config-templates.js +130 -0
  13. package/dist/src/cli/config-validation.d.ts +12 -0
  14. package/dist/src/cli/config-validation.js +353 -0
  15. package/dist/src/cli/migrate-addon-names.d.ts +82 -0
  16. package/dist/src/cli/migrate-addon-names.js +334 -0
  17. package/dist/src/cli/project-context.d.ts +21 -0
  18. package/dist/src/cli/project-context.js +105 -0
  19. package/dist/src/commands/conductor.d.ts +91 -0
  20. package/dist/src/commands/conductor.js +843 -0
  21. package/dist/src/config.d.ts +109 -0
  22. package/dist/src/config.js +712 -0
  23. package/dist/src/contract.d.ts +13 -0
  24. package/dist/src/contract.js +19 -0
  25. package/dist/src/core/action-queue.d.ts +31 -0
  26. package/dist/src/core/action-queue.js +69 -0
  27. package/dist/src/core/attempt.d.ts +25 -0
  28. package/dist/src/core/attempt.js +41 -0
  29. package/dist/src/core/conductor.d.ts +236 -0
  30. package/dist/src/core/conductor.js +1293 -0
  31. package/dist/src/core/host-key.d.ts +19 -0
  32. package/dist/src/core/host-key.js +27 -0
  33. package/dist/src/core/inbox-runtime.d.ts +48 -0
  34. package/dist/src/core/inbox-runtime.js +180 -0
  35. package/dist/src/core/mr-changes.d.ts +18 -0
  36. package/dist/src/core/mr-changes.js +47 -0
  37. package/dist/src/index.d.ts +8 -0
  38. package/dist/src/index.js +27 -0
  39. package/dist/src/plugins/agent-args.d.ts +43 -0
  40. package/dist/src/plugins/agent-args.js +221 -0
  41. package/dist/src/plugins/agent.d.ts +121 -0
  42. package/dist/src/plugins/agent.js +12 -0
  43. package/dist/src/plugins/executor.d.ts +182 -0
  44. package/dist/src/plugins/executor.js +1 -0
  45. package/dist/src/plugins/message.d.ts +61 -0
  46. package/dist/src/plugins/message.js +47 -0
  47. package/dist/src/plugins/plugins.d.ts +96 -0
  48. package/dist/src/plugins/plugins.js +120 -0
  49. package/dist/src/plugins/preset.d.ts +61 -0
  50. package/dist/src/plugins/preset.js +23 -0
  51. package/dist/src/plugins/process-host.d.ts +7 -0
  52. package/dist/src/plugins/process-host.js +1 -0
  53. package/dist/src/plugins/remote.d.ts +420 -0
  54. package/dist/src/plugins/remote.js +1 -0
  55. package/dist/src/plugins/workspace.d.ts +69 -0
  56. package/dist/src/plugins/workspace.js +1 -0
  57. package/dist/src/preset.d.ts +2 -0
  58. package/dist/src/preset.js +8 -0
  59. package/dist/src/types.d.ts +132 -0
  60. package/dist/src/types.js +6 -0
  61. package/package.json +38 -4
@@ -0,0 +1,843 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { CommandRunner, checkForUpdate, createBbConnectionClient, createLogger, declineUpdate, defaultProcProbe, deriveCheckoutRoot, deriveConductorLiveness, loadReadyBbConnection, printVersionLine, resolveConfigPath, setDefaultCommandRunner, signalVerifiedProcess, stemForConfigFile, updateNotice, verifyConductorProcess, } from '@bakers-build/core';
5
+ import { Command } from 'commander';
6
+ import { authStatus } from 'dropsh';
7
+ import { resolveBbProjectContext } from '../bakers-project-context.js';
8
+ import { composeConductorConfig, loadConductorConfig } from '../config.js';
9
+ import { Conductor } from '../core/conductor.js';
10
+ import { hostKeyOf } from '../core/host-key.js';
11
+ import { selectAgents, selectExecutor, selectRemote, selectWorkspace, } from '../plugins/plugins.js';
12
+ /** Default host bases for connection/plugin resolution when none is injected. */
13
+ function defaultHost() {
14
+ return { resolveBases: [import.meta.url, `${process.cwd()}/`] };
15
+ }
16
+ /**
17
+ * Resolve + compose the full conductor config: the engine half
18
+ * (`conductor.config.js` via `loadConductorConfig`) plus the connection half
19
+ * (`bakers.config.js` via `loadBbConfig`, walk-up → home → shipped fallback).
20
+ * `--config` / `$BB_CONDUCTOR_CONFIG` selects the engine file; the connection
21
+ * is resolved from cwd.
22
+ */
23
+ async function resolveConfig(deps, configPathOverride, resolveProject = false) {
24
+ if (deps.config) {
25
+ return deps.config;
26
+ }
27
+ const host = deps.host ?? defaultHost();
28
+ const engine = await loadConductorConfig(resolveConfigPath(configPathOverride));
29
+ if (!resolveProject) {
30
+ const connection = await loadReadyBbConnection(host, {
31
+ ...(engine.bakers.workspace
32
+ ? { workspaceSelector: engine.bakers.workspace }
33
+ : {}),
34
+ });
35
+ return composeConductorConfig(engine, connection);
36
+ }
37
+ const unscoped = await loadReadyBbConnection(host, {
38
+ workspaceIdOverride: '',
39
+ });
40
+ const client = await createBbConnectionClient(unscoped);
41
+ const context = await resolveBbProjectContext(client, engine.bakers);
42
+ const connection = await loadReadyBbConnection(host, {
43
+ workspaceIdOverride: '',
44
+ workspaceSelector: context.workspaceId,
45
+ });
46
+ return {
47
+ ...composeConductorConfig(engine, connection),
48
+ resolved_bakers: context,
49
+ };
50
+ }
51
+ async function resolveRemote(deps, config) {
52
+ return deps.remote ?? (await selectRemote(config));
53
+ }
54
+ /**
55
+ * The checkout root this conductor serves — the top of the working tree, not
56
+ * its `.bakers` config directory (BB-450 D3). Resolved through the one shared
57
+ * helper `deriveHerdrRoot()` already implemented, now `@bakers-build/core`'s
58
+ * `deriveCheckoutRoot()`, so this call site cannot regress to a bare
59
+ * `dirname(config_path)`.
60
+ */
61
+ function checkoutRootOf(config) {
62
+ return deriveCheckoutRoot(config.config_path);
63
+ }
64
+ /**
65
+ * The conductor's stable identity (bb_conductor.conductor_id). Required —
66
+ * the loader throws without it — so lifecycle commands read it here, never
67
+ * re-derive. Soft-reads legacy machine_id during the BB-353 cutover.
68
+ */
69
+ function conductorIdOf(config) {
70
+ const id = config.conductor_id ?? config.machine_id;
71
+ if (id === undefined || id.trim() === '') {
72
+ throw new Error('conductor config has no conductor_id');
73
+ }
74
+ return id;
75
+ }
76
+ /**
77
+ * Where a conductor keeps the frozen tickets it has observed, beside its
78
+ * config file, so freeze hooks survive a restart and a one-shot poll.
79
+ */
80
+ function frozenStateFileOf(config) {
81
+ const file = config.config_path;
82
+ return join(dirname(file), `${basename(file, '.js')}.frozen.json`);
83
+ }
84
+ /** Read the `conductor`-level --log-level / --log-sink flags from a subcommand. */
85
+ function logOptsOf(cmd) {
86
+ const opts = cmd.optsWithGlobals();
87
+ return {
88
+ ...(opts.logLevel !== undefined ? { level: opts.logLevel } : {}),
89
+ ...(opts.logSink !== undefined ? { sink: opts.logSink } : {}),
90
+ };
91
+ }
92
+ /** Create the conductor logger (with CLI log overrides) and route all exec logging through it. */
93
+ function loggerFor(checkoutRoot, log = {}) {
94
+ const logger = createLogger({ checkoutRoot, ...log });
95
+ setDefaultCommandRunner(new CommandRunner(logger));
96
+ return logger;
97
+ }
98
+ function herdrHostOf(deps, config) {
99
+ const host = deps.herdr ?? config.processHost;
100
+ if (!host) {
101
+ throw new Error('no herdr process host — add @bakers-build/addon-herdr to conductor addons');
102
+ }
103
+ return host;
104
+ }
105
+ /**
106
+ * The liveness words this CLI prints.
107
+ *
108
+ * Delegated to core so `bakers oven ls`, the cockpit and the server-side
109
+ * deriver describe one conductor identically. The previous CLI-only vocabulary
110
+ * (`wedged`, `stopped`, `host-missing`, `registry-only`) went with the registry
111
+ * that made those distinctions expressible.
112
+ *
113
+ * BB-473: `deriveConductorLiveness` MUST NOT change — it also classifies
114
+ * REMOTE conductors this machine cannot probe. This function additionally
115
+ * probes the LOCAL pid, when both `thisHostKey` is given and it is this
116
+ * conductor's own host (`hub.hostKey === thisHostKey`) with a positive
117
+ * `lastPid` on record. `verifyConductorProcess` (the read-only half of the
118
+ * pid-signal proof; BB-450 D1) either leaves the record's word alone
119
+ * (host-mismatch / no-pid / an unverifiable platform, or a genuinely live,
120
+ * verified pid) or, on `stale-pid` — the process is provably gone or was
121
+ * recycled by something else — overrides it to `offline`: positive proof the
122
+ * process is gone beats a lease-derived guess. The printed word always stays
123
+ * one of `online | stale | offline` — no fourth word is invented.
124
+ */
125
+ function classify(hub, thisHostKey, probe) {
126
+ const recordDerived = deriveConductorLiveness(hub, Math.floor(Date.now() / 1000));
127
+ if (!thisHostKey ||
128
+ hub.hostKey !== thisHostKey ||
129
+ !Number.isInteger(hub.lastPid) ||
130
+ (hub.lastPid ?? 0) <= 0) {
131
+ return recordDerived;
132
+ }
133
+ const verified = verifyConductorProcess({
134
+ pid: hub.lastPid,
135
+ hostKey: hub.hostKey,
136
+ thisHostKey,
137
+ ...(probe ? { probe } : {}),
138
+ });
139
+ return !verified.ok && verified.reason === 'stale-pid'
140
+ ? 'offline'
141
+ : recordDerived;
142
+ }
143
+ async function buildLsRows(remote, config, onlyId, probe) {
144
+ let hub = [];
145
+ try {
146
+ hub = await remote.listConductors('me');
147
+ }
148
+ catch {
149
+ hub = [];
150
+ }
151
+ // BB-473: the local-pid probe in `classify` needs to know THIS machine's
152
+ // host_key (to gate it to rows that are this machine's own conductor) —
153
+ // `config` used to be ignored here, which is why `ls`/`status` never probed
154
+ // the local pid at all and relied purely on the record.
155
+ const thisHostKey = config ? hostKeyOf(config) : undefined;
156
+ return hub
157
+ .filter((c) => !onlyId || c.id === onlyId)
158
+ .map((c) => ({
159
+ id: c.id,
160
+ project: c.project,
161
+ label: c.label,
162
+ workspaceRoot: c.workspaceRoot,
163
+ status: classify(c, thisHostKey, probe),
164
+ }));
165
+ }
166
+ function printRows(rows) {
167
+ if (rows.length === 0) {
168
+ console.log('no conductors on the control plane');
169
+ return;
170
+ }
171
+ for (const r of rows) {
172
+ console.log(`${r.id}\t${r.status}\t${r.project}\t${r.label}\t${r.workspaceRoot}`);
173
+ }
174
+ }
175
+ // --- command handlers -------------------------------------------------------
176
+ /**
177
+ * Start-time auth gate. Returns true if authenticated (session or session-less
178
+ * provider); otherwise logs a single clear line and returns false.
179
+ */
180
+ export async function ensureAuthenticated(config, logger) {
181
+ const st = await authStatus({
182
+ baseUrl: config.site.base_url,
183
+ plugins: config.plugins ?? [],
184
+ });
185
+ if (!st.loggedIn) {
186
+ logger.error({ baseUrl: config.site.base_url }, `not logged in against ${config.site.base_url} — run 'bakers dropsh auth login'`);
187
+ return false;
188
+ }
189
+ return true;
190
+ }
191
+ async function cmdPoll(deps, log = {}) {
192
+ const config = await resolveConfig(deps, undefined, true);
193
+ const checkoutRoot = await checkoutRootOf(config);
194
+ const logger = loggerFor(checkoutRoot, log);
195
+ if (!(await ensureAuthenticated(config, logger)))
196
+ return;
197
+ const remote = await resolveRemote(deps, config);
198
+ const executor = deps.executor ?? (await selectExecutor(config, logger));
199
+ const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
200
+ const agents = deps.agents ?? (await selectAgents(config));
201
+ const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot, frozenStateFileOf(config));
202
+ await conductor.start();
203
+ await conductor.tick();
204
+ }
205
+ async function cmdReap(deps, log = {}) {
206
+ const config = await resolveConfig(deps, undefined, true);
207
+ const checkoutRoot = await checkoutRootOf(config);
208
+ const logger = loggerFor(checkoutRoot, log);
209
+ if (!(await ensureAuthenticated(config, logger)))
210
+ return;
211
+ const remote = await resolveRemote(deps, config);
212
+ const executor = deps.executor ?? (await selectExecutor(config, logger));
213
+ const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
214
+ const agents = deps.agents ?? (await selectAgents(config));
215
+ const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
216
+ await conductor.reap();
217
+ }
218
+ /**
219
+ * BB-474: `--foreground`'s one-per-project guard. Unlike `cmdStartBody`'s
220
+ * entity-derived check (`classify(existing) === 'online'`), liveness here is
221
+ * proven by an actual pid probe (`verifyConductorProcess`) of the recorded
222
+ * `last_pid` — a stale record whose process is dead never blocks a fresh
223
+ * start, and a record whose `host_key` differs from this host's never blocks
224
+ * one either, since `verifyConductorProcess` refuses to confirm a pid it
225
+ * cannot attribute to this host. Returns `true` when the caller must stop
226
+ * (this process refused, or a `--force` restart failed) — never a signal:
227
+ * `verifyConductorProcess` observes, it does not touch, the process it checks.
228
+ */
229
+ async function refuseIfAlreadyRunning(remote, id, thisHostKey, force, deps) {
230
+ const existing = (await remote.listConductors('me')).find((c) => c.id === id);
231
+ if (!existing)
232
+ return false;
233
+ const probe = deps.procProbe ?? defaultProcProbe();
234
+ const verified = verifyConductorProcess({
235
+ pid: existing.lastPid,
236
+ hostKey: existing.hostKey,
237
+ thisHostKey,
238
+ probe,
239
+ });
240
+ if (!verified.ok)
241
+ return false;
242
+ if (!force) {
243
+ const age = heartbeatAgeSeconds(existing);
244
+ console.log(`conductor ${id} already running (pid ${existing.lastPid ?? 'unknown'}, ` +
245
+ `heartbeat ${age === undefined ? 'age unknown' : `${age}s old`})`);
246
+ return true;
247
+ }
248
+ const stopOutcome = signalVerifiedProcess({
249
+ pid: existing.lastPid,
250
+ hostKey: existing.hostKey,
251
+ thisHostKey,
252
+ signal: 'SIGTERM',
253
+ probe,
254
+ });
255
+ if (!stopOutcome.ok) {
256
+ console.log(`failed to restart conductor ${id}: could not stop the running conductor — ${stopOutcome.message}`);
257
+ process.exitCode = 1;
258
+ return true;
259
+ }
260
+ if (!(await waitForNotRunning(probe, stopOutcome.pid, deps))) {
261
+ const killOutcome = signalVerifiedProcess({
262
+ pid: existing.lastPid,
263
+ hostKey: existing.hostKey,
264
+ thisHostKey,
265
+ signal: 'SIGKILL',
266
+ probe,
267
+ });
268
+ if (!killOutcome.ok) {
269
+ console.log(`failed to restart conductor ${id}: still running after the grace period, and could not force-stop it — ${killOutcome.message}`);
270
+ process.exitCode = 1;
271
+ return true;
272
+ }
273
+ if (!(await waitForNotRunning(probe, killOutcome.pid, deps))) {
274
+ console.log(`failed to restart conductor ${id}: sent SIGKILL but it is still reported running — refusing a second spawn`);
275
+ process.exitCode = 1;
276
+ return true;
277
+ }
278
+ }
279
+ console.log(`stopped conductor ${id} for restart (--force)`);
280
+ return false;
281
+ }
282
+ async function cmdStartForeground(deps, log = {}, force = false) {
283
+ const config = await resolveConfig(deps, undefined, true);
284
+ const checkoutRoot = await checkoutRootOf(config);
285
+ const logger = loggerFor(checkoutRoot, log);
286
+ if (!(await ensureAuthenticated(config, logger)))
287
+ return;
288
+ const remote = await resolveRemote(deps, config);
289
+ const id = conductorIdOf(config);
290
+ const thisHostKey = hostKeyOf(config);
291
+ if (await refuseIfAlreadyRunning(remote, id, thisHostKey, force, deps))
292
+ return;
293
+ const executor = deps.executor ?? (await selectExecutor(config, logger));
294
+ const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
295
+ const agents = deps.agents ?? (await selectAgents(config));
296
+ const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot, frozenStateFileOf(config));
297
+ // This process IS the conductor, so it may report its own pid and config
298
+ // name — the two values a cockpit needs to stop it later. poll/reap build a
299
+ // Conductor too but never claim, so they cannot overwrite this pid.
300
+ conductor.claimServeProcess();
301
+ await conductor.start();
302
+ const controller = new AbortController();
303
+ const onSignal = () => controller.abort();
304
+ process.once('SIGINT', onSignal);
305
+ process.once('SIGTERM', onSignal);
306
+ try {
307
+ await conductor.serve(controller.signal);
308
+ }
309
+ finally {
310
+ process.removeListener('SIGINT', onSignal);
311
+ process.removeListener('SIGTERM', onSignal);
312
+ // The exiting process writes its own `offline`. It has to be this process:
313
+ // a heartbeat sets `online` unconditionally (ConductorRegistrar), so an
314
+ // offline written by anyone else is erased by the next tick — the status
315
+ // is only trustworthy when written after the heartbeats have stopped.
316
+ // Best-effort: a conductor that cannot reach the control plane on the way
317
+ // out must still exit, and the reaper flips it on lease expiry anyway.
318
+ try {
319
+ await remote.setConductorStatus(conductor.id, 'offline');
320
+ }
321
+ catch (err) {
322
+ logger.warn({ conductorId: conductor.id, err: String(err) }, 'could not write offline status on shutdown; the lease reaper will');
323
+ }
324
+ }
325
+ }
326
+ async function defaultAskUpdate(question) {
327
+ const rl = createInterface({
328
+ input: process.stdin,
329
+ output: process.stdout,
330
+ terminal: true,
331
+ });
332
+ let settled = false;
333
+ try {
334
+ return await new Promise((resolveAnswer, reject) => {
335
+ const cancel = () => {
336
+ if (settled)
337
+ return;
338
+ settled = true;
339
+ reject(new Error('cancelled'));
340
+ };
341
+ rl.once('SIGINT', cancel);
342
+ rl.once('close', cancel);
343
+ rl.question(`${question} `, (answer) => {
344
+ if (settled)
345
+ return;
346
+ settled = true;
347
+ resolveAnswer(answer);
348
+ });
349
+ });
350
+ }
351
+ finally {
352
+ rl.close();
353
+ }
354
+ }
355
+ async function cmdStart(deps, log = {}, updateCheck = true, body = cmdStartBody, force = false, foreground = false) {
356
+ const current = printVersionLine();
357
+ if (updateCheck) {
358
+ const options = {
359
+ fetchImpl: deps.fetch ?? globalThis.fetch,
360
+ ...deps.updateCheckOptions,
361
+ };
362
+ if (deps.fetch && options.cachePath === undefined) {
363
+ options.cachePath = `/tmp/bakers-update-check-${process.pid}-${Math.random()}.json`;
364
+ }
365
+ const result = await checkForUpdate(options);
366
+ const notice = result.latestVersion
367
+ ? updateNotice(current, result.latestVersion)
368
+ : null;
369
+ const env = deps.env ?? process.env;
370
+ const interactive = !foreground &&
371
+ (deps.stdinIsTTY ?? Boolean(process.stdin.isTTY)) &&
372
+ (deps.stdoutIsTTY ?? Boolean(process.stdout.isTTY)) &&
373
+ !env.CI;
374
+ if (notice && interactive && result.declined !== true) {
375
+ console.log('Installing accepts the newer release and runs configuration migration.');
376
+ console.log('After a successful install this start exits; rerun the start command to use the new release.');
377
+ const question = `A new bakers version is available (${current} → ${result.latestVersion}). Install it now? [Y/n]`;
378
+ const ask = deps.askUpdate ?? defaultAskUpdate;
379
+ let accepted;
380
+ try {
381
+ for (;;) {
382
+ const answer = (await ask(question)).trim().toLowerCase();
383
+ if (answer === '' || answer === 'y' || answer === 'yes') {
384
+ accepted = true;
385
+ break;
386
+ }
387
+ if (answer === 'n' || answer === 'no') {
388
+ accepted = false;
389
+ break;
390
+ }
391
+ }
392
+ }
393
+ catch {
394
+ console.error('Start cancelled.');
395
+ process.exitCode = 1;
396
+ return;
397
+ }
398
+ const runBb = deps.runBb ??
399
+ (async (args) => {
400
+ const { error, status, signal } = spawnSync('bakers', args, {
401
+ stdio: 'inherit',
402
+ cwd: process.cwd(),
403
+ env,
404
+ shell: false,
405
+ });
406
+ if (error)
407
+ throw error;
408
+ if (signal)
409
+ throw new Error(`bakers ${args[0]} terminated by ${signal}`);
410
+ if (status)
411
+ throw new Error(`bakers ${args[0]} exited ${status}`);
412
+ });
413
+ const prefix = env.BB_CONDUCTOR
414
+ ? `bakers --conductor ${env.BB_CONDUCTOR}`
415
+ : 'bakers';
416
+ const rerun = `${prefix} oven start${force ? ' --force' : ''}`;
417
+ if (accepted) {
418
+ try {
419
+ await runBb(['update']);
420
+ }
421
+ catch (err) {
422
+ console.error(`bakers update failed: ${String(err)}`);
423
+ console.error('Retry with: bakers update');
424
+ process.exitCode = 1;
425
+ return;
426
+ }
427
+ try {
428
+ await runBb(['config', 'upgrade', '--scope', 'all', '--yes']);
429
+ }
430
+ catch (err) {
431
+ console.error(`bakers config upgrade failed: ${String(err)}`);
432
+ console.error(`Retry with: ${prefix} config upgrade --scope all --yes`);
433
+ console.error(`Then rerun \`${rerun}\`.`);
434
+ process.exitCode = 1;
435
+ return;
436
+ }
437
+ console.log('bakers update and configuration migration completed.');
438
+ console.log(`This process still has the previous modules loaded; rerun \`${rerun}\` in a new process.`);
439
+ console.log('A source checkout or PATH shim may still run the source tree after a global install; use the bakers executable you expect after bakers update.');
440
+ return;
441
+ }
442
+ if (!(await declineUpdate(result, options))) {
443
+ console.error('Could not remember the decline; you may be asked again on the next start.');
444
+ }
445
+ }
446
+ else if (notice) {
447
+ console.log(notice);
448
+ }
449
+ }
450
+ deps.onStartLoop?.();
451
+ await body(deps, log, force);
452
+ }
453
+ /** Default budget: 1s between polls, 20 attempts ≈ 20s total. */
454
+ const START_POLL_INTERVAL_MS = 1000;
455
+ const START_POLL_MAX_ATTEMPTS = 20;
456
+ function realSleep(ms) {
457
+ return new Promise((resolve) => setTimeout(resolve, ms));
458
+ }
459
+ /** Seconds since the row's last heartbeat, or undefined when it never reported one. */
460
+ function heartbeatAgeSeconds(row) {
461
+ if (!row.lastSeen)
462
+ return undefined;
463
+ return Math.max(0, Math.floor(Date.now() / 1000) - row.lastSeen);
464
+ }
465
+ /**
466
+ * Polls the control plane until this conductor's DERIVED liveness reads
467
+ * `online`, the record carries a pid different from `priorPid`, AND its
468
+ * `last_seen` has advanced past `priorLastSeen` — a fresh heartbeat has
469
+ * arrived from a NEW process, not merely a record that was already fresh —
470
+ * or the attempt budget runs out.
471
+ *
472
+ * BB-450 D9: "started" must mean registered and claiming, not merely that a
473
+ * spawn call returned. The plain `start` path's premise — refusing to spawn
474
+ * when a row is already `online` means an `online` row afterwards is this
475
+ * start's own doing — is false on the `--force` path (BB-450 review B8):
476
+ * `--force` enters precisely because the existing row WAS `online`, a
477
+ * `SIGKILL`ed conductor writes no `offline` (nothing survives to write it),
478
+ * so the record can still read `online` with the OLD pid on attempt 0, before
479
+ * the new process has registered anything. `priorPid` is the pid on record
480
+ * before this call (the killed conductor's, under `--force`; a stale or
481
+ * absent one on a plain start), and a match on it is rejected rather than
482
+ * accepted as this start's proof.
483
+ *
484
+ * BB-450 review B15: the pid check alone is not enough. `last_seen` is
485
+ * written ONLY by the heartbeat resource, never by `registerConductor()`'s
486
+ * upsert (`ConductorDeriver.php:47`'s comment records this as deliberate —
487
+ * D12 does not make registration stamp it, because that would hide the
488
+ * failed-registration case D9/AC-13 exist to surface). So the instant the new
489
+ * process registers its OWN pid at boot (D8), the record already satisfies
490
+ * "online, new pid" while still carrying the KILLED conductor's in-window
491
+ * `last_seen` — `started` would print for a conductor that has only
492
+ * registered, never heartbeated. `priorLastSeen` is the value on record
493
+ * before this call, captured alongside `priorPid`; requiring `last_seen` to
494
+ * have moved past it, rather than merely being within the freshness window,
495
+ * is what tells "a heartbeat happened during this call" apart from "a
496
+ * heartbeat happened at some point before it, possibly by the process this
497
+ * call just killed".
498
+ */
499
+ async function waitForFreshHeartbeat(remote, id, priorPid, priorLastSeen, deps) {
500
+ const intervalMs = deps.startPollIntervalMs ?? START_POLL_INTERVAL_MS;
501
+ const attempts = deps.startPollAttempts ?? START_POLL_MAX_ATTEMPTS;
502
+ const sleep = deps.sleep ?? realSleep;
503
+ for (let attempt = 0; attempt < attempts; attempt++) {
504
+ const row = (await remote.listConductors('me')).find((c) => c.id === id);
505
+ if (row &&
506
+ classify(row) === 'online' &&
507
+ row.lastPid !== undefined &&
508
+ row.lastPid !== priorPid &&
509
+ (priorLastSeen === undefined || row.lastSeen > priorLastSeen))
510
+ return row;
511
+ await sleep(intervalMs);
512
+ }
513
+ return undefined;
514
+ }
515
+ /** Default grace period: 500ms between polls, 10 attempts ≈ 5s total. */
516
+ const FORCE_GRACE_INTERVAL_MS = 500;
517
+ const FORCE_GRACE_MAX_ATTEMPTS = 10;
518
+ /**
519
+ * Polls the LOCAL process — never the control plane — until the pid this
520
+ * `--force` just signalled is actually gone, or the grace-period budget runs
521
+ * out.
522
+ *
523
+ * BB-450 review (B4): this used to ask `remote.listConductors('me')` whether
524
+ * the conductor's DERIVED liveness had left `online`, which is the same
525
+ * category error D1 removed downstairs — proving a PROCESS fact (did the pid
526
+ * exit) from a CONTROL-PLANE value (has a heartbeat lapsed). A `SIGKILL`ed
527
+ * conductor writes no `offline` (nothing survives to write it), so its record
528
+ * keeps deriving `online` until the stale window or the lease lapses — both
529
+ * minutes away — while the grace budget is seconds. That mismatch made
530
+ * `--force` kill the wedged conductor and then refuse to restart it, on the
531
+ * one case `--force` exists for. `signalVerifiedProcess` already trusts
532
+ * `probe.cmdlineOf(pid) === undefined` as "gone" (`stale-pid`); this reuses
533
+ * exactly that authoritative local signal instead of a second, slower, and
534
+ * sometimes-wrong one.
535
+ */
536
+ async function waitForNotRunning(probe, pid, deps) {
537
+ const intervalMs = deps.forceGraceIntervalMs ?? FORCE_GRACE_INTERVAL_MS;
538
+ const attempts = deps.forceGraceAttempts ?? FORCE_GRACE_MAX_ATTEMPTS;
539
+ const sleep = deps.sleep ?? realSleep;
540
+ for (let attempt = 0; attempt < attempts; attempt++) {
541
+ if (probe.cmdlineOf(pid) === undefined)
542
+ return true;
543
+ await sleep(intervalMs);
544
+ }
545
+ return false;
546
+ }
547
+ /**
548
+ * `bakers oven start` reports exactly one of three outcomes (BB-450
549
+ * AC-12): **started**, naming the pid and `host_key`; **already running**,
550
+ * naming the live pid and its heartbeat age; **failed**, naming the reason.
551
+ * `failed` sets a non-zero exit status so a script can tell the three apart.
552
+ *
553
+ * `--force` (D10) turns "already running" into a restart: a verified
554
+ * `SIGTERM`, a bounded grace period, an escalation to `SIGKILL`, then the
555
+ * normal spawn+verify below. Any failure in that sequence — including the
556
+ * `no-pid` refusal `signalVerifiedProcess` already gives — is **failed** and
557
+ * never falls through to a second spawn (AC-14/AC-20's two-conductors risk).
558
+ */
559
+ async function cmdStartBody(deps, log, force = false) {
560
+ const config = await resolveConfig(deps, undefined, true);
561
+ const checkoutRoot = await checkoutRootOf(config);
562
+ const logger = loggerFor(checkoutRoot, log);
563
+ if (!(await ensureAuthenticated(config, logger)))
564
+ return;
565
+ const remote = await resolveRemote(deps, config);
566
+ const id = conductorIdOf(config);
567
+ const thisHostKey = hostKeyOf(config);
568
+ // Block on the DERIVED status, not the raw one. A crashed conductor keeps
569
+ // `status: online` on its record until the cron reaper notices the lapsed
570
+ // lease, and those are precisely the minutes in which someone wants to
571
+ // restart it. Only a conductor that is actually alive refuses a second start.
572
+ const existing = (await remote.listConductors('me')).find((c) => c.id === id);
573
+ // Captured before any stop/kill below: the pid this start must NOT report
574
+ // as its own (BB-450 review B8) — the killed conductor's under `--force`,
575
+ // a stale or absent one on a plain start.
576
+ const priorPid = existing?.lastPid;
577
+ // BB-450 review B15: the heartbeat this start must NOT accept as its own
578
+ // — a registration writes a new pid without touching `last_seen`, so this
579
+ // is what tells a genuine new heartbeat apart from the killed conductor's
580
+ // stale-but-still-in-window one.
581
+ const priorLastSeen = existing?.lastSeen;
582
+ if (existing && classify(existing) === 'online') {
583
+ if (!force) {
584
+ const age = heartbeatAgeSeconds(existing);
585
+ console.log(`conductor ${id} already running (pid ${existing.lastPid ?? 'unknown'}, ` +
586
+ `heartbeat ${age === undefined ? 'age unknown' : `${age}s old`})`);
587
+ return;
588
+ }
589
+ const probe = deps.procProbe ?? defaultProcProbe();
590
+ const stopOutcome = signalVerifiedProcess({
591
+ pid: existing.lastPid,
592
+ hostKey: existing.hostKey,
593
+ thisHostKey,
594
+ signal: 'SIGTERM',
595
+ probe,
596
+ });
597
+ if (!stopOutcome.ok) {
598
+ console.log(`failed to restart conductor ${id}: could not stop the running conductor — ${stopOutcome.message}`);
599
+ process.exitCode = 1;
600
+ return;
601
+ }
602
+ if (!(await waitForNotRunning(probe, stopOutcome.pid, deps))) {
603
+ const killOutcome = signalVerifiedProcess({
604
+ pid: existing.lastPid,
605
+ hostKey: existing.hostKey,
606
+ thisHostKey,
607
+ signal: 'SIGKILL',
608
+ probe,
609
+ });
610
+ if (!killOutcome.ok) {
611
+ console.log(`failed to restart conductor ${id}: still running after the grace period, and could not force-stop it — ${killOutcome.message}`);
612
+ process.exitCode = 1;
613
+ return;
614
+ }
615
+ if (!(await waitForNotRunning(probe, killOutcome.pid, deps))) {
616
+ console.log(`failed to restart conductor ${id}: sent SIGKILL but it is still reported running — refusing a second spawn`);
617
+ process.exitCode = 1;
618
+ return;
619
+ }
620
+ }
621
+ console.log(`stopped conductor ${id} for restart (--force)`);
622
+ }
623
+ // The conductor registers itself with the control plane on start(); there is
624
+ // no local file to write, and nothing left that a second list could add.
625
+ const handle = `bakers-conductor:${id}`;
626
+ const fgFlags = [
627
+ log.level ? `--log-level ${log.level}` : '',
628
+ log.sink ? `--log-sink ${log.sink}` : '',
629
+ ]
630
+ .filter(Boolean)
631
+ .join(' ');
632
+ // BB-477: a herdr pane is a child of the herdr server, not of this
633
+ // process, so it inherits the server's environment rather than the
634
+ // BB_CONDUCTOR this process resolved. Carry the selector on the command
635
+ // string itself, derived the same way the conductor reports its own
636
+ // `conductor_name`, so the pane cannot re-resolve to a different config.
637
+ const stem = stemForConfigFile(basename(config.config_path));
638
+ const selectorFlag = stem && stem !== 'conductor' ? `--conductor ${stem} ` : '';
639
+ const fgCmd = `BB_CONDUCTOR_LOG=file bakers ${selectorFlag}oven ${fgFlags} start --foreground --no-update-check`.replace(/\s+/g, ' ');
640
+ try {
641
+ await herdrHostOf(deps, config).spawn(handle, checkoutRoot, fgCmd);
642
+ logger.info({ id, handle }, 'started conductor via herdr');
643
+ }
644
+ catch (err) {
645
+ logger.error({ id, err: err.message }, 'could not start via herdr');
646
+ logger.warn({}, 'Hint: run `bakers oven start --foreground` under a service manager (systemd-user / docker) on hosts without herdr.');
647
+ console.log(`failed to start conductor ${id}: could not launch via herdr — ${err.message}`);
648
+ process.exitCode = 1;
649
+ return;
650
+ }
651
+ const started = await waitForFreshHeartbeat(remote, id, priorPid, priorLastSeen, deps);
652
+ if (!started) {
653
+ console.log(`failed to start conductor ${id}: spawned, but no fresh heartbeat arrived — ` +
654
+ 'the herdr pane closes when the conductor exits, so a missing bakers on PATH or a crash before logging leaves no file; ' +
655
+ 'check the conductor registered (a refused registration never heartbeats) and its own log');
656
+ process.exitCode = 1;
657
+ return;
658
+ }
659
+ console.log(`started conductor ${id} (pid ${started.lastPid ?? 'unknown'}, host ${started.hostKey ?? thisHostKey})`);
660
+ }
661
+ /**
662
+ * Other conductor records that name the SAME checkout as `own` (BB-450 D6,
663
+ * AC-19) — the exact ambiguity BB-281 recorded in the field: one checkout,
664
+ * two `bb_conductor` rows under different ids. Reported so the operator can
665
+ * see the stray; never signalled and never cleaned up here.
666
+ */
667
+ export function strayConductorsOf(rows, own) {
668
+ if (!own.workspaceRoot)
669
+ return [];
670
+ const target = resolve(own.workspaceRoot);
671
+ return rows.filter((c) => c.id !== own.id &&
672
+ c.workspaceRoot !== '' &&
673
+ resolve(c.workspaceRoot) === target);
674
+ }
675
+ /**
676
+ * Stops this checkout's conductor with a verified signal.
677
+ *
678
+ * Two things this deliberately no longer does. It does not kill a herdr tab:
679
+ * that killed the pane the conductor happened to be shown in, which is neither
680
+ * necessary (a signal reaches the process wherever it lives) nor sufficient (a
681
+ * conductor started by systemd or a bare --foreground has no tab). And it does
682
+ * not write `offline` — a heartbeat sets `online` unconditionally, so a status
683
+ * written from here is erased by the next tick unless it happens to land after
684
+ * the process died. The exiting process writes its own, which is correct by
685
+ * construction rather than by ordering luck.
686
+ *
687
+ * `--now` differs only in the signal. It runs through the same verification
688
+ * gate, because a hard kill aimed at a recycled pid is the most damaging
689
+ * version of the same mistake.
690
+ */
691
+ async function cmdStop(deps, now) {
692
+ const config = await resolveConfig(deps);
693
+ const remote = await resolveRemote(deps, config);
694
+ const id = conductorIdOf(config);
695
+ const thisHostKey = hostKeyOf(config);
696
+ const rows = await remote.listConductors('me');
697
+ const row = rows.find((c) => c.id === id);
698
+ if (!row) {
699
+ console.log(`no conductor ${id} on the control plane — nothing to stop (it may never have started)`);
700
+ return;
701
+ }
702
+ // BB-450 D6: report a stray record naming this checkout, never touch it.
703
+ for (const stray of strayConductorsOf(rows, row)) {
704
+ console.log(`note: conductor ${stray.id} also names this checkout (${stray.workspaceRoot}) — left untouched`);
705
+ }
706
+ const signal = now ? 'SIGKILL' : 'SIGTERM';
707
+ const outcome = signalVerifiedProcess({
708
+ pid: row.lastPid,
709
+ hostKey: row.hostKey,
710
+ thisHostKey,
711
+ signal,
712
+ ...(deps.procProbe ? { probe: deps.procProbe } : {}),
713
+ });
714
+ if (!outcome.ok) {
715
+ console.log(`could not stop conductor ${id}: ${outcome.message}`);
716
+ return;
717
+ }
718
+ console.log(now
719
+ ? `hard-stopped conductor ${id} (SIGKILL to pid ${outcome.pid}); a killed process writes no offline, so the status follows on lease expiry`
720
+ : `stopped conductor ${id} (SIGTERM to pid ${outcome.pid}); it writes its own offline as it exits`);
721
+ }
722
+ async function cmdLs(deps) {
723
+ const config = deps.config ?? (await tryConfig(deps));
724
+ const remote = await resolveRemote(deps, config ?? (await resolveConfig(deps)));
725
+ const rows = await buildLsRows(remote, config, undefined, deps.procProbe);
726
+ printRows(rows);
727
+ }
728
+ async function cmdStatus(deps) {
729
+ const config = await resolveConfig(deps);
730
+ const remote = await resolveRemote(deps, config);
731
+ const id = conductorIdOf(config);
732
+ const rows = await buildLsRows(remote, config, id, deps.procProbe);
733
+ printRows(rows);
734
+ }
735
+ /** ls may run without a config file; best-effort load. */
736
+ async function tryConfig(deps) {
737
+ if (deps.config) {
738
+ return deps.config;
739
+ }
740
+ try {
741
+ return await resolveConfig(deps);
742
+ }
743
+ catch {
744
+ return undefined;
745
+ }
746
+ }
747
+ /** Prompt for the developer Kürzel / user id on an interactive terminal. */
748
+ // --- the conductor command tree --------------------------------------------
749
+ /** Build the `oven` subcommand tree (lifecycle + registry). */
750
+ export function createConductorCommand(deps) {
751
+ const conductor = new Command('oven')
752
+ .description('node-agent lifecycle + local registry')
753
+ .option('--log-level <level>', 'log verbosity: debug | info | warn | error (overrides BB_LOG_LEVEL)')
754
+ .option('--log-sink <sink>', 'log sink: stdout | file | both (overrides BB_CONDUCTOR_LOG)')
755
+ .addHelpText('after', `
756
+ Logging (precedence: CLI flag > env var > default):
757
+ --log-level <level> debug | info (default) | warn | error.
758
+ Set debug to see per-poll ticks, claims, and idle cycles.
759
+ --log-sink <sink> stdout | file | both (file/both write <checkoutRoot>/.bakers/log.txt).
760
+ Default: both on a TTY, file otherwise (BB-237 — a run in a
761
+ herdr pane is on a TTY, and its diagnostics must outlive the pane).
762
+ BB_LOG_LEVEL env fallback for --log-level.
763
+ BB_CONDUCTOR_LOG env fallback for --log-sink.
764
+
765
+ Examples:
766
+ bakers oven --log-level debug start --foreground
767
+ bakers oven --log-level debug once
768
+ BB_LOG_LEVEL=debug bakers oven once`);
769
+ conductor
770
+ .command('start')
771
+ .description('start the conductor loop (herdr-hosted by default)')
772
+ .option('--foreground', 'run the loop in this process', false)
773
+ .option('--no-update-check', 'skip the daily CLI update check')
774
+ .option('--force', 'restart: verify-stop the already-running conductor (SIGTERM, a grace ' +
775
+ 'period, then SIGKILL) and start a new one. Fails rather than starting ' +
776
+ 'a second conductor if the running one cannot be verified or stopped.', false)
777
+ .action(async function (opts) {
778
+ await cmdStart(deps, logOptsOf(this), opts.updateCheck, opts.foreground ? cmdStartForeground : cmdStartBody, opts.force, opts.foreground);
779
+ });
780
+ conductor
781
+ .command('once')
782
+ .description('run one conductor cycle then exit')
783
+ .action(async function () {
784
+ await cmdPoll(deps, logOptsOf(this));
785
+ });
786
+ conductor
787
+ .command('clean')
788
+ .description('tear down herdr worktrees of finished-but-uncleaned tickets (recovers orphans a live tick missed: conductor was down at done, restarted, or reassigned)')
789
+ .action(async function () {
790
+ await cmdReap(deps, logOptsOf(this));
791
+ });
792
+ conductor
793
+ .command('stop')
794
+ .description('stop the conductor: graceful offline (default) or hard kill (--now)')
795
+ .option('--now', 'hard-kill via host and let the cron reaper mark it offline', false)
796
+ .action(async (opts) => {
797
+ await cmdStop(deps, opts.now);
798
+ });
799
+ conductor
800
+ .command('ls')
801
+ .description('list conductors on this machine + status')
802
+ .action(async () => {
803
+ await cmdLs(deps);
804
+ });
805
+ conductor
806
+ .command('status')
807
+ .description('status of the conductor for this checkout')
808
+ .action(async () => {
809
+ await cmdStatus(deps);
810
+ });
811
+ // `bakers conductor rm` is gone (BB-232). Its only job was deleting the local
812
+ // registry entry; with no registry there is nothing to remove, and deleting
813
+ // the control-plane record is explicitly not the model — a conductor that
814
+ // stops is offline, not absent.
815
+ return conductor;
816
+ }
817
+ /** The `oven` command plugin the host mounts (BB-201). BB-224
818
+ * (Finding 7): the `bakers deployment` batch helper is no longer registered here —
819
+ * it is its own command addon, `@bakers-build/addon-deployment`. */
820
+ const conductorCommandPlugin = {
821
+ kind: 'command',
822
+ name: 'oven',
823
+ describe: 'node-agent lifecycle + local registry',
824
+ register(program, host) {
825
+ program.addCommand(createConductorCommand({ host }));
826
+ },
827
+ };
828
+ export default conductorCommandPlugin;
829
+ /**
830
+ * Build a standalone program with the conductor command mounted — the test entry
831
+ * (mirrors what the host does for `bakers oven …`). `deps` inject fakes.
832
+ */
833
+ export function buildConductorProgram(deps = {}) {
834
+ const program = new Command();
835
+ program.name('bakers').description('bakers.build conductor + client CLI');
836
+ program.addCommand(createConductorCommand(deps));
837
+ return program;
838
+ }
839
+ /** Parse argv against the conductor program (test entry). */
840
+ export async function runConductorCli(argv, deps = {}) {
841
+ const program = buildConductorProgram(deps);
842
+ await program.parseAsync(argv, { from: 'user' });
843
+ }