@gaia-ai/conductor 0.4.3 → 0.4.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.
@@ -1,4 +1,5 @@
1
1
  import { type ConductorFileConfig, type ConductorLogger, type GaiaAgent, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace } from '@gaia-ai/core';
2
+ import { Command } from 'commander';
2
3
  /** Test seam: inject any subset of dependencies. */
3
4
  export interface GaiaCliDeps {
4
5
  remote?: GaiaRemote;
@@ -15,5 +16,6 @@ export declare function parseHerdrJson(output: string, command: string): unknown
15
16
  * when unauthenticated.
16
17
  */
17
18
  export declare function ensureAuthenticated(config: ConductorFileConfig, logger: ConductorLogger): Promise<boolean>;
19
+ export declare function buildProgram(deps: GaiaCliDeps): Command;
18
20
  export declare function runGaiaCli(argv: string[], deps?: GaiaCliDeps): Promise<void>;
19
21
  export declare function main(argv: string[]): Promise<void>;
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { CommandRunner, conductorId, createLogger, exec, setDefaultCommandRunner, } from '@gaia-ai/core';
5
+ import { CommandRunner, createLogger, exec, setDefaultCommandRunner, } from '@gaia-ai/core';
6
6
  import { selectAgent, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
7
7
  import { Command } from 'commander';
8
8
  import { authStatus, buildProgram as buildDropshProgram } from 'dropsh';
@@ -32,13 +32,17 @@ function checkoutRootOf(config) {
32
32
  return dirname(config.config_path);
33
33
  }
34
34
  /**
35
- * The conductor's stable identity (gaia_conductor.machine_id). The loader
36
- * always resolves this (config override or hostname+path hash), so lifecycle
37
- * commands read it here instead of re-deriving the hash otherwise a pinned
38
- * machine_id and the CLI's id would diverge.
35
+ * The conductor's stable identity (gaia_conductor.machine_id). machine_id is
36
+ * required the loader (loadConductorConfig) throws without it so lifecycle
37
+ * commands read it here, never re-derive. A config not built by the loader that
38
+ * somehow lacks it fails loudly rather than silently minting a divergent id.
39
39
  */
40
40
  function conductorIdOf(config) {
41
- return config.machine_id ?? conductorId(checkoutRootOf(config));
41
+ const id = config.machine_id;
42
+ if (id === undefined || id.trim() === '') {
43
+ throw new Error('conductor config has no machine_id');
44
+ }
45
+ return id;
42
46
  }
43
47
  /** Read the `conductor`-level --log-level / --log-sink flags from a subcommand. */
44
48
  function logOptsOf(cmd) {
@@ -388,9 +392,23 @@ function resolveCliVersion() {
388
392
  }
389
393
  return '0.0.0';
390
394
  }
391
- function buildProgram(deps) {
395
+ export function buildProgram(deps) {
392
396
  const program = new Command();
393
- program.name('gaia').description('GAIA conductor + client CLI');
397
+ program
398
+ .name('gaia')
399
+ .description('GAIA conductor + client CLI')
400
+ .option('--conductor <name>', 'select a named .gaia/<name>.config.js (default: the sole config; env $GAIA_CONDUCTOR)');
401
+ // GAIA-126: a repo may hold several named .gaia/<stem>.config.js conductors.
402
+ // resolveConfigPath already honours $GAIA_CONDUCTOR; thread the global
403
+ // --conductor flag into it (flag wins over env) so every command selects the
404
+ // named config without per-command wiring. A per-invocation flag beats any
405
+ // ambient env for this run.
406
+ program.hook('preAction', () => {
407
+ const name = program.opts().conductor;
408
+ if (typeof name === 'string' && name !== '') {
409
+ process.env.GAIA_CONDUCTOR = name;
410
+ }
411
+ });
394
412
  const cliVersion = resolveCliVersion();
395
413
  program.version(cliVersion); // -V, --version
396
414
  program
@@ -1,4 +1,4 @@
1
- import { type ConductorFileConfig } from '@gaia-ai/core';
1
+ import type { 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
@@ -12,29 +12,40 @@ import { type ConductorFileConfig } from '@gaia-ai/core';
12
12
  *
13
13
  * The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
14
14
  * ticket content into a single typed pane line overran the PTY canonical line
15
- * cap (MAX_CANON = 4096 B) and truncated the dispatch command. Instead the
16
- * prompt is a bounded, constant-size pointer and the agent reads the ticket +
17
- * all comments at run start via `gaia dropsh read … --include comments`.
15
+ * cap and truncated the dispatch command. Instead the prompt is a bounded,
16
+ * constant-size pointer and the agent reads the ticket + all comments at run
17
+ * start via `gaia dropsh read … --include comments`.
18
+ *
19
+ * HINT — the canonical cap is platform-specific: MAX_CANON is 4096 B on Linux
20
+ * but only 1024 B on macOS (a whole line >= 1024 B is silently DROPPED there).
21
+ * herdr types env-prefix + this prompt + flags as ONE line, so keep the total
22
+ * well under 1024 B — i.e. keep this prompt short (~a few hundred bytes). Do NOT
23
+ * grow it back toward the old ~1 KB, or macOS dispatch truncates silently again.
18
24
  *
19
25
  * The prompt is a SINGLE LINE — no newlines, no control chars (GAIA-128). herdr
20
26
  * types it into the pane as one line, so a newline would submit early and any
21
27
  * control char would force bash-only `$'…'` quoting that fish can't parse.
22
28
  * Being single-line + bounded, plain `'…'` shell-quoting suffices (fish-safe)
23
- * and the typed line stays well under the 4096 B cap so NO base64/`bash -c`
24
- * wrapper and NO multiline handling are needed (that wrapper, GAIA-118, was the
25
- * thing that re-inflated the line past the cap and truncated it mid-quote).
29
+ * and the typed line stays well under the cap (1024 B on macOS, 4096 B on Linux)
30
+ * — so NO base64/`bash -c` wrapper and NO multiline handling are needed (that
31
+ * wrapper, GAIA-118, was the thing that re-inflated the line past the cap and
32
+ * truncated it mid-quote).
26
33
  * Placeholders: `{identifier}`, `{state}`, `{runUuid}` ({state} falls back to
27
34
  * `triage` for unclassified tickets).
28
35
  */
29
36
  export declare const DEFAULT_AGENT_PROMPT: string;
30
37
  /**
31
- * Resolve the conductor config path from `cwd`. An explicit `--config`
32
- * override or `$GAIA_CONDUCTOR_CONFIG` wins verbatim (short-circuit, no
33
- * filesystem walk). Otherwise walk from `cwd` root-ward (git/eslint style) to
34
- * the nearest ancestor holding `.gaia/conductor.config.js` and return that
35
- * absolute path — so any subdirectory of a project/worktree resolves the same
36
- * config. If none is found up to the filesystem root, throw a clear, actionable
37
- * error instead of leaking a raw "Cannot find module" from a later import().
38
+ * Resolve the conductor config path from `cwd`.
39
+ *
40
+ * 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
41
+ * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding `*.config.js`
42
+ * — so any subdirectory of a project/worktree resolves the same dir.
43
+ * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves
44
+ * `<gaiaDir>/<name>.config.js` (error listing the stems if absent).
45
+ * - No selector: exactly one config → use it (back-compat: the lone
46
+ * `conductor.config.js`); many → error naming the stems + the selector.
47
+ * 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
48
+ * (never leak a raw "Cannot find module" from a later import()).
38
49
  */
39
- export declare function resolveConfigPath(override?: string, cwd?: string): string;
50
+ export declare function resolveConfigPath(override?: string, cwd?: string, conductorName?: string): string;
40
51
  export declare function loadConductorConfig(configFile: string): Promise<ConductorFileConfig>;
@@ -1,8 +1,7 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { conductorId, } from '@gaia-ai/core';
6
5
  /**
7
6
  * Default agent prompt - the GAIA run contract. One run works EXACTLY one state;
8
7
  * the agent must stop instead of running the whole flow in one session. The run
@@ -16,34 +15,34 @@ import { conductorId, } from '@gaia-ai/core';
16
15
  *
17
16
  * The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
18
17
  * ticket content into a single typed pane line overran the PTY canonical line
19
- * cap (MAX_CANON = 4096 B) and truncated the dispatch command. Instead the
20
- * prompt is a bounded, constant-size pointer and the agent reads the ticket +
21
- * all comments at run start via `gaia dropsh read … --include comments`.
18
+ * cap and truncated the dispatch command. Instead the prompt is a bounded,
19
+ * constant-size pointer and the agent reads the ticket + all comments at run
20
+ * start via `gaia dropsh read … --include comments`.
21
+ *
22
+ * HINT — the canonical cap is platform-specific: MAX_CANON is 4096 B on Linux
23
+ * but only 1024 B on macOS (a whole line >= 1024 B is silently DROPPED there).
24
+ * herdr types env-prefix + this prompt + flags as ONE line, so keep the total
25
+ * well under 1024 B — i.e. keep this prompt short (~a few hundred bytes). Do NOT
26
+ * grow it back toward the old ~1 KB, or macOS dispatch truncates silently again.
22
27
  *
23
28
  * The prompt is a SINGLE LINE — no newlines, no control chars (GAIA-128). herdr
24
29
  * types it into the pane as one line, so a newline would submit early and any
25
30
  * control char would force bash-only `$'…'` quoting that fish can't parse.
26
31
  * Being single-line + bounded, plain `'…'` shell-quoting suffices (fish-safe)
27
- * and the typed line stays well under the 4096 B cap so NO base64/`bash -c`
28
- * wrapper and NO multiline handling are needed (that wrapper, GAIA-118, was the
29
- * thing that re-inflated the line past the cap and truncated it mid-quote).
32
+ * and the typed line stays well under the cap (1024 B on macOS, 4096 B on Linux)
33
+ * — so NO base64/`bash -c` wrapper and NO multiline handling are needed (that
34
+ * wrapper, GAIA-118, was the thing that re-inflated the line past the cap and
35
+ * truncated it mid-quote).
30
36
  * Placeholders: `{identifier}`, `{state}`, `{runUuid}` ({state} falls back to
31
37
  * `triage` for unclassified tickets).
32
38
  */
33
39
  export const DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identifier}, current state: {state}. ` +
34
- `Invoke the gaia skill and run \`ticket:run {identifier} {state}\` — it renders ` +
35
- `the intake splash, runs the {state} engine, and applies WORKFLOW.md's {state} policy. ` +
36
- `This run covers EXACTLY the {state} state - do only its work. Do not start, ` +
37
- `prepare, or perform any later state's work, even if WORKFLOW.md mentions ` +
38
- `transitioning onward. When the {state} work is done and you have no open ` +
39
- `questions, STOP - do not pick up or work any further state or ticket. If ` +
40
- `blocked or you need a human, stop and surface the blocker. After you have ` +
41
- `written the ticket's next state, run /exit to end this session. ` +
42
- `First, read the ticket + all comments in one call: run ` +
43
- `gaia dropsh read gaia_ticket/gaia_ticket/$GAIA_ID --include comments — the ` +
44
- `latest \`summary\` comment is review feedback you MUST address; any ` +
45
- `\`spec\`/\`diagnose\` comment is the agreed plan. Treat them as in-scope ` +
46
- `acceptance criteria.`;
40
+ `Invoke the gaia skill and run \`ticket:run {identifier} {state}\` — it first ` +
41
+ `reads the ticket + all comments ` +
42
+ `(gaia dropsh read gaia_ticket/gaia_ticket/$GAIA_ID --include comments), ` +
43
+ `renders the intake splash, runs the {state} engine, and applies ` +
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.`;
47
46
  function requirePlugin(value, kind) {
48
47
  if (!isRecord(value) || value.kind !== kind) {
49
48
  throw new Error(`conductor config requires a ${kind} plugin in the "${kind}" slot`);
@@ -123,13 +122,19 @@ async function resolveSlot(value, kind, configPath) {
123
122
  /**
124
123
  * Resolve the `plugins[]` array: descriptor entries are constructed, already-
125
124
  * constructed entries pass through. No kind-guard (plugins are not slotted).
125
+ *
126
+ * A factory may return one plugin OR an array of plugins — an aggregator built
127
+ * with dropsh's `composePlugins(a(), b())` returns the flat child list. dropsh's
128
+ * own `loadConfig` flattens such a nested entry one level; we mirror that here so
129
+ * `buildProgram({ plugins })` — which reads each entry's hooks/renderers per
130
+ * top-level element and does NOT recurse — sees every child.
126
131
  */
127
132
  async function resolvePlugins(raw, configPath) {
128
133
  if (!Array.isArray(raw)) {
129
134
  return [];
130
135
  }
131
136
  const resolved = await Promise.all(raw.map((entry) => isPluginDescriptor(entry) ? loadNamedPlugin(entry, configPath) : entry));
132
- return resolved;
137
+ return resolved.flat();
133
138
  }
134
139
  function isRecord(value) {
135
140
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -149,35 +154,70 @@ function optionalPositiveInteger(value, fallback, key) {
149
154
  }
150
155
  return value;
151
156
  }
152
- /** The conductor config lives at .gaia/conductor.config.js inside a project root. */
153
- const CONFIG_RELATIVE_PATH = join('.gaia', 'conductor.config.js');
157
+ /** A repo's config files live in this dir, one `<stem>.config.js` per conductor. */
158
+ const GAIA_DIR = '.gaia';
159
+ /** List the `*.config.js` stems in a `.gaia/` dir (e.g. `shop.config.js` → `shop`). */
160
+ function configStems(gaiaDir) {
161
+ return readdirSync(gaiaDir)
162
+ .filter((f) => f.endsWith('.config.js'))
163
+ .map((f) => f.slice(0, -'.config.js'.length))
164
+ .sort();
165
+ }
154
166
  /**
155
- * Resolve the conductor config path from `cwd`. An explicit `--config`
156
- * override or `$GAIA_CONDUCTOR_CONFIG` wins verbatim (short-circuit, no
157
- * filesystem walk). Otherwise walk from `cwd` root-ward (git/eslint style) to
158
- * the nearest ancestor holding `.gaia/conductor.config.js` and return that
159
- * absolute path — so any subdirectory of a project/worktree resolves the same
160
- * config. If none is found up to the filesystem root, throw a clear, actionable
161
- * error instead of leaking a raw "Cannot find module" from a later import().
167
+ * Walk from `cwd` root-ward (git/eslint style) to the nearest ancestor whose
168
+ * `.gaia/` dir holds at least one `*.config.js`; return that `.gaia/` dir, or
169
+ * `undefined` if none is found up to the filesystem root.
162
170
  */
163
- export function resolveConfigPath(override, cwd = process.cwd()) {
164
- const explicit = override ?? process.env.GAIA_CONDUCTOR_CONFIG;
165
- if (explicit) {
166
- return explicit;
167
- }
171
+ function findGaiaDir(cwd) {
168
172
  let dir = resolve(cwd);
169
173
  for (;;) {
170
- const candidate = join(dir, CONFIG_RELATIVE_PATH);
171
- if (existsSync(candidate)) {
172
- return candidate;
174
+ const gaiaDir = join(dir, GAIA_DIR);
175
+ if (existsSync(gaiaDir) && configStems(gaiaDir).length > 0) {
176
+ return gaiaDir;
173
177
  }
174
178
  const parent = dirname(dir);
175
179
  if (parent === dir) {
176
- break;
180
+ return undefined;
177
181
  }
178
182
  dir = parent;
179
183
  }
180
- throw new Error(`no .gaia/conductor.config.js found from ${cwd} upward; run \`gaia init\``);
184
+ }
185
+ /**
186
+ * Resolve the conductor config path from `cwd`.
187
+ *
188
+ * 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
189
+ * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding `*.config.js`
190
+ * — so any subdirectory of a project/worktree resolves the same dir.
191
+ * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves
192
+ * `<gaiaDir>/<name>.config.js` (error listing the stems if absent).
193
+ * - No selector: exactly one config → use it (back-compat: the lone
194
+ * `conductor.config.js`); many → error naming the stems + the selector.
195
+ * 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
196
+ * (never leak a raw "Cannot find module" from a later import()).
197
+ */
198
+ export function resolveConfigPath(override, cwd = process.cwd(), conductorName) {
199
+ const explicit = override ?? process.env.GAIA_CONDUCTOR_CONFIG;
200
+ if (explicit) {
201
+ return explicit;
202
+ }
203
+ const gaiaDir = findGaiaDir(cwd);
204
+ if (gaiaDir === undefined) {
205
+ throw new Error(`no .gaia/conductor.config.js found from ${cwd} upward; run \`gaia init\``);
206
+ }
207
+ const stems = configStems(gaiaDir);
208
+ const name = conductorName ?? process.env.GAIA_CONDUCTOR;
209
+ if (name !== undefined && name !== '') {
210
+ const candidate = join(gaiaDir, `${name}.config.js`);
211
+ if (!existsSync(candidate)) {
212
+ throw new Error(`no conductor '${name}' in ${gaiaDir}; available: ${stems.join(', ')}`);
213
+ }
214
+ return candidate;
215
+ }
216
+ if (stems.length === 1) {
217
+ return join(gaiaDir, `${stems[0]}.config.js`);
218
+ }
219
+ throw new Error(`${stems.length} conductors in ${gaiaDir} (${stems.join(', ')}); ` +
220
+ 'select one with --conductor <name> or $GAIA_CONDUCTOR');
181
221
  }
182
222
  export async function loadConductorConfig(configFile) {
183
223
  const configPath = resolve(configFile);
@@ -194,14 +234,15 @@ export async function loadConductorConfig(configFile) {
194
234
  throw new Error('conductor config requires non-empty states');
195
235
  }
196
236
  const states = config.states.map((state) => requireNonEmptyString(state, 'states'));
197
- const checkoutRoot = dirname(configPath);
198
- // Resolve the effective machine_id once (config override or hostname+path
199
- // hash) so the id is defined in a single place the CLI lifecycle commands
200
- // and registration all read config.machine_id, never re-derive it. The
201
- // conductor label defaults to this id (a conductor is identified by it).
202
- const machineId = typeof config.machine_id === 'string' && config.machine_id.trim() !== ''
203
- ? config.machine_id
204
- : conductorId(checkoutRoot);
237
+ // machine_id is required — the config MUST set it; there is no derived
238
+ // fallback. The id is defined in a single place (config.machine_id), which
239
+ // the CLI lifecycle commands and registration all read, never re-derive. The
240
+ // conductor label defaults to it (a conductor is identified by it). A repo's
241
+ // committed config composes it from the machine context
242
+ // (`${user_id}-${machine_id}-${project}`); with several named configs in one
243
+ // .gaia/ (GAIA-126) each composes its own from its own project, so co-located
244
+ // conductors get distinct identities with no filename-based fallback.
245
+ const machineId = requireNonEmptyString(config.machine_id, 'machine_id');
205
246
  const label = typeof config.label === 'string' && config.label.trim() !== ''
206
247
  ? config.label
207
248
  : machineId;
@@ -38,27 +38,25 @@ export declare class Conductor {
38
38
  * flag via {@link GaiaRemote.fetchUncleanedTickets}: every ticket that is
39
39
  * finished (state=done OR closed) but not yet cleaned. The SAME reconciliation
40
40
  * runs on every tick AND standalone as `gaia conductor reap`, and the query is
41
- * NOT machine-scoped — so it catches the orphans a live-tick-only path
42
- * structurally cannot (RC2/RC3): the conductor was down when the ticket hit
43
- * `done`, crashed mid-cleanup, or the ticket's `conductor_id` was reassigned.
44
- * A foreign (non-gaia) worktree has no gaia ticket, so it is never on the work
45
- * list and never touched foreign-safety falls out of the DB-driven model
46
- * with no host enumeration.
41
+ * scoped to THIS conductor (GAIA-121) — so it catches the orphans a
42
+ * live-tick-only path structurally cannot (the conductor was down when the
43
+ * ticket hit `done`, or crashed mid-cleanup) for its OWN tickets. A foreign
44
+ * (non-gaia) worktree has no gaia ticket, so it is never on the work list; a
45
+ * ticket assigned to another conductor is filtered out at the query — both are
46
+ * left untouched with no host enumeration.
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. Teardown is best-effort + verified — `cleaned_up` is
51
- * set only when the worktree was actually torn down ON THIS HOST. Since the
52
- * query is not machine-scoped, a conductor may see a finished ticket whose
53
- * worktree lives on ANOTHER host; `removeWorktree` returns `false` there (a
54
- * clean "not here") and the reaper DEFERS it does not flag the ticket
55
- * cleaned, leaving the teardown to the host that physically holds the
56
- * worktree. That gate is what keeps the not-machine-scoped query from
57
- * cross-host-false-teardowning a still-live workspace. A teardown that throws
58
- * logs loudly and leaves `cleaned_up=0`, so the next reconciliation retries;
59
- * one miss never orphans the workspace (RC1). Re-running on an already-cleaned
60
- * ticket is a no-op — it is off the list (idempotent). Each ticket is isolated
61
- * in a try/catch so one failure never aborts the rest.
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.
62
60
  *
63
61
  * The `after_done` hook runs through the executor (GAIA-84), which owns the
64
62
  * best-effort policy — it logs+swallows a hook failure and never throws, so
@@ -194,8 +194,22 @@ export class Conductor {
194
194
  const log = r.worktreePath
195
195
  ? await this.agent.getRunLog(r.worktreePath)
196
196
  : '';
197
- await this.remote.finalizeRun(r.runUuid, log);
198
- this.logger.info({ run: r.runUuid }, 'run finalised');
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');
199
213
  }
200
214
  catch (err) {
201
215
  this.logger.warn({ run: r.runUuid, err: String(err) }, 'run finalise failed');
@@ -208,34 +222,32 @@ export class Conductor {
208
222
  * flag via {@link GaiaRemote.fetchUncleanedTickets}: every ticket that is
209
223
  * finished (state=done OR closed) but not yet cleaned. The SAME reconciliation
210
224
  * runs on every tick AND standalone as `gaia conductor reap`, and the query is
211
- * NOT machine-scoped — so it catches the orphans a live-tick-only path
212
- * structurally cannot (RC2/RC3): the conductor was down when the ticket hit
213
- * `done`, crashed mid-cleanup, or the ticket's `conductor_id` was reassigned.
214
- * A foreign (non-gaia) worktree has no gaia ticket, so it is never on the work
215
- * list and never touched foreign-safety falls out of the DB-driven model
216
- * with no host enumeration.
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.
217
231
  *
218
232
  * Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
219
233
  * is closed at once as a lifecycle step, independent of whether its worktree
220
- * teardown then succeeds. Teardown is best-effort + verified — `cleaned_up` is
221
- * set only when the worktree was actually torn down ON THIS HOST. Since the
222
- * query is not machine-scoped, a conductor may see a finished ticket whose
223
- * worktree lives on ANOTHER host; `removeWorktree` returns `false` there (a
224
- * clean "not here") and the reaper DEFERS it does not flag the ticket
225
- * cleaned, leaving the teardown to the host that physically holds the
226
- * worktree. That gate is what keeps the not-machine-scoped query from
227
- * cross-host-false-teardowning a still-live workspace. A teardown that throws
228
- * logs loudly and leaves `cleaned_up=0`, so the next reconciliation retries;
229
- * one miss never orphans the workspace (RC1). Re-running on an already-cleaned
230
- * ticket is a no-op — it is off the list (idempotent). Each ticket is isolated
231
- * in a try/catch so one failure never aborts the rest.
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.
232
244
  *
233
245
  * The `after_done` hook runs through the executor (GAIA-84), which owns the
234
246
  * best-effort policy — it logs+swallows a hook failure and never throws, so
235
247
  * the core needs no per-call-site try/catch around it.
236
248
  */
237
249
  async reap() {
238
- const tickets = await this.remote.fetchUncleanedTickets();
250
+ const tickets = await this.remote.fetchUncleanedTickets(this.id);
239
251
  for (const t of tickets) {
240
252
  try {
241
253
  // Lifecycle: close a done+unclosed ticket at once, decoupled from the
@@ -259,9 +271,12 @@ export class Conductor {
259
271
  });
260
272
  }
261
273
  if (this.executor.capabilities().persistent) {
262
- let removed;
263
274
  try {
264
- removed = await this.executor.removeWorktree(t.branchName, t.worktreePath || undefined);
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);
265
280
  }
266
281
  catch (err) {
267
282
  this.logger.warn({
@@ -272,20 +287,6 @@ export class Conductor {
272
287
  }, 'worktree teardown failed');
273
288
  continue; // leave cleaned_up=0 → the next reconciliation retries.
274
289
  }
275
- // A recorded worktree path that was NOT present on this host belongs
276
- // to another conductor's host (the query is not machine-scoped, so we
277
- // see every finished ticket). Do NOT flag it cleaned — that would be a
278
- // cross-host false-teardown, orphaning a still-live workspace. Defer:
279
- // the host that physically holds it reaps + flags it. A ticket with no
280
- // recorded path (nothing hosted to locate) falls through to cleaned.
281
- if (t.worktreePath && !removed) {
282
- this.logger.info({
283
- ticket: t.ticketUuid,
284
- branch: t.branchName,
285
- worktreePath: t.worktreePath,
286
- }, 'worktree not on this host — deferring teardown to its host');
287
- continue;
288
- }
289
290
  }
290
291
  // Teardown verified locally (or nothing hosted to tear down): flag it
291
292
  // cleaned so it drops off the work list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/conductor",
3
- "version": "0.4.3",
3
+ "version": "0.4.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,10 +25,10 @@
25
25
  "directory": "conductor/packages/conductor"
26
26
  },
27
27
  "dependencies": {
28
- "@gaia-ai/core": "^0.4.3",
28
+ "@gaia-ai/core": "^0.4.4",
29
29
  "@dropsh/plugin-oauth2": "^0.4.1",
30
- "@dropsh/plugin-jsonapi-schema": "^0.5.1",
30
+ "@dropsh/plugin-jsonapi-schema": "^0.5.6",
31
31
  "commander": "^12.1.0",
32
- "dropsh": "^0.5.3"
32
+ "dropsh": "^0.5.6"
33
33
  }
34
34
  }