@bakers-build/conductor 0.4.1-next.499 → 0.4.1-next.536

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.
@@ -9,6 +9,7 @@ import { composeConductorConfig, loadConductorConfig } from '../config.js';
9
9
  import { Conductor } from '../core/conductor.js';
10
10
  import { hostKeyOf } from '../core/host-key.js';
11
11
  import { selectAgents, selectExecutor, selectRemote, selectWorkspace, } from '../plugins/plugins.js';
12
+ import { sandboxFailures } from '../plugins/sandbox-preflight.js';
12
13
  /** Default host bases for connection/plugin resolution when none is injected. */
13
14
  function defaultHost() {
14
15
  return { resolveBases: [import.meta.url, `${process.cwd()}/`] };
@@ -202,6 +203,16 @@ async function cmdPoll(deps, log = {}) {
202
203
  await conductor.start();
203
204
  await conductor.tick();
204
205
  }
206
+ /** `bakers oven check`: whether each enabled agent can start its sandbox. */
207
+ async function cmdCheck(deps) {
208
+ const config = await resolveConfig(deps);
209
+ for (const agent of deps.agents ?? (await selectAgents(config))) {
210
+ const [failure] = await sandboxFailures([agent]);
211
+ console.log(failure ?? `${agent.alias ?? agent.id}: sandbox ok`);
212
+ if (failure)
213
+ process.exitCode = 1;
214
+ }
215
+ }
205
216
  async function cmdReap(deps, log = {}) {
206
217
  const config = await resolveConfig(deps, undefined, true);
207
218
  const checkoutRoot = await checkoutRootOf(config);
@@ -562,6 +573,14 @@ async function cmdStartBody(deps, log, force = false) {
562
573
  const logger = loggerFor(checkoutRoot, log);
563
574
  if (!(await ensureAuthenticated(config, logger)))
564
575
  return;
576
+ // The loop withholds claims while this fails; say so before it starts.
577
+ // Best-effort: an agent config the loop cannot load fails there, as before.
578
+ try {
579
+ for (const line of await sandboxFailures(deps.agents ?? (await selectAgents(config)))) {
580
+ console.warn(`warning: ${line}`);
581
+ }
582
+ }
583
+ catch { }
565
584
  const remote = await resolveRemote(deps, config);
566
585
  const id = conductorIdOf(config);
567
586
  const thisHostKey = hostKeyOf(config);
@@ -783,6 +802,12 @@ Examples:
783
802
  .action(async function () {
784
803
  await cmdPoll(deps, logOptsOf(this));
785
804
  });
805
+ conductor
806
+ .command('check')
807
+ .description('check that every enabled agent can start its sandbox on this machine (exit 1 if one cannot)')
808
+ .action(async () => {
809
+ await cmdCheck(deps);
810
+ });
786
811
  conductor
787
812
  .command('clean')
788
813
  .description('tear down herdr worktrees of finished-but-uncleaned tickets (recovers orphans a live tick missed: conductor was down at done, restarted, or reassigned)')
@@ -9,5 +9,6 @@ export { resolveAgent, selectAgent, selectAgents, selectExecutor, selectRemote,
9
9
  export { type ConductorAddonEntry, type ConductorContributions, narrowConductorContributions, type Preset, } from './plugins/preset.js';
10
10
  export type { HerdrHost } from './plugins/process-host.js';
11
11
  export type { ActiveRun, AddonDefinitionV2, AddonRegistration, BbRemote, ClaimedRun, ClaimOptions, ClaimWithheld, ConductorRegistration, ConductorStatus, FinalizableRun, FrozenTicket, InboxMessage, InboxSnapshot, RunMetrics, RunWriteAttributes, Ticket, TicketMetric, UncleanTicket, } from './plugins/remote.js';
12
+ export { installCommand, lastLine, type SandboxHost, type SandboxPreflight, sandboxFailures, systemSandboxHost, } from './plugins/sandbox-preflight.js';
12
13
  export type { BbWorkspace, EnsuredWorkspace, } from './plugins/workspace.js';
13
14
  export type { ConductorEngineConfig, ConductorFileConfig, ConductorSettings, } from './types.js';
@@ -17,3 +17,4 @@ export { detectKnownOverrides, resolveAgentCliOptions, resolveSecurityOptions, S
17
17
  export { loadInbox, validateRequestPayload, } from './plugins/message.js';
18
18
  export { resolveAgent, selectAgent, selectAgents, selectExecutor, selectRemote, selectWorkspace, } from './plugins/plugins.js';
19
19
  export { narrowConductorContributions, } from './plugins/preset.js';
20
+ export { installCommand, lastLine, sandboxFailures, systemSandboxHost, } from './plugins/sandbox-preflight.js';
@@ -3,6 +3,7 @@ import { basename, dirname, join } from 'node:path';
3
3
  import { shellQuote, stemForConfigFile, } from '@bakers-build/core';
4
4
  import { DEFAULT_ENV_DENY, matchesEnvDeny, } from '../plugins/agent.js';
5
5
  import { resolveAgent } from '../plugins/plugins.js';
6
+ import { sandboxFailures } from '../plugins/sandbox-preflight.js';
6
7
  import { ActionQueue } from './action-queue.js';
7
8
  import { attemptFamily } from './attempt.js';
8
9
  import { hostKeyOf } from './host-key.js';
@@ -278,6 +279,9 @@ export class Conductor {
278
279
  const log = this.inboxAddons.empty ? this.logger.warn : this.logger.error;
279
280
  log.call(this.logger, { conductorId: this.id, err: String(err) }, 'addon registration failed');
280
281
  }
282
+ for (const line of await sandboxFailures(this.agents)) {
283
+ this.logger.error({ conductorId: this.id }, line);
284
+ }
281
285
  }
282
286
  /** One tick that also waits for its actions: `bakers oven once`, tests. */
283
287
  async tick() {
@@ -335,9 +339,15 @@ export class Conductor {
335
339
  await this.step('reap', () => this.enqueueReap());
336
340
  await this.step('freeze', () => this.observeFreezes());
337
341
  await runInbox(this.remote, this.inboxAddons, this.id, this.logger, this.queue);
342
+ // A run that cannot start its sandbox would only fail and count against
343
+ // the retry cap; claim nothing until every enabled agent can start.
344
+ const unavailable = machineLoad < maxParallel ? await sandboxFailures(this.agents) : [];
345
+ for (const line of unavailable) {
346
+ this.logger.warn({ conductorId: this.id }, `claims withheld: ${line}`);
347
+ }
338
348
  let claimed = 0;
339
- let withheld = false;
340
- while (machineLoad < maxParallel) {
349
+ let withheld = unavailable.length > 0;
350
+ while (!withheld && machineLoad < maxParallel) {
341
351
  const run = await this.remote.claimNext({
342
352
  leaseSeconds: this.config.lease_seconds,
343
353
  conductorId: this.id,
@@ -441,7 +451,7 @@ export class Conductor {
441
451
  // server change is needed and no empty log is written.
442
452
  const uploadedLog = (this.config.capture_logs ?? true) ? captured.log : '';
443
453
  await this.remote.finalizeRun(r.runUuid, uploadedLog, captured.metrics);
444
- this.logger.info({ run: r.runUuid, footprint: captured.metrics }, 'run finalised');
454
+ this.logger.info({ run: r.runUuid, state: r.state, footprint: captured.metrics }, 'run finalised');
445
455
  }
446
456
  catch (err) {
447
457
  this.logger.warn({ run: r.runUuid, err: String(err) }, 'run finalise failed');
@@ -1041,6 +1051,10 @@ export class Conductor {
1041
1051
  defaultAgent: this.config.default_agent,
1042
1052
  warn: (message) => this.logger.warn({ ticket: t.identifier }, message),
1043
1053
  });
1054
+ // The tick's gate may be stale by now; spawn nothing that cannot start.
1055
+ const [unavailable] = await sandboxFailures([chosen]);
1056
+ if (unavailable)
1057
+ throw new Error(unavailable);
1044
1058
  const baseRef = t.baseBranch ? `origin/${t.baseBranch}` : undefined;
1045
1059
  // Resolve the run env once (per-ticket env_vars + core BB_* vars) and
1046
1060
  // inject it into BOTH the executor-owned lifecycle hooks AND the agent run
@@ -1,3 +1,4 @@
1
+ import type { SandboxHost, SandboxPreflight } from './sandbox-preflight.js';
1
2
  /**
2
3
  * Per-agent effort footprint parsed from that agent's run transcript
3
4
  * (BB-132). The metrics are transcript-derived and therefore
@@ -136,4 +137,11 @@ export interface BbAgent {
136
137
  * {@link emptyAgentFootprint}.
137
138
  */
138
139
  parseFootprint(log: string): AgentFootprint;
140
+ /**
141
+ * Whether this agent's OS sandbox can start on this machine. The conductor
142
+ * withholds claims and refuses to spawn while it reports a cause. Returns
143
+ * ok without probing when the agent does not run sandboxed. Optional: an
144
+ * agent without it is never checked.
145
+ */
146
+ sandboxPreflight?(host?: SandboxHost): Promise<SandboxPreflight>;
139
147
  }
@@ -122,6 +122,8 @@ export interface ConductorStatus {
122
122
  }
123
123
  export interface FinalizableRun {
124
124
  runUuid: string;
125
+ /** The run's terminal state; a `cancelled` run is stopped like a `done` one. */
126
+ state: 'done' | 'cancelled';
125
127
  /** Numeric run id (the `#<runId>` tab-label token); scopes the run's tab-close at finalise (BB-183). */
126
128
  runId: number;
127
129
  /**
@@ -0,0 +1,35 @@
1
+ /** Whether an agent's sandbox can start on this machine; see {@link BbAgent.sandboxPreflight}. */
2
+ export type SandboxPreflight = {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ cause: string;
7
+ fix: string;
8
+ };
9
+ /** The machine a preflight inspects; injected by tests. */
10
+ export interface SandboxHost {
11
+ platform: NodeJS.Platform;
12
+ /** Whether `name` is an executable on `PATH`. */
13
+ onPath(name: string): boolean;
14
+ /** Runs a probe command; never throws. */
15
+ run(cmd: string, args: string[]): Promise<{
16
+ ok: boolean;
17
+ stderr: string;
18
+ }>;
19
+ }
20
+ export declare function systemSandboxHost(env?: NodeJS.ProcessEnv): SandboxHost;
21
+ /** The last non-empty line of a probe's stderr: the error, after any warnings. */
22
+ export declare function lastLine(text: string): string;
23
+ /** The platform's install command for `packages`, from the package manager on `PATH`. */
24
+ export declare function installCommand(host: SandboxHost, packages: string[]): string;
25
+ /**
26
+ * One `sandbox unavailable: …` line per agent whose sandbox cannot start;
27
+ * empty when every agent can start (or declares no preflight).
28
+ */
29
+ export declare function sandboxFailures(agents: readonly {
30
+ id: string;
31
+ alias?: string;
32
+ agent: {
33
+ sandboxPreflight?(): Promise<SandboxPreflight>;
34
+ };
35
+ }[]): Promise<string[]>;
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
+ export function systemSandboxHost(env = process.env) {
5
+ return {
6
+ platform: process.platform,
7
+ onPath: (name) => (env.PATH ?? '').split(delimiter).some((dir) => {
8
+ try {
9
+ accessSync(join(dir, name), constants.X_OK);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }),
16
+ run: (cmd, args) => new Promise((resolve) => {
17
+ execFile(cmd, args, { env, timeout: 15_000 }, (err, _out, stderr) => {
18
+ resolve({ ok: !err, stderr: String(stderr || err?.message || '') });
19
+ });
20
+ }),
21
+ };
22
+ }
23
+ /** The last non-empty line of a probe's stderr: the error, after any warnings. */
24
+ export function lastLine(text) {
25
+ return (text
26
+ .split('\n')
27
+ .map((line) => line.trim())
28
+ .filter(Boolean)
29
+ .pop() ?? 'no error output');
30
+ }
31
+ /** The platform's install command for `packages`, from the package manager on `PATH`. */
32
+ export function installCommand(host, packages) {
33
+ const managers = [
34
+ ['apt-get', 'sudo apt-get install -y'],
35
+ ['dnf', 'sudo dnf install -y'],
36
+ ['pacman', 'sudo pacman -S --needed'],
37
+ ];
38
+ const found = managers.find(([bin]) => host.onPath(bin));
39
+ return found
40
+ ? `${found[1]} ${packages.join(' ')}`
41
+ : `your package manager (packages: ${packages.join(', ')})`;
42
+ }
43
+ /**
44
+ * One `sandbox unavailable: …` line per agent whose sandbox cannot start;
45
+ * empty when every agent can start (or declares no preflight).
46
+ */
47
+ export async function sandboxFailures(agents) {
48
+ const lines = [];
49
+ for (const { id, alias, agent } of agents) {
50
+ const result = (await agent.sandboxPreflight?.()) ?? { ok: true };
51
+ if (!result.ok) {
52
+ lines.push(`sandbox unavailable: ${alias ?? id}: ${result.cause} — ${result.fix} (see https://bakers.build/docs/advanced/run-security#sandbox-prerequisites)`);
53
+ }
54
+ }
55
+ return [...new Set(lines)]; // unaliased entries of one harness report the same line
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakers-build/conductor",
3
- "version": "0.4.1-next.499",
3
+ "version": "0.4.1-next.536",
4
4
  "description": "bakers.build conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,8 +32,8 @@
32
32
  "dependencies": {
33
33
  "@dropsh/plugin-jsonapi-schema": "^0.8.2",
34
34
  "@dropsh/plugin-oauth2": "^0.8.2",
35
- "@bakers-build/core": "^0.4.1-next.499",
36
- "@bakers-build/message": "^0.4.1-next.499",
35
+ "@bakers-build/core": "^0.4.1-next.536",
36
+ "@bakers-build/message": "^0.4.1-next.536",
37
37
  "commander": "^12.1.0",
38
38
  "dropsh": "^0.8.2"
39
39
  }