@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,13 @@
1
+ export { AmbiguousProjectNameError, type ResolvedBbProjectContext, resolveBbProjectContext, } from './bakers-project-context.js';
2
+ export { type AgentFootprint, type AgentRunIdentity, type BbAgent, emptyAgentFootprint, } from './plugins/agent.js';
3
+ export { detectKnownOverrides, type KnownOptionSpec, type KnownOverrides, type KnownScalarKind, resolveAgentCliOptions, validateAgentArgs, validateOptionalBoolean, validateOptionalString, } from './plugins/agent-args.js';
4
+ export type { BbExecutor, ExecutorCapabilities, HookContext, HookListener, HookListenerContext, HookListenerFn, HookName, HookResult, SpawnedSession, SpawnRunInput, WorktreeTeardown, } from './plugins/executor.js';
5
+ export type { DiscoveredInbox, InboxMessageHandler, InboxMessageRequest, InboxMessages, LoadedInbox, } from './plugins/message.js';
6
+ export { loadInbox, validateRequestPayload, } from './plugins/message.js';
7
+ export type { AgentCandidate, AgentPlugin, ExecutorDeps, ExecutorPlugin, RemotePlugin, ResolvedAgent, WorkspacePlugin, } from './plugins/plugins.js';
8
+ export { resolveAgent, selectAgent, selectAgents, selectExecutor, selectRemote, selectWorkspace, } from './plugins/plugins.js';
9
+ export { type ConductorAddonEntry, type ConductorContributions, narrowConductorContributions, type Preset, } from './plugins/preset.js';
10
+ export type { HerdrHost } from './plugins/process-host.js';
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 type { BbWorkspace, EnsuredWorkspace, } from './plugins/workspace.js';
13
+ export type { ConductorEngineConfig, ConductorFileConfig, ConductorSettings, } from './types.js';
@@ -0,0 +1,19 @@
1
+ // BB-224 (Finding 6): the CONDUCTOR-SURFACE CONTRACT, exposed as the
2
+ // runtime-light subpath `@bakers-build/conductor/contract`.
3
+ //
4
+ // This is the module a conductor-surface addon (`addons/remote-drupal`,
5
+ // `addons/workspace-git`, `addons/herdr`, `addons/claude`, …) imports. It carries
6
+ // ONLY the surface interfaces, the config contract, the preset view, and the two
7
+ // tiny runtime helpers (`emptyAgentFootprint`, the `select*` slot selectors) — it
8
+ // imports NO engine, NO commander, NO dropsh runtime. Importing it therefore
9
+ // costs an addon nothing at boot, unlike the package main (`@bakers-build/conductor`),
10
+ // which pulls the whole run engine + the `conductor` command plugin.
11
+ //
12
+ // The package main re-exports everything here too, so `import type { BbRemote }
13
+ // from '@bakers-build/conductor'` keeps working for type-only consumers.
14
+ export { AmbiguousProjectNameError, resolveBbProjectContext, } from './bakers-project-context.js';
15
+ export { emptyAgentFootprint, } from './plugins/agent.js';
16
+ export { detectKnownOverrides, resolveAgentCliOptions, validateAgentArgs, validateOptionalBoolean, validateOptionalString, } from './plugins/agent-args.js';
17
+ export { loadInbox, validateRequestPayload, } from './plugins/message.js';
18
+ export { resolveAgent, selectAgent, selectAgents, selectExecutor, selectRemote, selectWorkspace, } from './plugins/plugins.js';
19
+ export { narrowConductorContributions, } from './plugins/preset.js';
@@ -0,0 +1,31 @@
1
+ import type { ConductorLogger } from '@bakers-build/core';
2
+ export interface Action {
3
+ /** An action whose key is queued or running is not enqueued again. */
4
+ key: string;
5
+ /** Actions of one lane run one after another; lanes run in parallel. */
6
+ lane: string;
7
+ run: () => Promise<void>;
8
+ }
9
+ export declare class ActionQueue {
10
+ private readonly logger;
11
+ private readonly now;
12
+ private readonly keys;
13
+ private readonly lanes;
14
+ /** Running action key → start (ms). */
15
+ private readonly running;
16
+ constructor(logger: ConductorLogger, now?: () => number);
17
+ /** Queued or running actions. */
18
+ get size(): number;
19
+ /** Starts the action after its lane's last one; false when its key is pending. */
20
+ enqueue(action: Action): boolean;
21
+ /** Resolves once every queued and running action has settled. */
22
+ drain(): Promise<void>;
23
+ /**
24
+ * Warns once per call about each action running longer than `limitMs`: only
25
+ * addon handlers time out, so a hung hook would otherwise block its ticket's
26
+ * lane without a trace while the heartbeat keeps the conductor healthy.
27
+ */
28
+ warnOverdue(limitMs: number): void;
29
+ /** Never rejects: a lane's chain must survive a failing action. */
30
+ private execute;
31
+ }
@@ -0,0 +1,69 @@
1
+ // The conductor's per-item work: the tick only reads what is due and enqueues
2
+ // one action per item. Every action starts at once, fails and logs on its own;
3
+ // actions of one lane (a ticket) run in order, lanes run in parallel.
4
+ export class ActionQueue {
5
+ logger;
6
+ now;
7
+ keys = new Set();
8
+ lanes = new Map();
9
+ /** Running action key → start (ms). */
10
+ running = new Map();
11
+ constructor(logger, now = Date.now) {
12
+ this.logger = logger;
13
+ this.now = now;
14
+ }
15
+ /** Queued or running actions. */
16
+ get size() {
17
+ return this.keys.size;
18
+ }
19
+ /** Starts the action after its lane's last one; false when its key is pending. */
20
+ enqueue(action) {
21
+ if (this.keys.has(action.key))
22
+ return false;
23
+ this.keys.add(action.key);
24
+ const tail = (this.lanes.get(action.lane) ?? Promise.resolve()).then(() => this.execute(action));
25
+ this.lanes.set(action.lane, tail);
26
+ void tail.then(() => {
27
+ if (this.lanes.get(action.lane) === tail)
28
+ this.lanes.delete(action.lane);
29
+ });
30
+ return true;
31
+ }
32
+ /** Resolves once every queued and running action has settled. */
33
+ async drain() {
34
+ while (this.lanes.size > 0)
35
+ await Promise.all(this.lanes.values());
36
+ }
37
+ /**
38
+ * Warns once per call about each action running longer than `limitMs`: only
39
+ * addon handlers time out, so a hung hook would otherwise block its ticket's
40
+ * lane without a trace while the heartbeat keeps the conductor healthy.
41
+ */
42
+ warnOverdue(limitMs) {
43
+ const now = this.now();
44
+ for (const [key, started] of this.running) {
45
+ const ms = now - started;
46
+ if (ms > limitMs) {
47
+ this.logger.warn({ action: key, ms }, `action ${key} running for ${ms}ms`);
48
+ }
49
+ }
50
+ }
51
+ /** Never rejects: a lane's chain must survive a failing action. */
52
+ async execute(action) {
53
+ const started = this.now();
54
+ this.running.set(action.key, started);
55
+ try {
56
+ await action.run();
57
+ const ms = this.now() - started;
58
+ this.logger.debug({ action: action.key, ms }, `action ${action.key} done in ${ms}ms`);
59
+ }
60
+ catch (err) {
61
+ const ms = this.now() - started;
62
+ this.logger.warn({ action: action.key, ms, err: String(err) }, `action ${action.key} failed after ${ms}ms`);
63
+ }
64
+ finally {
65
+ this.running.delete(action.key);
66
+ this.keys.delete(action.key);
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Deterministic per-attempt session id family for a run (BB-473 review
3
+ * round 4, Critical 2). A re-dispatch of the same run needs its own
4
+ * collision-free session id — `claude`/`grok --session-id` refuse to start
5
+ * when the id already names a transcript from a prior attempt — but the
6
+ * previous fix (a `startedAt` time window) selects by *when*, not by *which
7
+ * attempt*, and a worktree-keyed transcript directory can hold a sibling
8
+ * run's session newer than that bound. An attempt family gives finalise an
9
+ * IDENTITY to select by instead: attempt 0 is the run's own UUID (unchanged,
10
+ * so every caller that only knows a single `sessionId` keeps working);
11
+ * attempt n>0 is a stable UUID-shaped id derived from `${runUuid}:${n}`, so
12
+ * both dispatch (which candidate to pin) and finalise (which candidate's
13
+ * file exists) derive the SAME sequence from nothing but the run's own UUID
14
+ * — no new persisted field, no schema change.
15
+ */
16
+ export declare function attemptSessionId(runUuid: string, attempt: number): string;
17
+ /** Bound on how many re-dispatches of one run this family covers. */
18
+ export declare const MAX_ATTEMPTS = 25;
19
+ /**
20
+ * The full candidate sequence for a run, attempt 0 first. Both the dispatch
21
+ * pin-selection loop and finalise's selection loop derive this from nothing
22
+ * but the run's own UUID, so the two sides never need to agree through any
23
+ * channel but the UUID itself.
24
+ */
25
+ export declare function attemptFamily(runUuid: string, max?: number): string[];
@@ -0,0 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Deterministic per-attempt session id family for a run (BB-473 review
4
+ * round 4, Critical 2). A re-dispatch of the same run needs its own
5
+ * collision-free session id — `claude`/`grok --session-id` refuse to start
6
+ * when the id already names a transcript from a prior attempt — but the
7
+ * previous fix (a `startedAt` time window) selects by *when*, not by *which
8
+ * attempt*, and a worktree-keyed transcript directory can hold a sibling
9
+ * run's session newer than that bound. An attempt family gives finalise an
10
+ * IDENTITY to select by instead: attempt 0 is the run's own UUID (unchanged,
11
+ * so every caller that only knows a single `sessionId` keeps working);
12
+ * attempt n>0 is a stable UUID-shaped id derived from `${runUuid}:${n}`, so
13
+ * both dispatch (which candidate to pin) and finalise (which candidate's
14
+ * file exists) derive the SAME sequence from nothing but the run's own UUID
15
+ * — no new persisted field, no schema change.
16
+ */
17
+ export function attemptSessionId(runUuid, attempt) {
18
+ if (attempt <= 0) {
19
+ return runUuid;
20
+ }
21
+ const hex = createHash('sha256')
22
+ .update(`${runUuid}:${attempt}`)
23
+ .digest('hex')
24
+ .slice(0, 32)
25
+ .split('');
26
+ hex[12] = '5'; // version nibble — cosmetic, just keeps the shape UUID-like.
27
+ hex[16] = ((Number.parseInt(hex[16] ?? '0', 16) & 0x3) | 0x8).toString(16); // variant nibble.
28
+ const h = hex.join('');
29
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
30
+ }
31
+ /** Bound on how many re-dispatches of one run this family covers. */
32
+ export const MAX_ATTEMPTS = 25;
33
+ /**
34
+ * The full candidate sequence for a run, attempt 0 first. Both the dispatch
35
+ * pin-selection loop and finalise's selection loop derive this from nothing
36
+ * but the run's own UUID, so the two sides never need to agree through any
37
+ * channel but the UUID itself.
38
+ */
39
+ export function attemptFamily(runUuid, max = MAX_ATTEMPTS) {
40
+ return Array.from({ length: max }, (_, i) => attemptSessionId(runUuid, i));
41
+ }
@@ -0,0 +1,236 @@
1
+ import { type ConductorLogger } from '@bakers-build/core';
2
+ import type { BbExecutor } from '../plugins/executor.js';
3
+ import { type ResolvedAgent } from '../plugins/plugins.js';
4
+ import type { BbRemote } from '../plugins/remote.js';
5
+ import type { BbWorkspace } from '../plugins/workspace.js';
6
+ import type { ConductorFileConfig } from '../types.js';
7
+ /**
8
+ * Resolve the environment a run executes in (BB-99): parse the ticket's
9
+ * effective env_vars, drop any reserved key (loud warn — key NAME only, never
10
+ * the value), then overlay the conductor's core vars so they always win.
11
+ *
12
+ * Values are potentially secret (e.g. a DB DSN): this function logs key names
13
+ * only, never a value. The returned map is injected into BOTH the agent run env
14
+ * and every workspace hook.
15
+ */
16
+ export declare function resolveRunEnv(effectiveEnvVars: string | undefined, core: Record<string, string>, logger: ConductorLogger): Record<string, string>;
17
+ export declare class Conductor {
18
+ private readonly config;
19
+ private readonly remote;
20
+ private readonly executor;
21
+ private readonly workspace;
22
+ private readonly agents;
23
+ private readonly logger;
24
+ private readonly checkoutRoot;
25
+ private readonly frozenStateFile?;
26
+ private uuid;
27
+ /**
28
+ * Frozen tickets seen on the last poll, by uuid, with the worktree their
29
+ * hook ran in. Mirrored to `frozenStateFile` when one is given, so an
30
+ * unfreeze while the conductor is down still gets its `on_resume` and a
31
+ * restart does not repeat `on_freeze`; loaded on the first observation.
32
+ */
33
+ private frozenSeen;
34
+ /** The addons with contract-2 inbox messages this conductor runs. */
35
+ private readonly inboxAddons;
36
+ /** The per-item work each tick enqueues. */
37
+ private readonly queue;
38
+ /** Serializes git worktree add/remove: parallel ones fight over git locks. */
39
+ private gitLock;
40
+ constructor(config: ConductorFileConfig, remote: BbRemote, executor: BbExecutor, workspace: BbWorkspace, agents: ResolvedAgent[], logger: ConductorLogger, checkoutRoot?: string, frozenStateFile?: string | undefined);
41
+ private serialGit;
42
+ /**
43
+ * `conductor_id` is required and the loader throws without it (BB-353), so
44
+ * there is no directory-derived fallback here (BB-450 D5) — that fallback
45
+ * was the mechanism by which one checkout came to be registered under two
46
+ * different ids.
47
+ */
48
+ get id(): string;
49
+ /**
50
+ * Host stem for bb_machine.host_key. Prefer config.host_key from
51
+ * MachineContext.machine_id; fall back to a best-effort parse of the composed
52
+ * conductor_id only when the loader could not supply the stem.
53
+ */
54
+ private get hostKey();
55
+ /**
56
+ * Whether this instance is the serve-loop process itself (BB-232).
57
+ *
58
+ * Off by default so that only a caller which genuinely owns the process can
59
+ * report a pid — see {@link claimServeProcess}.
60
+ */
61
+ private ownsServeProcess;
62
+ /**
63
+ * Declares that this instance runs *as* the conductor process, so its
64
+ * registrations may carry the process identity a cockpit needs to stop it.
65
+ *
66
+ * Only `cmdStartForeground` calls this. `cmdPoll` and `cmdReap` also build a
67
+ * Conductor and call start() + tick(), then exit; if they reported a pid,
68
+ * every `bakers oven once` would overwrite the running conductor's pid
69
+ * with its own short-lived one and the next stop would signal a dead — or
70
+ * recycled — process. Making the report opt-in means those two commands
71
+ * cannot do so even by accident.
72
+ */
73
+ claimServeProcess(): void;
74
+ /** This conductor's registration payload, built from config. */
75
+ private registration;
76
+ /**
77
+ * The stem of the config file this conductor was loaded from.
78
+ *
79
+ * Derived through core's own mapping rather than by slicing the filename
80
+ * here, so the CLI and the conductor cannot disagree about which files count
81
+ * as a conductor config.
82
+ */
83
+ private get conductorName();
84
+ start(): Promise<void>;
85
+ /** One tick that also waits for its actions: `bakers oven once`, tests. */
86
+ tick(): Promise<void>;
87
+ /** A failing list query must not stop the other steps or the claims. */
88
+ private step;
89
+ /**
90
+ * The tick body: heartbeat, the list queries and the claim loop. Every
91
+ * per-item job becomes an action on the queue, so no slow item can delay
92
+ * the heartbeat or the claims.
93
+ */
94
+ produce(): Promise<void>;
95
+ /**
96
+ * One-shot finalisation: for each of this conductor's runs that is state in
97
+ * (done, cancelled) but not yet closed, read the agent transcript from its
98
+ * worktree and write it as the final log, then mark the run closed. Keyed off
99
+ * the durable terminal-state query — no in-memory state, so a restarted
100
+ * conductor still finalises any unclosed run on its next tick.
101
+ */
102
+ private enqueueFinalize;
103
+ /** Finalises one run; a failure is logged and retried next tick. */
104
+ private finalizeOne;
105
+ /** Read the run's transcript and parse its footprint for the close write. */
106
+ private captureRun;
107
+ /**
108
+ * Close one run's tab. Run-scoped (BB-183): only the `#<runId>` tab, never
109
+ * a sibling run's. Best-effort: a close failure never mislabels the run as
110
+ * "finalise failed" or replaces a dispatch error.
111
+ */
112
+ private closeRunTab;
113
+ /**
114
+ * `after_run` for an ended run (BB-599), rebuilt from durable state: the
115
+ * run record names its ticket and start state, and the env is resolved from
116
+ * the CURRENT ticket — so a restarted conductor stops the same sidecars.
117
+ * Like `after_done`, it needs the worktree directory to exist.
118
+ */
119
+ private runFinalizeHook;
120
+ /**
121
+ * Rebuild a listener context from the ticket's current record, or
122
+ * `undefined` (logged) when it cannot be read — the caller still runs the
123
+ * project shell hook, it only loses the addon listeners. Built even with no
124
+ * listeners: the shell `after_run` takes its env from it.
125
+ */
126
+ private listenerContext;
127
+ private get listeners();
128
+ /**
129
+ * Run one lifecycle slot (BB-599): every addon listener in accumulator
130
+ * order, awaited, then the project shell hook through the executor. Never
131
+ * throws. A failing listener is logged by slot, listener, ticket and run —
132
+ * never with its message or the env — and reported in the returned result
133
+ * the same way a failing shell command is, so the rest of the slot, the
134
+ * shell hook and dispatch all continue.
135
+ */
136
+ private runSlot;
137
+ /**
138
+ * Undo a listener's env change the conductor does not accept: a reserved
139
+ * key keeps the conductor's value, and an invalid name or a non-string value
140
+ * never reaches a spawn. Key names only are logged.
141
+ */
142
+ private guardEnv;
143
+ /**
144
+ * Run `on_freeze` once for each newly frozen ticket and `on_resume` once
145
+ * for each that left the frozen list, in the ticket's worktree on this
146
+ * machine. A ticket without a worktree here gets no hook. Never throws: an
147
+ * unreadable list keeps the previous observation for the next poll.
148
+ */
149
+ private observeFreezes;
150
+ private loadFrozenSeen;
151
+ private saveFrozenSeen;
152
+ private runFreezeSlot;
153
+ /**
154
+ * Reconcile closed tickets against their herdr worktrees and tear down any
155
+ * whose workspace still lingers (BB-89). Driven by the durable `cleaned_up`
156
+ * flag via {@link BbRemote.fetchUncleanedTickets}: every ticket the server
157
+ * has already closed but not yet cleaned. The SAME reconciliation runs on
158
+ * every tick AND standalone as `bakers oven clean`, and the query is
159
+ * scoped to THIS conductor (BB-121) — so it catches the orphans a
160
+ * live-tick-only path structurally cannot (the conductor was down when the
161
+ * ticket closed, or crashed mid-cleanup) for its OWN tickets. A foreign
162
+ * (non-bakers) worktree has no bakers ticket, so it is never on the work list; a
163
+ * ticket assigned to another conductor is filtered out at the query — both are
164
+ * left untouched with no host enumeration.
165
+ *
166
+ * The conductor is structurally UNABLE to close a ticket (BB-451): closing
167
+ * is a server-side projection of terminality written by
168
+ * `BbTicket::preSave()` on any save of a terminal, unclosed ticket, so it
169
+ * holds for every write path and cannot be withheld by a teardown hiccup —
170
+ * the reap reasons about `cleaned_up` alone. `cleaned_up` is withheld unless
171
+ * the teardown is ACCOUNTED FOR (BB-141 RC-2), which `removeWorktree`
172
+ * reports as one of three outcomes (BB-293):
173
+ * - `removed` — gone because this host tore it down → flag cleaned;
174
+ * - `already_absent` — nothing was there to tear down. A FINISHED teardown,
175
+ * not a failure: it is recorded in this log AND on the run record, then
176
+ * flagged cleaned. Recording it is the point — swallowing the absence and
177
+ * leaving `cleaned_up=0` is what made every later reap repeat the same
178
+ * failing teardown;
179
+ * - `unverified` — it could not be resolved and may still be on disk (e.g.
180
+ * its `worktree_path` was never persisted). Like a THROW, it logs loudly
181
+ * and leaves `cleaned_up=0` so the next reconciliation retries.
182
+ * Flagging cleaned on an unverified teardown was the root cause of the ~28
183
+ * never-touched leftovers, because the ticket then dropped off the
184
+ * (cleaned_up=0) work list forever.
185
+ * Re-running on an already-cleaned ticket is a no-op — it is off the list
186
+ * (idempotent). Each ticket is isolated in a try/catch so one failure never
187
+ * aborts the rest.
188
+ *
189
+ * The `after_done` hook runs through the executor (BB-84), which owns the
190
+ * best-effort policy — it logs+swallows a hook failure and never throws, so
191
+ * the core needs no per-call-site try/catch around it.
192
+ */
193
+ reap(): Promise<void>;
194
+ private enqueueReap;
195
+ /**
196
+ * Record the merge request's size on a done ticket while its worktree still
197
+ * exists. The server keeps `metrics` writable until `cleaned_up`; a failure
198
+ * is logged and never holds up the teardown. A teardown that keeps failing
199
+ * re-measures on every reap; the rows are replaced, so they stay correct.
200
+ */
201
+ private recordMrChanges;
202
+ /** Reaps one ticket; isolated, so one failure never aborts the rest. */
203
+ private reapOne;
204
+ /**
205
+ * The env a run executes in: the ticket's effective env_vars under the core
206
+ * vars. Used at dispatch and, rebuilt from the current ticket, for the
207
+ * `after_run` / `after_done` listener contexts (BB-599).
208
+ */
209
+ private runEnv;
210
+ /** Dispatches one claimed run; a failure terminalises it (BB-149). */
211
+ private dispatchOrFail;
212
+ private dispatch;
213
+ /**
214
+ * The part of a dispatch that can leave a sidecar or a worker behind
215
+ * (BB-599): its caller unwinds it through `after_run` on any failure.
216
+ */
217
+ private launch;
218
+ /**
219
+ * Stop what a failed dispatch started (BB-599): the worker if it was
220
+ * launched, then the `after_run` listeners and shell hook. Best-effort — it
221
+ * never replaces the dispatch error the caller re-throws.
222
+ */
223
+ private unwindDispatch;
224
+ serve(signal?: AbortSignal): Promise<void>;
225
+ /**
226
+ * True once this conductor's own checkout has been removed from disk (its
227
+ * worktree was reaped while the process kept running). Such a conductor is a
228
+ * pure liability: it still heartbeats and still claims runs, but every
229
+ * dispatch fails — no git command and no relative path can resolve from a
230
+ * deleted directory — so each claim burns one attempt and three of them park
231
+ * the ticket behind the circuit breaker. Observed as 30 orphans claiming and
232
+ * failing tickets they could never dispatch.
233
+ */
234
+ private checkoutGone;
235
+ private pollLoop;
236
+ }