@gaia-ai/conductor 0.5.3 → 0.5.4

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.
@@ -396,8 +396,9 @@ export function buildProgram(deps) {
396
396
  program
397
397
  .name('gaia')
398
398
  .description('GAIA conductor + client CLI')
399
- .option('--conductor <name>', 'select a named .gaia/<name>.config.js (default: the sole config; env $GAIA_CONDUCTOR)');
400
- // GAIA-126: a repo may hold several named .gaia/<stem>.config.js conductors.
399
+ .option('--conductor <name>', 'select a conductor by stem (conductor→conductor.config.js, else <name>.conductor.config.js; default: conductor.config.js, else the sole config; env $GAIA_CONDUCTOR)');
400
+ // GAIA-126/137: a repo may hold several .gaia/ conductor configs — the default
401
+ // conductor.config.js plus <stem>.conductor.config.js variants.
401
402
  // resolveConfigPath already honours $GAIA_CONDUCTOR; thread the global
402
403
  // --conductor flag into it (flag wins over env) so every command selects the
403
404
  // named config without per-command wiring. A per-invocation flag beats any
@@ -5,9 +5,9 @@ import type { AgentCandidate, ConductorFileConfig } from '@gaia-ai/core';
5
5
  * is closed automatically when the ticket state changes on the next claim - the
6
6
  * agent does not release it. Run mechanics live here (not in the repo's
7
7
  * WORKFLOW.md). The prompt routes through the gaia skill (`ticket:run`) rather
8
- * than pointing at WORKFLOW.md directly (GAIA-125): the intake splash + state
8
+ * than pointing at WORKFLOW.md directly (GAIA-125): the intake line + state
9
9
  * engine are a skill mechanic, so a bare "follow WORKFLOW.md" pointer left the
10
- * splash unrendered unless an external skill-forcing hook happened to fire. A
10
+ * intake line unprinted unless an external skill-forcing hook happened to fire. A
11
11
  * conductor config may override via the `prompt` field.
12
12
  *
13
13
  * The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
@@ -47,12 +47,14 @@ export declare function resolveAgents(raw: unknown, configPath: string): Promise
47
47
  * Resolve the conductor config path from `cwd`.
48
48
  *
49
49
  * 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
50
- * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding `*.config.js`
50
+ * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding a conductor config
51
51
  * — so any subdirectory of a project/worktree resolves the same dir.
52
- * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves
53
- * `<gaiaDir>/<name>.config.js` (error listing the stems if absent).
54
- * - No selector: exactly one config use it (back-compat: the lone
55
- * `conductor.config.js`); manyerror naming the stems + the selector.
52
+ * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves the stem's
53
+ * file (`conductor` `conductor.config.js`, else
54
+ * `<name>.conductor.config.js`; error listing the stems if absent).
55
+ * - No selector: `conductor.config.js` presentit is the default; else
56
+ * exactly one config → use it (back-compat); else → error naming the
57
+ * stems + the selector.
56
58
  * 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
57
59
  * (never leak a raw "Cannot find module" from a later import()).
58
60
  */
@@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url';
8
8
  * is closed automatically when the ticket state changes on the next claim - the
9
9
  * agent does not release it. Run mechanics live here (not in the repo's
10
10
  * WORKFLOW.md). The prompt routes through the gaia skill (`ticket:run`) rather
11
- * than pointing at WORKFLOW.md directly (GAIA-125): the intake splash + state
11
+ * than pointing at WORKFLOW.md directly (GAIA-125): the intake line + state
12
12
  * engine are a skill mechanic, so a bare "follow WORKFLOW.md" pointer left the
13
- * splash unrendered unless an external skill-forcing hook happened to fire. A
13
+ * intake line unprinted unless an external skill-forcing hook happened to fire. A
14
14
  * conductor config may override via the `prompt` field.
15
15
  *
16
16
  * The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
@@ -40,7 +40,7 @@ export const DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identi
40
40
  `Invoke the gaia skill and run \`ticket:run {identifier} {state}\` — it first ` +
41
41
  `reads the ticket + all comments ` +
42
42
  `(gaia dropsh read gaia_ticket/gaia_ticket/$GAIA_ID --include comments), ` +
43
- `renders the intake splash, runs the {state} engine, and applies ` +
43
+ `prints an intake line, runs the {state} engine, and applies ` +
44
44
  `WORKFLOW.md's {state} policy. Do ONLY the {state} work — never start or ` +
45
45
  `prepare a later state.`;
46
46
  function requirePlugin(value, kind) {
@@ -179,18 +179,36 @@ function optionalPositiveInteger(value, fallback, key) {
179
179
  }
180
180
  return value;
181
181
  }
182
- /** A repo's config files live in this dir, one `<stem>.config.js` per conductor. */
182
+ /** A repo's config files live in this dir, one conductor config per conductor. */
183
183
  const GAIA_DIR = '.gaia';
184
- /** List the `*.config.js` stems in a `.gaia/` dir (e.g. `shop.config.js` → `shop`). */
184
+ /** The default conductor's file name; its stem is `conductor`. */
185
+ const DEFAULT_CONFIG = 'conductor.config.js';
186
+ /** Variant files are `<stem>.conductor.config.js`. */
187
+ const VARIANT_SUFFIX = '.conductor.config.js';
188
+ /**
189
+ * List the conductor-config stems in a `.gaia/` dir. A file is a conductor
190
+ * config iff it is exactly `conductor.config.js` (stem `conductor`, the
191
+ * default) or ends with `.conductor.config.js` (stem = the leading part).
192
+ * Every other file (`vite.config.js`, the near-miss `myconductor.config.js`)
193
+ * is ignored.
194
+ */
185
195
  function configStems(gaiaDir) {
186
196
  return readdirSync(gaiaDir)
187
- .filter((f) => f.endsWith('.config.js'))
188
- .map((f) => f.slice(0, -'.config.js'.length))
197
+ .map((f) => f === DEFAULT_CONFIG
198
+ ? 'conductor'
199
+ : f.endsWith(VARIANT_SUFFIX)
200
+ ? f.slice(0, -VARIANT_SUFFIX.length)
201
+ : undefined)
202
+ .filter((s) => s !== undefined)
189
203
  .sort();
190
204
  }
205
+ /** Map a conductor stem back to its file name. */
206
+ function fileForStem(stem) {
207
+ return stem === 'conductor' ? DEFAULT_CONFIG : `${stem}${VARIANT_SUFFIX}`;
208
+ }
191
209
  /**
192
210
  * Walk from `cwd` root-ward (git/eslint style) to the nearest ancestor whose
193
- * `.gaia/` dir holds at least one `*.config.js`; return that `.gaia/` dir, or
211
+ * `.gaia/` dir holds at least one conductor config; return that `.gaia/` dir, or
194
212
  * `undefined` if none is found up to the filesystem root.
195
213
  */
196
214
  function findGaiaDir(cwd) {
@@ -211,12 +229,14 @@ function findGaiaDir(cwd) {
211
229
  * Resolve the conductor config path from `cwd`.
212
230
  *
213
231
  * 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
214
- * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding `*.config.js`
232
+ * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding a conductor config
215
233
  * — so any subdirectory of a project/worktree resolves the same dir.
216
- * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves
217
- * `<gaiaDir>/<name>.config.js` (error listing the stems if absent).
218
- * - No selector: exactly one config use it (back-compat: the lone
219
- * `conductor.config.js`); manyerror naming the stems + the selector.
234
+ * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves the stem's
235
+ * file (`conductor` `conductor.config.js`, else
236
+ * `<name>.conductor.config.js`; error listing the stems if absent).
237
+ * - No selector: `conductor.config.js` presentit is the default; else
238
+ * exactly one config → use it (back-compat); else → error naming the
239
+ * stems + the selector.
220
240
  * 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
221
241
  * (never leak a raw "Cannot find module" from a later import()).
222
242
  */
@@ -232,14 +252,21 @@ export function resolveConfigPath(override, cwd = process.cwd(), conductorName)
232
252
  const stems = configStems(gaiaDir);
233
253
  const name = conductorName ?? process.env.GAIA_CONDUCTOR;
234
254
  if (name !== undefined && name !== '') {
235
- const candidate = join(gaiaDir, `${name}.config.js`);
255
+ const candidate = join(gaiaDir, fileForStem(name));
236
256
  if (!existsSync(candidate)) {
237
257
  throw new Error(`no conductor '${name}' in ${gaiaDir}; available: ${stems.join(', ')}`);
238
258
  }
239
259
  return candidate;
240
260
  }
261
+ // No selector: the file named conductor.config.js is the default (AC-1, AC-2).
262
+ // Two conductor.config.js files cannot coexist in one dir, so ">1 default"
263
+ // (AC-5) is structurally impossible — no ambiguity branch is needed.
264
+ if (stems.includes('conductor')) {
265
+ return join(gaiaDir, DEFAULT_CONFIG);
266
+ }
267
+ // Back-compat: a lone config resolves even without the default name.
241
268
  if (stems.length === 1) {
242
- return join(gaiaDir, `${stems[0]}.config.js`);
269
+ return join(gaiaDir, fileForStem(stems[0]));
243
270
  }
244
271
  throw new Error(`${stems.length} conductors in ${gaiaDir} (${stems.join(', ')}); ` +
245
272
  'select one with --conductor <name> or $GAIA_CONDUCTOR');
@@ -141,16 +141,24 @@ export class Conductor {
141
141
  await this.dispatch(run);
142
142
  }
143
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.
144
+ // GAIA-149: terminalise the run to `failed` with the error captured,
145
+ // instead of leaving it `claimed` to expire after the ~300s lease (the
146
+ // old silent stall). `failed` is a terminal sibling of `expired`
147
+ // (dispatch/setup error vs lease lapse); it does NOT reopen the ticket
148
+ // the way the GAIA-41-era fabricated `done` did, so there is no runaway.
149
+ // Marking it now frees the ticket immediately and records *why* it
150
+ // failed; a persistently-failing setup is then parked by the server-side
151
+ // circuit breaker (TicketClaimService::assertNotCircuitBroken) after the
152
+ // retry cap, not by a hot re-dispatch loop. Best-effort: a markFailed
153
+ // that itself errors must not stop the claim loop, so it is swallowed
154
+ // (the run then falls back to lease-expiry recovery).
153
155
  this.logger.error({ run: run.runUuid, err: String(err) }, 'dispatch failed');
156
+ try {
157
+ await this.remote.markFailed(run.runUuid, String(err));
158
+ }
159
+ catch (markErr) {
160
+ this.logger.warn({ run: run.runUuid, err: String(markErr) }, 'markFailed failed');
161
+ }
154
162
  }
155
163
  count += 1;
156
164
  claimed += 1;
@@ -176,15 +184,16 @@ export class Conductor {
176
184
  const runs = await this.remote.fetchFinalizableRuns(this.id);
177
185
  for (const r of runs) {
178
186
  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.
187
+ // Signal the agent to stop before capturing its log. Best-effort and
188
+ // gated on the persistent capability (only hosted executors have a live
189
+ // agent) — mirrors removeWorktree's gate. For herdr this is a no-op
190
+ // (the tab-close in cleanupRun kills the PTY); a stop failure must
191
+ // never block finalisation.
183
192
  if (this.executor.capabilities().persistent) {
184
193
  const branch = await this.remote.getRunTicketBranchName(r.runUuid);
185
194
  if (branch) {
186
195
  try {
187
- await this.executor.stopAgent(branch);
196
+ await this.executor.stopRun(branch);
188
197
  }
189
198
  catch (err) {
190
199
  this.logger.warn({ run: r.runUuid, err: String(err) }, 'agent stop failed');
@@ -202,14 +211,16 @@ export class Conductor {
202
211
  ? await resolved.agent.getRunLog(r.worktreePath)
203
212
  : '';
204
213
  // Footprint (GAIA-132): the agent parses its own transcript for the
205
- // effort metrics (transcript format is agent-specific); the conductor
206
- // adds duration_s from started_at → now. The conductor owns the run, so
207
- // this write always lands (the agent session could not).
214
+ // effort metrics AND its wall-clock duration_s (from the transcript's
215
+ // first→last timestamps GAIA-151). Deriving duration from the log
216
+ // keeps it a single source with one format, avoiding the earlier
217
+ // `started_at` re-read that mis-parsed the JSON:API ISO string to 0.
218
+ // The conductor owns the run, so this write always lands (the agent
219
+ // session could not).
208
220
  const parsed = resolved.agent.parseFootprint(log);
209
- const now = Math.floor(Date.now() / 1000);
210
221
  const metrics = {
211
222
  tokens: parsed.tokens,
212
- duration_s: r.startedAt !== undefined ? Math.max(0, now - r.startedAt) : 0,
223
+ duration_s: parsed.duration_s,
213
224
  agent_turns: parsed.agent_turns,
214
225
  tool_calls: parsed.tool_calls,
215
226
  user_prompts: parsed.user_prompts,
@@ -217,6 +228,21 @@ export class Conductor {
217
228
  };
218
229
  await this.remote.finalizeRun(r.runUuid, log, metrics);
219
230
  this.logger.info({ run: r.runUuid, footprint: metrics }, 'run finalised');
231
+ // Tab-close is the LAST finalisation step (GAIA-171): the transcript is
232
+ // now captured, so killing the PTY is safe. Best-effort + gated on the
233
+ // persistent capability (only hosted executors have a tab), self-guarded
234
+ // so a close failure never mislabels the run as "finalise failed".
235
+ if (this.executor.capabilities().persistent) {
236
+ const branch = await this.remote.getRunTicketBranchName(r.runUuid);
237
+ if (branch) {
238
+ try {
239
+ await this.executor.cleanupRun(branch);
240
+ }
241
+ catch (err) {
242
+ this.logger.warn({ run: r.runUuid, err: String(err) }, 'run tab cleanup failed');
243
+ }
244
+ }
245
+ }
220
246
  }
221
247
  catch (err) {
222
248
  this.logger.warn({ run: r.runUuid, err: String(err) }, 'run finalise failed');
@@ -360,7 +386,8 @@ export class Conductor {
360
386
  await this.executor.runHook('after_create', ws.path, { ticket: t.identifier }, env);
361
387
  }
362
388
  await this.executor.runHook('before_run', ws.path, { ticket: t.identifier }, env);
363
- await this.executor.retire(t.branchName);
389
+ // Clear any leftover tabs of a reused workspace before starting the run.
390
+ await this.executor.cleanupRun(t.branchName);
364
391
  const prompt = ws.instructions
365
392
  ? renderPrompt(this.config.prompt, {
366
393
  identifier: t.identifier,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/conductor",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "directory": "conductor/packages/conductor"
26
26
  },
27
27
  "dependencies": {
28
- "@gaia-ai/core": "^0.5.3",
28
+ "@gaia-ai/core": "^0.5.4",
29
29
  "@dropsh/plugin-oauth2": "^0.5.7",
30
30
  "@dropsh/plugin-jsonapi-schema": "^0.5.7",
31
31
  "commander": "^12.1.0",