@gaia-ai/conductor 0.4.7 → 0.5.0

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.
@@ -1,12 +1,14 @@
1
- import { type ConductorFileConfig, type ConductorLogger, type GaiaAgent, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace } from '@gaia-ai/core';
1
+ import { type ConductorFileConfig, type ConductorLogger, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace, type ResolvedAgent } from '@gaia-ai/core';
2
2
  import { Command } from 'commander';
3
3
  /** Test seam: inject any subset of dependencies. */
4
4
  export interface GaiaCliDeps {
5
5
  remote?: GaiaRemote;
6
6
  executor?: GaiaExecutor;
7
7
  workspace?: GaiaWorkspace;
8
- agent?: GaiaAgent;
8
+ agents?: ResolvedAgent[];
9
9
  config?: ConductorFileConfig;
10
+ /** Injectable registry fetch for the start-time version check (tests). */
11
+ fetch?: typeof fetch;
10
12
  }
11
13
  export declare function parseHerdrJson(output: string, command: string): unknown;
12
14
  /**
@@ -1,15 +1,15 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import { fileURLToPath } from 'node:url';
5
4
  import { CommandRunner, createLogger, exec, setDefaultCommandRunner, } from '@gaia-ai/core';
6
- import { selectAgent, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
5
+ import { selectAgents, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
7
6
  import { Command } from 'commander';
8
7
  import { authStatus, buildProgram as buildDropshProgram } from 'dropsh';
9
8
  import { loadConductorConfig, resolveConfigPath } from '../config.js';
10
9
  import { Conductor } from '../core/conductor.js';
11
10
  import { machineContextPath, readMachineContext, scaffold, } from './init.js';
12
11
  import * as registry from './local-registry.js';
12
+ import { fetchUpdateNotice, printVersionLine, resolveCliVersion, runUpdate, } from './version-check.js';
13
13
  /**
14
14
  * Default config path. `--config` / `$GAIA_CONDUCTOR_CONFIG` win; otherwise walk
15
15
  * up from cwd to the nearest `.gaia/conductor.config.js` (git/eslint style), so a
@@ -185,8 +185,8 @@ async function cmdPoll(deps, log = {}) {
185
185
  const remote = await resolveRemote(deps, config);
186
186
  const executor = deps.executor ?? (await selectExecutor(config, logger));
187
187
  const workspace = deps.workspace ?? (await selectWorkspace(config));
188
- const agent = deps.agent ?? (await selectAgent(config));
189
- const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
188
+ const agents = deps.agents ?? (await selectAgents(config));
189
+ const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
190
190
  await conductor.start();
191
191
  await conductor.tick();
192
192
  }
@@ -199,8 +199,8 @@ async function cmdReap(deps, log = {}) {
199
199
  const remote = await resolveRemote(deps, config);
200
200
  const executor = deps.executor ?? (await selectExecutor(config, logger));
201
201
  const workspace = deps.workspace ?? (await selectWorkspace(config));
202
- const agent = deps.agent ?? (await selectAgent(config));
203
- const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
202
+ const agents = deps.agents ?? (await selectAgents(config));
203
+ const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
204
204
  // The reaper reconciles finished-but-uncleaned tickets (the cleaned_up flag)
205
205
  // against their worktrees; it needs no registration/heartbeat (it is not a
206
206
  // poll), just the executor + remote, so it runs standalone after a
@@ -216,8 +216,8 @@ async function cmdStartForeground(deps, log = {}) {
216
216
  const remote = await resolveRemote(deps, config);
217
217
  const executor = deps.executor ?? (await selectExecutor(config, logger));
218
218
  const workspace = deps.workspace ?? (await selectWorkspace(config));
219
- const agent = deps.agent ?? (await selectAgent(config));
220
- const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
219
+ const agents = deps.agents ?? (await selectAgents(config));
220
+ const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
221
221
  await conductor.start();
222
222
  const controller = new AbortController();
223
223
  const onSignal = () => controller.abort();
@@ -232,6 +232,24 @@ async function cmdStartForeground(deps, log = {}) {
232
232
  }
233
233
  }
234
234
  async function cmdStart(deps, log = {}) {
235
+ // Print the installed version (AC-2) immediately, then kick the newer-version
236
+ // registry check off CONCURRENTLY with config resolution so a slow/offline
237
+ // registry never stalls start (finding #1 — the check no longer runs serially
238
+ // before resolveConfig). Fail-silent + bounded (AC-3/AC-4/AC-5); the notice is
239
+ // printed once it resolves, after the start work below. Not in
240
+ // cmdStartForeground — that path is herdr-hosted, no TTY.
241
+ const current = printVersionLine();
242
+ const noticePromise = fetchUpdateNotice(current, deps.fetch ?? globalThis.fetch).catch(() => null);
243
+ try {
244
+ await cmdStartBody(deps, log);
245
+ }
246
+ finally {
247
+ const notice = await noticePromise;
248
+ if (notice !== null)
249
+ console.log(notice);
250
+ }
251
+ }
252
+ async function cmdStartBody(deps, log) {
235
253
  const config = await resolveConfig(deps);
236
254
  const checkoutRoot = checkoutRootOf(config);
237
255
  const logger = loggerFor(checkoutRoot, log);
@@ -373,25 +391,6 @@ async function promptSecret() {
373
391
  }
374
392
  }
375
393
  // --- program ----------------------------------------------------------------
376
- // Report the CLI's own package version. Walks up from this module to the
377
- // nearest @gaia-ai/conductor package.json so it resolves both from the
378
- // compiled dist/src/cli/gaia.js (3 dirs up) and the src/cli/gaia.ts vitest
379
- // runs (2 dirs up). Same release tag across packages ⇒ == @gaia-ai/gaia.
380
- function resolveCliVersion() {
381
- let dir = dirname(fileURLToPath(import.meta.url));
382
- for (let i = 0; i < 6; i++) {
383
- try {
384
- const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
385
- if (pkg.name === '@gaia-ai/conductor')
386
- return pkg.version ?? '0.0.0';
387
- }
388
- catch {
389
- // no package.json here — keep walking up
390
- }
391
- dir = dirname(dir);
392
- }
393
- return '0.0.0';
394
- }
395
394
  export function buildProgram(deps) {
396
395
  const program = new Command();
397
396
  program
@@ -417,6 +416,31 @@ export function buildProgram(deps) {
417
416
  .action(() => {
418
417
  console.log(cliVersion);
419
418
  });
419
+ program
420
+ .command('update')
421
+ .description('upgrade the globally installed gaia CLI to the latest npm release')
422
+ .action(async () => {
423
+ const { ok, before, after } = await runUpdate();
424
+ if (!ok) {
425
+ console.error('gaia update failed — see npm output above');
426
+ process.exitCode = 1;
427
+ return;
428
+ }
429
+ // Report the real on-disk change (finding #2): never a fabricated
430
+ // "latest", never a false "upgraded" when npm changed nothing.
431
+ if (after === null) {
432
+ console.log('gaia update completed (installed version undetermined)');
433
+ }
434
+ else if (before === null) {
435
+ console.log(`gaia installed: ${after}`);
436
+ }
437
+ else if (before === after) {
438
+ console.log(`gaia is already up to date (${after})`);
439
+ }
440
+ else {
441
+ console.log(`gaia updated: ${before} → ${after}`);
442
+ }
443
+ });
420
444
  const conductor = program
421
445
  .command('conductor')
422
446
  .description('node-agent lifecycle + local registry')
@@ -28,8 +28,10 @@ export function renderCommittedConfig(inputs) {
28
28
  // resolves each name ESLint-style (config dir → cwd → conductor install), so
29
29
  // config load never depends on a \`node_modules/@gaia-ai\` symlink beside this
30
30
  // file. Each plugin package default-exports its factory, so the resolver's
31
- // auto-pick needs no \`export:\` here — only the \`@gaia-ai/gaia/plugins\` host
32
- // barrel (many exports) still names one via \`export: 'drupalRemote'\`.
31
+ // auto-pick needs no \`export:\` here — EXCEPT the \`@gaia-ai/gaia/plugins\` host
32
+ // barrel (many exports \`export: 'drupalRemote'\`) and the merged herdr
33
+ // workspace slot (\`@gaia-ai/plugin-herdr\` default-exports the executor, so the
34
+ // workspace names \`export: 'herdrWorkspace'\` — GAIA-139).
33
35
 
34
36
  // The user-global machine context: identity + connection (incl. secret), shared
35
37
  // by every project on this machine. Never committed.
@@ -79,8 +81,12 @@ export default {
79
81
  plugin: '@gaia-ai/plugin-claude',
80
82
  with: { model: local.model ?? 'claude-opus-4-8' },
81
83
  },
84
+ // GAIA-139: the herdr workspace ships in @gaia-ai/plugin-herdr now (the
85
+ // separate plugin-herdr-workspace was merged in). That package default-exports
86
+ // the EXECUTOR, so the workspace slot must name export: 'herdrWorkspace'.
82
87
  workspace: {
83
- plugin: '@gaia-ai/plugin-herdr-workspace',
88
+ plugin: '@gaia-ai/plugin-herdr',
89
+ export: 'herdrWorkspace',
84
90
  },
85
91
  // oauth2 is a real dep of the host (npm installs it alongside @gaia-ai/gaia).
86
92
  // NOTE: plugins[] is consumed by DROPSH, which reloads this config with its OWN
@@ -0,0 +1,72 @@
1
+ /** The global install target on npm (the meta package). */
2
+ export declare const GAIA_PACKAGE = "@gaia-ai/gaia";
3
+ /** The npm registry endpoint for the latest published version. */
4
+ export declare const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@gaia-ai/gaia/latest";
5
+ /**
6
+ * The sentinel returned when the installed version cannot be resolved (the
7
+ * package.json walk failed). Never a real published release, so callers treat
8
+ * it as "unknown" and suppress the update notice rather than nagging.
9
+ */
10
+ export declare const UNKNOWN_VERSION = "0.0.0";
11
+ /**
12
+ * The currently installed CLI version. Walks up from this module to the nearest
13
+ * @gaia-ai/conductor package.json so it resolves both from the compiled
14
+ * dist/src/cli/version-check.js and from a vitest src run. Same release tag
15
+ * across packages ⇒ == @gaia-ai/gaia. Memoized — the version cannot change
16
+ * within a process, so the walk runs at most once (finding #6).
17
+ */
18
+ export declare function resolveCliVersion(): string;
19
+ /**
20
+ * Fetch the latest published version of @gaia-ai/gaia from the npm registry.
21
+ * Bounded by an AbortController timeout; NEVER throws — any failure (network,
22
+ * non-2xx, timeout, malformed JSON, missing field) resolves to null so the
23
+ * caller can treat the check as best-effort (AC-4). fetchImpl is injectable so
24
+ * tests run without network.
25
+ */
26
+ export declare function fetchLatestVersion(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;
27
+ /**
28
+ * A human-readable "update available" notice when `latest` is strictly newer
29
+ * than `current`, else null (already current or ahead — AC-5). Pure.
30
+ */
31
+ export declare function updateNotice(current: string, latest: string): string | null;
32
+ /** Spawns a command, inheriting stdio, resolving to its exit code. */
33
+ export type SpawnUpdate = (cmd: string, args: string[]) => Promise<number>;
34
+ /** Spawns a command, capturing stdout, resolving to its exit code + stdout. */
35
+ export type SpawnCapture = (cmd: string, args: string[]) => Promise<{
36
+ code: number;
37
+ stdout: string;
38
+ }>;
39
+ /**
40
+ * Upgrade the globally installed gaia CLI to the latest published version via
41
+ * `npm install -g @gaia-ai/gaia@latest`. Reports the actual global version
42
+ * before and after the install (queried from npm, not from a separate registry
43
+ * fetch — finding #2), so the caller reports the real change: `before` is null
44
+ * when gaia was not installed globally, and no "upgraded" claim is made unless
45
+ * npm actually changed the on-disk version. On install failure `after` mirrors
46
+ * `before` and the post-install query is skipped. spawnImpl/captureImpl are
47
+ * injectable for tests.
48
+ */
49
+ export declare function runUpdate(spawnImpl?: SpawnUpdate, captureImpl?: SpawnCapture): Promise<{
50
+ ok: boolean;
51
+ before: string | null;
52
+ after: string | null;
53
+ }>;
54
+ /**
55
+ * Print the installed gaia version line (AC-2), synchronously, and return it.
56
+ * Never blocks on the registry — the caller kicks off the newer-version check
57
+ * separately (see fetchUpdateNotice) so start is not stalled waiting on npm.
58
+ */
59
+ export declare function printVersionLine(out?: (s: string) => void): string;
60
+ /**
61
+ * Best-effort "update available" notice for the given installed version
62
+ * (AC-3/AC-5). Fetches the latest published version (fail-silent → null on any
63
+ * error, AC-4) and compares. Returns the notice string or null. fetchImpl is
64
+ * injectable for tests.
65
+ */
66
+ export declare function fetchUpdateNotice(current: string, fetchImpl?: typeof fetch): Promise<string | null>;
67
+ /**
68
+ * Print the installed gaia version (AC-2) and, best-effort, a newer-version
69
+ * notice (AC-3/AC-5). The registry check is fail-silent (AC-4): an offline
70
+ * registry prints only the version line. out/fetchImpl are injectable for tests.
71
+ */
72
+ export declare function printStartVersionBanner(out?: (s: string) => void, fetchImpl?: typeof fetch): Promise<void>;
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import semver from 'semver';
6
+ /** The global install target on npm (the meta package). */
7
+ export const GAIA_PACKAGE = '@gaia-ai/gaia';
8
+ /** The npm registry endpoint for the latest published version. */
9
+ export const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@gaia-ai/gaia/latest';
10
+ /**
11
+ * The sentinel returned when the installed version cannot be resolved (the
12
+ * package.json walk failed). Never a real published release, so callers treat
13
+ * it as "unknown" and suppress the update notice rather than nagging.
14
+ */
15
+ export const UNKNOWN_VERSION = '0.0.0';
16
+ let cachedCliVersion;
17
+ /**
18
+ * The currently installed CLI version. Walks up from this module to the nearest
19
+ * @gaia-ai/conductor package.json so it resolves both from the compiled
20
+ * dist/src/cli/version-check.js and from a vitest src run. Same release tag
21
+ * across packages ⇒ == @gaia-ai/gaia. Memoized — the version cannot change
22
+ * within a process, so the walk runs at most once (finding #6).
23
+ */
24
+ export function resolveCliVersion() {
25
+ if (cachedCliVersion !== undefined)
26
+ return cachedCliVersion;
27
+ let dir = dirname(fileURLToPath(import.meta.url));
28
+ for (let i = 0; i < 6; i++) {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
31
+ if (pkg.name === '@gaia-ai/conductor') {
32
+ cachedCliVersion = pkg.version ?? UNKNOWN_VERSION;
33
+ return cachedCliVersion;
34
+ }
35
+ }
36
+ catch {
37
+ // no package.json here — keep walking up
38
+ }
39
+ dir = dirname(dir);
40
+ }
41
+ cachedCliVersion = UNKNOWN_VERSION;
42
+ return cachedCliVersion;
43
+ }
44
+ /**
45
+ * Fetch the latest published version of @gaia-ai/gaia from the npm registry.
46
+ * Bounded by an AbortController timeout; NEVER throws — any failure (network,
47
+ * non-2xx, timeout, malformed JSON, missing field) resolves to null so the
48
+ * caller can treat the check as best-effort (AC-4). fetchImpl is injectable so
49
+ * tests run without network.
50
+ */
51
+ export async function fetchLatestVersion(fetchImpl = globalThis.fetch, timeoutMs = 1500) {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ try {
55
+ const res = await fetchImpl(REGISTRY_LATEST_URL, {
56
+ signal: controller.signal,
57
+ headers: { accept: 'application/json' },
58
+ });
59
+ if (!res.ok)
60
+ return null;
61
+ const body = (await res.json());
62
+ return typeof body.version === 'string' ? body.version : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+ /**
72
+ * A human-readable "update available" notice when `latest` is strictly newer
73
+ * than `current`, else null (already current or ahead — AC-5). Pure.
74
+ */
75
+ export function updateNotice(current, latest) {
76
+ // Unknown installed version ⇒ suppress the notice (finding #5): otherwise a
77
+ // failed package.json walk yields a perpetual bogus "0.0.0 → x.y.z" nag.
78
+ if (current === UNKNOWN_VERSION)
79
+ return null;
80
+ if (!semver.valid(current) || !semver.valid(latest))
81
+ return null;
82
+ if (!semver.gt(latest, current))
83
+ return null;
84
+ return (`A new gaia version is available: ${current} → ${latest}\n` +
85
+ 'Run `gaia update` to upgrade.');
86
+ }
87
+ const defaultSpawn = (cmd, args) => new Promise((resolve) => {
88
+ const child = spawn(cmd, args, { stdio: 'inherit', shell: false });
89
+ child.on('error', () => resolve(1));
90
+ child.on('close', (code) => resolve(code ?? 1));
91
+ });
92
+ const defaultCapture = (cmd, args) => new Promise((resolve) => {
93
+ let stdout = '';
94
+ const child = spawn(cmd, args, {
95
+ stdio: ['ignore', 'pipe', 'ignore'],
96
+ shell: false,
97
+ });
98
+ child.stdout?.on('data', (chunk) => {
99
+ stdout += String(chunk);
100
+ });
101
+ child.on('error', () => resolve({ code: 1, stdout }));
102
+ child.on('close', (code) => resolve({ code: code ?? 1, stdout }));
103
+ });
104
+ /**
105
+ * The version of the globally installed @gaia-ai/gaia, read from
106
+ * `npm ls -g @gaia-ai/gaia --json`, or null when it is not installed globally /
107
+ * the output cannot be parsed. This reflects what npm actually has on disk —
108
+ * independent of the (possibly dev-shim) CLI running this process.
109
+ */
110
+ async function queryGlobalVersion(captureImpl) {
111
+ try {
112
+ const { stdout } = await captureImpl('npm', [
113
+ 'ls',
114
+ '-g',
115
+ GAIA_PACKAGE,
116
+ '--json',
117
+ '--depth',
118
+ '0',
119
+ ]);
120
+ const parsed = JSON.parse(stdout);
121
+ const v = parsed.dependencies?.[GAIA_PACKAGE]?.version;
122
+ return typeof v === 'string' ? v : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /**
129
+ * Upgrade the globally installed gaia CLI to the latest published version via
130
+ * `npm install -g @gaia-ai/gaia@latest`. Reports the actual global version
131
+ * before and after the install (queried from npm, not from a separate registry
132
+ * fetch — finding #2), so the caller reports the real change: `before` is null
133
+ * when gaia was not installed globally, and no "upgraded" claim is made unless
134
+ * npm actually changed the on-disk version. On install failure `after` mirrors
135
+ * `before` and the post-install query is skipped. spawnImpl/captureImpl are
136
+ * injectable for tests.
137
+ */
138
+ export async function runUpdate(spawnImpl = defaultSpawn, captureImpl = defaultCapture) {
139
+ const before = await queryGlobalVersion(captureImpl);
140
+ const code = await spawnImpl('npm', [
141
+ 'install',
142
+ '-g',
143
+ `${GAIA_PACKAGE}@latest`,
144
+ ]);
145
+ if (code !== 0)
146
+ return { ok: false, before, after: before };
147
+ const after = await queryGlobalVersion(captureImpl);
148
+ return { ok: true, before, after };
149
+ }
150
+ /**
151
+ * Print the installed gaia version line (AC-2), synchronously, and return it.
152
+ * Never blocks on the registry — the caller kicks off the newer-version check
153
+ * separately (see fetchUpdateNotice) so start is not stalled waiting on npm.
154
+ */
155
+ export function printVersionLine(out = console.log) {
156
+ const current = resolveCliVersion();
157
+ out(`gaia v${current}`);
158
+ return current;
159
+ }
160
+ /**
161
+ * Best-effort "update available" notice for the given installed version
162
+ * (AC-3/AC-5). Fetches the latest published version (fail-silent → null on any
163
+ * error, AC-4) and compares. Returns the notice string or null. fetchImpl is
164
+ * injectable for tests.
165
+ */
166
+ export async function fetchUpdateNotice(current, fetchImpl = globalThis.fetch) {
167
+ const latest = await fetchLatestVersion(fetchImpl);
168
+ if (latest === null)
169
+ return null;
170
+ return updateNotice(current, latest);
171
+ }
172
+ /**
173
+ * Print the installed gaia version (AC-2) and, best-effort, a newer-version
174
+ * notice (AC-3/AC-5). The registry check is fail-silent (AC-4): an offline
175
+ * registry prints only the version line. out/fetchImpl are injectable for tests.
176
+ */
177
+ export async function printStartVersionBanner(out = console.log, fetchImpl = globalThis.fetch) {
178
+ const current = printVersionLine(out);
179
+ const notice = await fetchUpdateNotice(current, fetchImpl);
180
+ if (notice !== null)
181
+ out(notice);
182
+ }
@@ -1,4 +1,4 @@
1
- import type { ConductorFileConfig } from '@gaia-ai/core';
1
+ import type { AgentCandidate, ConductorFileConfig } from '@gaia-ai/core';
2
2
  /**
3
3
  * Default agent prompt - the GAIA run contract. One run works EXACTLY one state;
4
4
  * the agent must stop instead of running the whole flow in one session. The run
@@ -34,6 +34,15 @@ import type { ConductorFileConfig } from '@gaia-ai/core';
34
34
  * `triage` for unclassified tickets).
35
35
  */
36
36
  export declare const DEFAULT_AGENT_PROMPT: string;
37
+ /**
38
+ * Resolve the `agent` slot into candidate(s), **preserving the authored shape**:
39
+ * a single descriptor/plugin/wrapper → a single `AgentCandidate`, an array →
40
+ * `AgentCandidate[]`. Each entry is either a bare descriptor/plugin or a
41
+ * `{ agent, priority? }` wrapper; the inner agent value is resolved through the
42
+ * same `agent` kind-guard as any slot, and a `priority` function is preserved
43
+ * verbatim (AC-1, AC-2). Normalization to a list happens at selection time.
44
+ */
45
+ export declare function resolveAgents(raw: unknown, configPath: string): Promise<AgentCandidate | AgentCandidate[]>;
37
46
  /**
38
47
  * Resolve the conductor config path from `cwd`.
39
48
  *
@@ -42,7 +42,7 @@ export const DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identi
42
42
  `(gaia dropsh read gaia_ticket/gaia_ticket/$GAIA_ID --include comments), ` +
43
43
  `renders the intake splash, runs the {state} engine, and applies ` +
44
44
  `WORKFLOW.md's {state} policy. Do ONLY the {state} work — never start or ` +
45
- `prepare a later state. When done, or if you are blocked, STOP and run /exit.`;
45
+ `prepare a later state.`;
46
46
  function requirePlugin(value, kind) {
47
47
  if (!isRecord(value) || value.kind !== kind) {
48
48
  throw new Error(`conductor config requires a ${kind} plugin in the "${kind}" slot`);
@@ -119,6 +119,31 @@ async function resolveSlot(value, kind, configPath) {
119
119
  : value;
120
120
  return requirePlugin(resolved, kind);
121
121
  }
122
+ /**
123
+ * Resolve the `agent` slot into candidate(s), **preserving the authored shape**:
124
+ * a single descriptor/plugin/wrapper → a single `AgentCandidate`, an array →
125
+ * `AgentCandidate[]`. Each entry is either a bare descriptor/plugin or a
126
+ * `{ agent, priority? }` wrapper; the inner agent value is resolved through the
127
+ * same `agent` kind-guard as any slot, and a `priority` function is preserved
128
+ * verbatim (AC-1, AC-2). Normalization to a list happens at selection time.
129
+ */
130
+ export async function resolveAgents(raw, configPath) {
131
+ const resolveOne = async (entry) => {
132
+ const hasWrapper = isRecord(entry) &&
133
+ 'agent' in entry &&
134
+ !('kind' in entry) &&
135
+ !('plugin' in entry);
136
+ const agentValue = hasWrapper ? entry.agent : entry;
137
+ const priority = hasWrapper
138
+ ? entry.priority
139
+ : undefined;
140
+ const agent = await resolveSlot(agentValue, 'agent', configPath);
141
+ return { agent, ...(priority ? { priority } : {}) };
142
+ };
143
+ return Array.isArray(raw)
144
+ ? Promise.all(raw.map(resolveOne))
145
+ : resolveOne(raw);
146
+ }
122
147
  /**
123
148
  * Resolve the `plugins[]` array: descriptor entries are constructed, already-
124
149
  * constructed entries pass through. No kind-guard (plugins are not slotted).
@@ -259,7 +284,7 @@ export async function loadConductorConfig(configFile) {
259
284
  : {}),
260
285
  remote: await resolveSlot(config.remote, 'remote', configPath),
261
286
  executor: await resolveSlot(config.executor, 'executor', configPath),
262
- agent: await resolveSlot(config.agent, 'agent', configPath),
287
+ agent: await resolveAgents(config.agent, configPath),
263
288
  workspace: await resolveSlot(config.workspace, 'workspace', configPath),
264
289
  label,
265
290
  machine_id: machineId,
@@ -1,4 +1,4 @@
1
- import { type ConductorFileConfig, type ConductorLogger, type GaiaAgent, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace } from '@gaia-ai/core';
1
+ import { type ConductorFileConfig, type ConductorLogger, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace, type ResolvedAgent } from '@gaia-ai/core';
2
2
  /**
3
3
  * Resolve the environment a run executes in (GAIA-99): parse the ticket's
4
4
  * effective env_vars, drop any reserved key (loud warn — key NAME only, never
@@ -14,11 +14,11 @@ export declare class Conductor {
14
14
  private readonly remote;
15
15
  private readonly executor;
16
16
  private readonly workspace;
17
- private readonly agent;
17
+ private readonly agents;
18
18
  private readonly logger;
19
19
  private readonly checkoutRoot;
20
20
  private uuid;
21
- constructor(config: ConductorFileConfig, remote: GaiaRemote, executor: GaiaExecutor, workspace: GaiaWorkspace, agent: GaiaAgent, logger: ConductorLogger, checkoutRoot?: string);
21
+ constructor(config: ConductorFileConfig, remote: GaiaRemote, executor: GaiaExecutor, workspace: GaiaWorkspace, agents: ResolvedAgent[], logger: ConductorLogger, checkoutRoot?: string);
22
22
  get id(): string;
23
23
  /** This conductor's registration payload, built from config. */
24
24
  private registration;
@@ -47,16 +47,17 @@ export declare class Conductor {
47
47
  *
48
48
  * Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
49
49
  * is closed at once as a lifecycle step, independent of whether its worktree
50
- * teardown then succeeds. Because the list is conductor-scoped, every ticket's
51
- * worktree belongs on THIS host: `removeWorktree` returning `false` means the
52
- * directory was already deleted out of band (nothing left to remove), which is
53
- * itself a completed teardown flag `cleaned_up`. There is no cross-host defer
54
- * (GAIA-121) the manufactured ambiguity that required it is gone once the
55
- * query no longer loads foreign tickets. A teardown that THROWS logs loudly and
56
- * leaves `cleaned_up=0`, so the next reconciliation retries; one miss never
57
- * orphans the workspace (RC1). Re-running on an already-cleaned ticket is a
58
- * no-op — it is off the list (idempotent). Each ticket is isolated in a
59
- * try/catch so one failure never aborts the rest.
50
+ * teardown then succeeds. `cleaned_up` is flagged ONLY on a VERIFIED teardown
51
+ * (GAIA-141 RC-2): `removeWorktree` returns `true` when the worktree is gone on
52
+ * this host (removed by us, or confirmed already absent), `false` when it could
53
+ * not be resolved and may still be on disk (e.g. its `worktree_path` was never
54
+ * persisted). A `false` return like a THROW logs loudly and leaves
55
+ * `cleaned_up=0`, so the next reconciliation retries; flagging cleaned on an
56
+ * unverified teardown was the root cause of the ~28 never-touched leftovers,
57
+ * because the ticket then dropped off the (cleaned_up=0) work list forever.
58
+ * Re-running on an already-cleaned ticket is a no-op — it is off the list
59
+ * (idempotent). Each ticket is isolated in a try/catch so one failure never
60
+ * aborts the rest.
60
61
  *
61
62
  * The `after_done` hook runs through the executor (GAIA-84), which owns the
62
63
  * best-effort policy — it logs+swallows a hook failure and never throws, so
@@ -1,4 +1,4 @@
1
- import { conductorId, } from '@gaia-ai/core';
1
+ import { conductorId, selectAgent, } from '@gaia-ai/core';
2
2
  function sleep(ms, signal) {
3
3
  return new Promise((resolve) => {
4
4
  if (signal?.aborted) {
@@ -86,16 +86,16 @@ export class Conductor {
86
86
  remote;
87
87
  executor;
88
88
  workspace;
89
- agent;
89
+ agents;
90
90
  logger;
91
91
  checkoutRoot;
92
92
  uuid = null;
93
- constructor(config, remote, executor, workspace, agent, logger, checkoutRoot = process.cwd()) {
93
+ constructor(config, remote, executor, workspace, agents, logger, checkoutRoot = process.cwd()) {
94
94
  this.config = config;
95
95
  this.remote = remote;
96
96
  this.executor = executor;
97
97
  this.workspace = workspace;
98
- this.agent = agent;
98
+ this.agents = agents;
99
99
  this.logger = logger;
100
100
  this.checkoutRoot = checkoutRoot;
101
101
  }
@@ -191,14 +191,21 @@ export class Conductor {
191
191
  }
192
192
  }
193
193
  }
194
+ // Route to the agent that actually ran (GAIA-144): the persisted
195
+ // run.agent id, falling back to the first candidate for a run written
196
+ // before the field existed (empty id).
197
+ const resolved = this.agents.find((a) => a.id === r.agent) ?? this.agents[0];
198
+ if (!resolved) {
199
+ throw new Error('no agent candidates configured for finalize');
200
+ }
194
201
  const log = r.worktreePath
195
- ? await this.agent.getRunLog(r.worktreePath)
202
+ ? await resolved.agent.getRunLog(r.worktreePath)
196
203
  : '';
197
204
  // Footprint (GAIA-132): the agent parses its own transcript for the
198
205
  // effort metrics (transcript format is agent-specific); the conductor
199
206
  // adds duration_s from started_at → now. The conductor owns the run, so
200
207
  // this write always lands (the agent session could not).
201
- const parsed = this.agent.parseFootprint(log);
208
+ const parsed = resolved.agent.parseFootprint(log);
202
209
  const now = Math.floor(Date.now() / 1000);
203
210
  const metrics = {
204
211
  tokens: parsed.tokens,
@@ -231,16 +238,17 @@ export class Conductor {
231
238
  *
232
239
  * Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
233
240
  * 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.
241
+ * teardown then succeeds. `cleaned_up` is flagged ONLY on a VERIFIED teardown
242
+ * (GAIA-141 RC-2): `removeWorktree` returns `true` when the worktree is gone on
243
+ * this host (removed by us, or confirmed already absent), `false` when it could
244
+ * not be resolved and may still be on disk (e.g. its `worktree_path` was never
245
+ * persisted). A `false` return like a THROW logs loudly and leaves
246
+ * `cleaned_up=0`, so the next reconciliation retries; flagging cleaned on an
247
+ * unverified teardown was the root cause of the ~28 never-touched leftovers,
248
+ * because the ticket then dropped off the (cleaned_up=0) work list forever.
249
+ * Re-running on an already-cleaned ticket is a no-op — it is off the list
250
+ * (idempotent). Each ticket is isolated in a try/catch so one failure never
251
+ * aborts the rest.
244
252
  *
245
253
  * The `after_done` hook runs through the executor (GAIA-84), which owns the
246
254
  * best-effort policy — it logs+swallows a hook failure and never throws, so
@@ -271,12 +279,14 @@ export class Conductor {
271
279
  });
272
280
  }
273
281
  if (this.executor.capabilities().persistent) {
282
+ let removed;
274
283
  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);
284
+ // removeWorktree returns whether the teardown was VERIFIED on this
285
+ // host: `true` = the worktree is gone (removed by us, or confirmed
286
+ // already absent); `false` = it could NOT be resolved and may still
287
+ // be on disk (GAIA-141 RC-2 e.g. worktree_path was never persisted
288
+ // and herdr lost track of the branch). A THROW is a hard failure.
289
+ removed = await this.executor.removeWorktree(t.branchName, t.worktreePath || undefined);
280
290
  }
281
291
  catch (err) {
282
292
  this.logger.warn({
@@ -287,6 +297,19 @@ export class Conductor {
287
297
  }, 'worktree teardown failed');
288
298
  continue; // leave cleaned_up=0 → the next reconciliation retries.
289
299
  }
300
+ if (!removed) {
301
+ // Unverified teardown (GAIA-141 RC-2): do NOT flag cleaned_up, or the
302
+ // ticket drops off fetchUncleanedTickets (which filters cleaned_up=0)
303
+ // and the orphan is never retried. Leave it on the work list so a
304
+ // later reap — with a persisted path, or once the executor resolves
305
+ // it via the parent-repo git worktree list — can finish the job.
306
+ this.logger.warn({
307
+ ticket: t.ticketUuid,
308
+ branch: t.branchName,
309
+ worktreePath: t.worktreePath,
310
+ }, 'worktree teardown unverified — retrying next reap');
311
+ continue; // leave cleaned_up=0 → the next reconciliation retries.
312
+ }
290
313
  }
291
314
  // Teardown verified locally (or nothing hosted to tear down): flag it
292
315
  // cleaned so it drops off the work list.
@@ -300,6 +323,11 @@ export class Conductor {
300
323
  }
301
324
  async dispatch(run) {
302
325
  const t = await this.remote.getTicket(run.ticketUuid);
326
+ // Config-side agent selection (GAIA-144): pick the highest-priority
327
+ // candidate for this ticket (labels/environments already sideloaded by
328
+ // getTicket, so priority(ticket) never fetches). The chosen id is persisted
329
+ // on the run below so the stateless finalize step routes footprint parsing.
330
+ const chosen = selectAgent(this.agents, t);
303
331
  const baseRef = t.baseBranch ? `origin/${t.baseBranch}` : undefined;
304
332
  // Resolve the run env once (per-ticket env_vars + core GAIA_* vars) and
305
333
  // inject it into BOTH the executor-owned lifecycle hooks AND the agent run
@@ -352,7 +380,7 @@ export class Conductor {
352
380
  run: { uuid: run.runUuid, id: run.runId, handler: run.handler },
353
381
  workspacePath: ws.path,
354
382
  instructions: ws.instructions,
355
- command: this.agent.launchCommand(prompt),
383
+ command: chosen.agent.launchCommand(prompt),
356
384
  env,
357
385
  });
358
386
  this.logger.info({
@@ -365,7 +393,12 @@ export class Conductor {
365
393
  }, 'dispatched run');
366
394
  // The conductor — not the agent — knows the per-run git worktree path, so
367
395
  // it persists `worktree_path` to gaia_run here alongside the running state.
368
- await this.remote.markRunning(run.runUuid, { worktree_path: ws.path });
396
+ // It also persists the chosen agent id (GAIA-144) for finalize footprint
397
+ // routing.
398
+ await this.remote.markRunning(run.runUuid, {
399
+ worktree_path: ws.path,
400
+ agent: chosen.id,
401
+ });
369
402
  }
370
403
  async serve(signal) {
371
404
  await this.pollLoop(signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/conductor",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,10 +25,11 @@
25
25
  "directory": "conductor/packages/conductor"
26
26
  },
27
27
  "dependencies": {
28
- "@gaia-ai/core": "^0.4.7",
28
+ "@gaia-ai/core": "^0.5.0",
29
29
  "@dropsh/plugin-oauth2": "^0.5.7",
30
30
  "@dropsh/plugin-jsonapi-schema": "^0.5.7",
31
31
  "commander": "^12.1.0",
32
- "dropsh": "^0.5.7"
32
+ "dropsh": "^0.5.7",
33
+ "semver": "^7.6.0"
33
34
  }
34
35
  }