@gaia-ai/conductor 0.0.1

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.
@@ -0,0 +1,389 @@
1
+ import { conductorId, } from '@gaia-ai/core';
2
+ function sleep(ms, signal) {
3
+ return new Promise((resolve) => {
4
+ if (signal?.aborted) {
5
+ resolve();
6
+ return;
7
+ }
8
+ const timer = setTimeout(() => {
9
+ signal?.removeEventListener('abort', onAbort);
10
+ resolve();
11
+ }, ms);
12
+ const onAbort = () => {
13
+ clearTimeout(timer);
14
+ resolve();
15
+ };
16
+ signal?.addEventListener('abort', onAbort, { once: true });
17
+ });
18
+ }
19
+ /**
20
+ * A run-env key is reserved when it names a conductor-provided core variable:
21
+ * any `GAIA_*`, `WORKSPACE_ROOT`, or `TICKET_URL`. User `env_vars` may not
22
+ * clobber these — the core vars always win (GAIA-99).
23
+ */
24
+ function isReservedEnvKey(key) {
25
+ return (key.startsWith('GAIA_') || key === 'WORKSPACE_ROOT' || key === 'TICKET_URL');
26
+ }
27
+ /**
28
+ * Parse the server-canonicalised effective env_vars (`KEY=value` lines, one per
29
+ * line) into a map. The server already stripped comments/blank/malformed lines,
30
+ * so this is deliberately minimal: split on the first `=`, keep a valid key.
31
+ * Defensive against stray blank lines only — never throws.
32
+ */
33
+ function parseEnvLines(text) {
34
+ const out = {};
35
+ for (const raw of text.split('\n')) {
36
+ const line = raw.trim();
37
+ if (line === '') {
38
+ continue;
39
+ }
40
+ const eq = line.indexOf('=');
41
+ if (eq <= 0) {
42
+ continue;
43
+ }
44
+ const key = line.slice(0, eq);
45
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
46
+ continue;
47
+ }
48
+ out[key] = line.slice(eq + 1);
49
+ }
50
+ return out;
51
+ }
52
+ /**
53
+ * Resolve the environment a run executes in (GAIA-99): parse the ticket's
54
+ * effective env_vars, drop any reserved key (loud warn — key NAME only, never
55
+ * the value), then overlay the conductor's core vars so they always win.
56
+ *
57
+ * Values are potentially secret (e.g. a DB DSN): this function logs key names
58
+ * only, never a value. The returned map is injected into BOTH the agent run env
59
+ * and every workspace hook.
60
+ */
61
+ export function resolveRunEnv(effectiveEnvVars, core, logger) {
62
+ const user = effectiveEnvVars ? parseEnvLines(effectiveEnvVars) : {};
63
+ const clobbered = [];
64
+ const safe = {};
65
+ for (const [key, value] of Object.entries(user)) {
66
+ if (isReservedEnvKey(key)) {
67
+ clobbered.push(key);
68
+ continue;
69
+ }
70
+ safe[key] = value;
71
+ }
72
+ if (clobbered.length > 0) {
73
+ logger.warn({ keys: clobbered }, 'ignored reserved keys in ticket env_vars (core vars win)');
74
+ }
75
+ // Core vars overlay last — they can never be overridden.
76
+ return { ...safe, ...core };
77
+ }
78
+ function renderPrompt(template, values) {
79
+ return template
80
+ .replaceAll('{identifier}', values.identifier)
81
+ .replaceAll('{state}', values.state)
82
+ .replaceAll('{runUuid}', values.runUuid);
83
+ }
84
+ export class Conductor {
85
+ config;
86
+ remote;
87
+ executor;
88
+ workspace;
89
+ agent;
90
+ logger;
91
+ checkoutRoot;
92
+ uuid = null;
93
+ constructor(config, remote, executor, workspace, agent, logger, checkoutRoot = process.cwd()) {
94
+ this.config = config;
95
+ this.remote = remote;
96
+ this.executor = executor;
97
+ this.workspace = workspace;
98
+ this.agent = agent;
99
+ this.logger = logger;
100
+ this.checkoutRoot = checkoutRoot;
101
+ }
102
+ get id() {
103
+ return this.config.machine_id ?? conductorId(this.checkoutRoot);
104
+ }
105
+ /** This conductor's registration payload, built from config. */
106
+ registration() {
107
+ return {
108
+ id: this.id,
109
+ project: this.config.project,
110
+ states: this.config.states,
111
+ workspace: this.checkoutRoot,
112
+ label: this.config.label,
113
+ max_parallel: this.config.max_parallel,
114
+ };
115
+ }
116
+ async start() {
117
+ this.uuid = await this.remote.registerConductor(this.registration());
118
+ this.logger.info({ conductorId: this.id, uuid: this.uuid, project: this.config.project }, 'conductor started');
119
+ }
120
+ async tick() {
121
+ if (!this.uuid)
122
+ throw new Error('not started');
123
+ let count = await this.remote.activeRunCount(this.id);
124
+ // A heartbeat is the server-side self-heal: it upserts by machine_id (so it
125
+ // never 404s and recreates a vanished registration) and refreshes the lease
126
+ // — no client-side re-register, no separate status read.
127
+ await this.remote.heartbeat(this.registration(), count, this.config.lease_seconds);
128
+ this.logger.info({ conductorId: this.id, active: count }, 'conductor tick');
129
+ await this.finalizeDoneRuns();
130
+ await this.reap();
131
+ let claimed = 0;
132
+ while (count < this.config.max_parallel) {
133
+ const run = await this.remote.claimNext({
134
+ leaseSeconds: this.config.lease_seconds,
135
+ conductorId: this.id,
136
+ });
137
+ if (!run)
138
+ break;
139
+ this.logger.info({ conductorId: this.id, run: run.runUuid, ticket: run.ticketUuid }, 'claimed run');
140
+ try {
141
+ await this.dispatch(run);
142
+ }
143
+ catch (err) {
144
+ // A failed dispatch must NOT terminalise the run. The run lifecycle is
145
+ // server-owned; a client-side releaseRun('done') here fabricated a
146
+ // terminal state on failure, which reopened the ticket for claiming and
147
+ // re-dispatched it every tick — the GAIA-41 runaway. Leaving the run
148
+ // `claimed` lets the server's same-state claim conflict
149
+ // (TicketClaimService::retireSupersededRuns) block a re-claim of the
150
+ // same ticket, so we just log and move on. A run stuck `claimed` on a
151
+ // persistently-failing dispatch is freed by the lease-expiry reaper
152
+ // (separate follow-up), not here.
153
+ this.logger.error({ run: run.runUuid, err: String(err) }, 'dispatch failed');
154
+ }
155
+ count += 1;
156
+ claimed += 1;
157
+ }
158
+ if (claimed === 0) {
159
+ this.logger.info({
160
+ conductorId: this.id,
161
+ active: count,
162
+ capacity: this.config.max_parallel,
163
+ }, count >= this.config.max_parallel
164
+ ? 'at capacity, nothing claimed'
165
+ : 'idle, nothing to claim');
166
+ }
167
+ }
168
+ /**
169
+ * One-shot finalisation: for each of this conductor's runs that is
170
+ * state=done but not yet closed, read the agent transcript from its worktree
171
+ * and write it as the final log, then mark the run closed. Keyed off the
172
+ * durable `state=done AND closed=false` query — no in-memory state, so a
173
+ * restarted conductor still finalises any unclosed done run on its next tick.
174
+ */
175
+ async finalizeDoneRuns() {
176
+ const runs = await this.remote.fetchFinalizableRuns(this.id);
177
+ for (const r of runs) {
178
+ try {
179
+ // Ask the agent to exit gracefully (/exit) before capturing its log.
180
+ // Best-effort and gated on the persistent capability (only hosted
181
+ // executors have a live agent pane) — mirrors removeWorktree's gate.
182
+ // A stop failure must never block finalisation.
183
+ if (this.executor.capabilities().persistent) {
184
+ const branch = await this.remote.getRunTicketBranchName(r.runUuid);
185
+ if (branch) {
186
+ try {
187
+ await this.executor.stopAgent(branch);
188
+ }
189
+ catch (err) {
190
+ this.logger.warn({ run: r.runUuid, err: String(err) }, 'agent stop failed');
191
+ }
192
+ }
193
+ }
194
+ const log = r.worktreePath
195
+ ? await this.agent.getRunLog(r.worktreePath)
196
+ : '';
197
+ // Footprint (GAIA-132): the agent parses its own transcript for the
198
+ // effort metrics (transcript format is agent-specific); the conductor
199
+ // adds duration_s from started_at → now. The conductor owns the run, so
200
+ // this write always lands (the agent session could not).
201
+ const parsed = this.agent.parseFootprint(log);
202
+ const now = Math.floor(Date.now() / 1000);
203
+ const metrics = {
204
+ tokens: parsed.tokens,
205
+ duration_s: r.startedAt !== undefined ? Math.max(0, now - r.startedAt) : 0,
206
+ agent_turns: parsed.agent_turns,
207
+ tool_calls: parsed.tool_calls,
208
+ user_prompts: parsed.user_prompts,
209
+ user_prompt_words: parsed.user_prompt_words,
210
+ };
211
+ await this.remote.finalizeRun(r.runUuid, log, metrics);
212
+ this.logger.info({ run: r.runUuid, footprint: metrics }, 'run finalised');
213
+ }
214
+ catch (err) {
215
+ this.logger.warn({ run: r.runUuid, err: String(err) }, 'run finalise failed');
216
+ }
217
+ }
218
+ }
219
+ /**
220
+ * Reconcile finished tickets against their herdr worktrees and tear down any
221
+ * whose workspace still lingers (GAIA-89). Driven by the durable `cleaned_up`
222
+ * flag via {@link GaiaRemote.fetchUncleanedTickets}: every ticket that is
223
+ * finished (state=done OR closed) but not yet cleaned. The SAME reconciliation
224
+ * runs on every tick AND standalone as `gaia conductor reap`, and the query is
225
+ * scoped to THIS conductor (GAIA-121) — so it catches the orphans a
226
+ * live-tick-only path structurally cannot (the conductor was down when the
227
+ * ticket hit `done`, or crashed mid-cleanup) for its OWN tickets. A foreign
228
+ * (non-gaia) worktree has no gaia ticket, so it is never on the work list; a
229
+ * ticket assigned to another conductor is filtered out at the query — both are
230
+ * left untouched with no host enumeration.
231
+ *
232
+ * Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
233
+ * is closed at once as a lifecycle step, independent of whether its worktree
234
+ * teardown then succeeds. Because the list is conductor-scoped, every ticket's
235
+ * worktree belongs on THIS host: `removeWorktree` returning `false` means the
236
+ * directory was already deleted out of band (nothing left to remove), which is
237
+ * itself a completed teardown → flag `cleaned_up`. There is no cross-host defer
238
+ * (GAIA-121) — the manufactured ambiguity that required it is gone once the
239
+ * query no longer loads foreign tickets. A teardown that THROWS logs loudly and
240
+ * leaves `cleaned_up=0`, so the next reconciliation retries; one miss never
241
+ * orphans the workspace (RC1). Re-running on an already-cleaned ticket is a
242
+ * no-op — it is off the list (idempotent). Each ticket is isolated in a
243
+ * try/catch so one failure never aborts the rest.
244
+ *
245
+ * The `after_done` hook runs through the executor (GAIA-84), which owns the
246
+ * best-effort policy — it logs+swallows a hook failure and never throws, so
247
+ * the core needs no per-call-site try/catch around it.
248
+ */
249
+ async reap() {
250
+ const tickets = await this.remote.fetchUncleanedTickets(this.id);
251
+ for (const t of tickets) {
252
+ try {
253
+ // Lifecycle: close a done+unclosed ticket at once, decoupled from the
254
+ // infra teardown below — a done ticket whose conductor was down was
255
+ // never closed, and withholding the close on a teardown hiccup would
256
+ // make a finished ticket look unfinished (the pre-GAIA-89 defect).
257
+ if (t.state === 'done' && !t.closed) {
258
+ await this.remote.closeTicket(t.ticketUuid);
259
+ }
260
+ // Infra teardown. The `after_done` hook runs in the worktree regardless
261
+ // of executor kind (a non-persistent gitWorkspace still has an on-disk
262
+ // worktree); only the workspace removal is gated on the persistent
263
+ // capability (mirrors the executor-agnostic release path). Pass the
264
+ // stable worktreePath so the executor resolves the worktree by path,
265
+ // not by the mutable checked-out branch (the coding agent may rename
266
+ // it). Teardown is idempotent: a worktree herdr no longer tracks is a
267
+ // no-op / on-disk reclaim, not a failure.
268
+ if (t.worktreePath) {
269
+ await this.executor.runHook('after_done', t.worktreePath, {
270
+ ticket: t.ticketUuid,
271
+ });
272
+ }
273
+ if (this.executor.capabilities().persistent) {
274
+ try {
275
+ // The work list is scoped to this conductor (GAIA-121), so its
276
+ // worktree belongs on THIS host. Whether removeWorktree tears one
277
+ // down or finds it already gone (`false`), teardown is complete —
278
+ // the boolean is irrelevant, only a THROW (a real failure) matters.
279
+ await this.executor.removeWorktree(t.branchName, t.worktreePath || undefined);
280
+ }
281
+ catch (err) {
282
+ this.logger.warn({
283
+ ticket: t.ticketUuid,
284
+ branch: t.branchName,
285
+ worktreePath: t.worktreePath,
286
+ err: String(err),
287
+ }, 'worktree teardown failed');
288
+ continue; // leave cleaned_up=0 → the next reconciliation retries.
289
+ }
290
+ }
291
+ // Teardown verified locally (or nothing hosted to tear down): flag it
292
+ // cleaned so it drops off the work list.
293
+ await this.remote.markTicketCleanedUp(t.ticketUuid);
294
+ this.logger.info({ ticket: t.ticketUuid }, 'ticket cleaned up');
295
+ }
296
+ catch (err) {
297
+ this.logger.warn({ ticket: t.ticketUuid, err: String(err) }, 'ticket cleanup failed');
298
+ }
299
+ }
300
+ }
301
+ async dispatch(run) {
302
+ const t = await this.remote.getTicket(run.ticketUuid);
303
+ const baseRef = t.baseBranch ? `origin/${t.baseBranch}` : undefined;
304
+ // Resolve the run env once (per-ticket env_vars + core GAIA_* vars) and
305
+ // inject it into BOTH the executor-owned lifecycle hooks AND the agent run
306
+ // env (GAIA-99). Reserved core keys always win; values may be sensitive so
307
+ // only key names are ever logged.
308
+ const core = {
309
+ GAIA_URL: this.config.site.base_url,
310
+ GAIA_ID: run.ticketUuid,
311
+ GAIA_RUN_UUID: run.runUuid,
312
+ WORKSPACE_ROOT: this.checkoutRoot,
313
+ ...(t.issueUrl ? { TICKET_URL: t.issueUrl } : {}),
314
+ };
315
+ const env = resolveRunEnv(t.effectiveEnvVars, core, this.logger);
316
+ const ws = await this.workspace.ensure(t.identifier, t.branchName, baseRef);
317
+ // A claimable state has no deterministic policy without a WORKFLOW.md
318
+ // (SKILL.md dispatch reads its `## State: <state>` sections). The human
319
+ // steered this to WARN-and-proceed (not a hard fail): the agent runs on
320
+ // engine defaults, and the gap is observable in the conductor log rather
321
+ // than wedging the run. `ws.instructions` IS the loaded WORKFLOW.md (null
322
+ // when absent), so no extra stat.
323
+ if (!ws.instructions) {
324
+ this.logger.warn({ ticket: t.identifier, workspace: ws.path }, 'WORKFLOW.md missing — dispatching on engine defaults');
325
+ }
326
+ // Lifecycle hooks are executor-owned + best-effort (GAIA-84): runHook never
327
+ // throws, so neither call can abort dispatch or wedge a run in `claimed`.
328
+ // after_create only on a freshly-created worktree; before_run always. Both
329
+ // get the resolved run env so a setup/pre hook sees the same vars (e.g. a DB
330
+ // DSN) the agent does (GAIA-99).
331
+ if (ws.created) {
332
+ await this.executor.runHook('after_create', ws.path, { ticket: t.identifier }, env);
333
+ }
334
+ await this.executor.runHook('before_run', ws.path, { ticket: t.identifier }, env);
335
+ await this.executor.retire(t.branchName);
336
+ const prompt = ws.instructions
337
+ ? renderPrompt(this.config.prompt, {
338
+ identifier: t.identifier,
339
+ state: t.state || 'triage',
340
+ runUuid: run.runUuid,
341
+ })
342
+ : '';
343
+ await this.executor.startRun({
344
+ ticket: {
345
+ uuid: t.uuid,
346
+ identifier: t.identifier,
347
+ title: t.title,
348
+ branchName: t.branchName,
349
+ state: t.state,
350
+ ...(t.issueUrl ? { url: t.issueUrl } : {}),
351
+ },
352
+ run: { uuid: run.runUuid, id: run.runId, handler: run.handler },
353
+ workspacePath: ws.path,
354
+ instructions: ws.instructions,
355
+ command: this.agent.launchCommand(prompt),
356
+ env,
357
+ });
358
+ this.logger.info({
359
+ ticket: t.identifier,
360
+ run: run.runUuid,
361
+ state: t.state,
362
+ workspace: ws.path,
363
+ // Key names only — env values may be sensitive (GAIA-99).
364
+ envKeys: Object.keys(env),
365
+ }, 'dispatched run');
366
+ // The conductor — not the agent — knows the per-run git worktree path, so
367
+ // it persists `worktree_path` to gaia_run here alongside the running state.
368
+ await this.remote.markRunning(run.runUuid, { worktree_path: ws.path });
369
+ }
370
+ async serve(signal) {
371
+ await this.pollLoop(signal);
372
+ }
373
+ async pollLoop(signal) {
374
+ while (!signal?.aborted) {
375
+ try {
376
+ await this.tick();
377
+ }
378
+ catch (err) {
379
+ this.logger.error({ conductorId: this.id, err: String(err) }, 'tick failed');
380
+ }
381
+ await sleep(this.config.poll_interval_ms, signal);
382
+ }
383
+ // NB: the poll loop never writes status=offline itself. A graceful stop
384
+ // (`gaia conductor stop`) writes offline directly server-side; a crash or
385
+ // hard-kill leaves a stale lease that the Drupal cron reaper flips to
386
+ // offline (gaia_core cron → ConductorReaper). Liveness is thus always
387
+ // derived from status + last_seen/lease_expires_at, never from the loop.
388
+ }
389
+ }
@@ -0,0 +1,8 @@
1
+ export type * from '@gaia-ai/core';
2
+ export { conductorId } from '@gaia-ai/core';
3
+ export { DrupalGaiaRemote, drupalRemote, FakeGaiaRemote, FakeWorkspace, fakeRemote, fakeWorkspace, GitWorkspace, gitWorkspace, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
4
+ export type { GaiaCliDeps } from './cli/gaia.js';
5
+ export { main, runGaiaCli } from './cli/gaia.js';
6
+ export type { ConductorRegistryEntry } from './cli/local-registry.js';
7
+ export { DEFAULT_AGENT_PROMPT, loadConductorConfig } from './config.js';
8
+ export { Conductor } from './core/conductor.js';
@@ -0,0 +1,5 @@
1
+ export { conductorId } from '@gaia-ai/core';
2
+ export { DrupalGaiaRemote, drupalRemote, FakeGaiaRemote, FakeWorkspace, fakeRemote, fakeWorkspace, GitWorkspace, gitWorkspace, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
3
+ export { main, runGaiaCli } from './cli/gaia.js';
4
+ export { DEFAULT_AGENT_PROMPT, loadConductorConfig } from './config.js';
5
+ export { Conductor } from './core/conductor.js';
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@gaia-ai/conductor",
3
+ "version": "0.0.1",
4
+ "description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=20.19"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/src/index.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "dist/src",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://git.key-tec.de/keytec/gaia.git",
25
+ "directory": "conductor/packages/conductor"
26
+ },
27
+ "dependencies": {
28
+ "@gaia-ai/core": "^0.0.1",
29
+ "@dropsh/plugin-oauth2": "^0.5.7",
30
+ "@dropsh/plugin-jsonapi-schema": "^0.5.7",
31
+ "commander": "^12.1.0",
32
+ "dropsh": "^0.5.7"
33
+ }
34
+ }