@gaia-ai/conductor 0.7.0 → 0.8.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,4 +1,4 @@
1
- import { type ConductorLogger, type GaiaCommandHost, type GaiaCommandPlugin } from '@gaia-ai/core';
1
+ import { type ConductorLogger, type GaiaCommandHost, type GaiaCommandPlugin, type ProcProbe } from '@gaia-ai/core';
2
2
  import { Command } from 'commander';
3
3
  import type { GaiaExecutor } from '../plugins/executor.js';
4
4
  import { type ResolvedAgent } from '../plugins/plugins.js';
@@ -14,6 +14,8 @@ export interface GaiaCliDeps {
14
14
  config?: ConductorFileConfig;
15
15
  /** Injectable registry fetch for the start-time version check (tests). */
16
16
  fetch?: typeof fetch;
17
+ /** Marker immediately before start hands control to conductor work. */
18
+ onStartLoop?: () => void;
17
19
  /** Host contract (resolveBases); default: this install + cwd. */
18
20
  host?: GaiaCommandHost;
19
21
  /**
@@ -23,6 +25,11 @@ export interface GaiaCliDeps {
23
25
  * machine.
24
26
  */
25
27
  herdr?: HerdrHost;
28
+ /**
29
+ * GAIA-232: the pid probe behind `stop`. Defaults to the real /proc reader —
30
+ * a test MUST inject a fake, or the suite signals real processes.
31
+ */
32
+ procProbe?: ProcProbe;
26
33
  }
27
34
  /**
28
35
  * The multiplexer seam of the conductor lifecycle: start a detached foreground
@@ -1,12 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import { CommandRunner, createLogger, exec, getRegisteredConductor, listRegisteredConductors, loadGaiaConfig, machineContextPath, readMachineContext, registerConductor, removeConductor, resolveConfigPath, setDefaultCommandRunner, } from '@gaia-ai/core';
4
+ import { CommandRunner, createLogger, deriveConductorLiveness, exec, fetchUpdateNotice, loadGaiaConfig, machineContextPath, printVersionLine, readMachineContext, resolveConfigPath, setDefaultCommandRunner, signalVerifiedProcess, } from '@gaia-ai/core';
5
5
  import { Command } from 'commander';
6
6
  import { authStatus } from 'dropsh';
7
7
  import { scaffold } from '../cli/init.js';
8
8
  import { runUpgrade } from '../cli/upgrade.js';
9
- import { fetchUpdateNotice, printVersionLine } from '../cli/version-check.js';
10
9
  import { composeConductorConfig, loadConductorConfig } from '../config.js';
11
10
  import { Conductor } from '../core/conductor.js';
12
11
  import { selectAgents, selectExecutor, selectRemote, selectWorkspace, } from '../plugins/plugins.js';
@@ -106,26 +105,18 @@ export const realHerdrHost = {
106
105
  function herdrHostOf(deps) {
107
106
  return deps.herdr ?? realHerdrHost;
108
107
  }
109
- // --- ls/status freshness ----------------------------------------------------
110
- const DEFAULT_FRESH_S = 120;
111
- function freshnessThresholdS(config) {
112
- return config
113
- ? Math.max(DEFAULT_FRESH_S, config.lease_seconds * 2)
114
- : DEFAULT_FRESH_S;
115
- }
116
- function classify(hub, freshS) {
117
- if (!hub) {
118
- return 'registry-only';
119
- }
120
- if (hub.status === 'offline') {
121
- return 'stopped';
122
- }
123
- const nowS = Math.floor(Date.now() / 1000);
124
- const fresh = hub.lastSeen > 0 && nowS - hub.lastSeen <= freshS;
125
- return fresh ? 'running' : 'wedged';
108
+ /**
109
+ * The liveness words this CLI prints.
110
+ *
111
+ * Delegated to core so `gaia conductor ls`, the cockpit and the server-side
112
+ * deriver describe one conductor identically. The previous CLI-only vocabulary
113
+ * (`wedged`, `stopped`, `host-missing`, `registry-only`) went with the registry
114
+ * that made those distinctions expressible.
115
+ */
116
+ function classify(hub) {
117
+ return deriveConductorLiveness(hub, Math.floor(Date.now() / 1000));
126
118
  }
127
- async function buildLsRows(remote, config, onlyId) {
128
- const entries = await listRegisteredConductors();
119
+ async function buildLsRows(remote, _config, onlyId) {
129
120
  let hub = [];
130
121
  try {
131
122
  hub = await remote.listConductors('me');
@@ -133,39 +124,23 @@ async function buildLsRows(remote, config, onlyId) {
133
124
  catch {
134
125
  hub = [];
135
126
  }
136
- const hubById = new Map(hub.map((c) => [c.id, c]));
137
- const freshS = freshnessThresholdS(config);
138
- const ids = new Set();
139
- for (const e of entries) {
140
- ids.add(e.id);
141
- }
142
- for (const c of hub) {
143
- ids.add(c.id);
144
- }
145
- const rows = [];
146
- for (const id of ids) {
147
- if (onlyId && id !== onlyId) {
148
- continue;
149
- }
150
- const entry = entries.find((e) => e.id === id);
151
- const h = hubById.get(id);
152
- rows.push({
153
- id,
154
- project: entry?.project ?? h?.project ?? '',
155
- label: entry?.label ?? h?.label ?? '',
156
- host: entry?.host ?? '-',
157
- status: classify(h, freshS),
158
- });
159
- }
160
- return rows;
127
+ return hub
128
+ .filter((c) => !onlyId || c.id === onlyId)
129
+ .map((c) => ({
130
+ id: c.id,
131
+ project: c.project,
132
+ label: c.label,
133
+ workspaceRoot: c.workspaceRoot,
134
+ status: classify(c),
135
+ }));
161
136
  }
162
137
  function printRows(rows) {
163
138
  if (rows.length === 0) {
164
- console.log('no conductors registered');
139
+ console.log('no conductors on the control plane');
165
140
  return;
166
141
  }
167
142
  for (const r of rows) {
168
- console.log(`${r.id}\t${r.status}\t${r.host}\t${r.project}\t${r.label}`);
143
+ console.log(`${r.id}\t${r.status}\t${r.project}\t${r.label}\t${r.workspaceRoot}`);
169
144
  }
170
145
  }
171
146
  // --- command handlers -------------------------------------------------------
@@ -222,6 +197,10 @@ async function cmdStartForeground(deps, log = {}) {
222
197
  const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
223
198
  const agents = deps.agents ?? (await selectAgents(config));
224
199
  const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
200
+ // This process IS the conductor, so it may report its own pid and config
201
+ // name — the two values a cockpit needs to stop it later. poll/reap build a
202
+ // Conductor too but never claim, so they cannot overwrite this pid.
203
+ conductor.claimServeProcess();
225
204
  await conductor.start();
226
205
  const controller = new AbortController();
227
206
  const onSignal = () => controller.abort();
@@ -233,19 +212,36 @@ async function cmdStartForeground(deps, log = {}) {
233
212
  finally {
234
213
  process.removeListener('SIGINT', onSignal);
235
214
  process.removeListener('SIGTERM', onSignal);
215
+ // The exiting process writes its own `offline`. It has to be this process:
216
+ // a heartbeat sets `online` unconditionally (ConductorRegistrar), so an
217
+ // offline written by anyone else is erased by the next tick — the status
218
+ // is only trustworthy when written after the heartbeats have stopped.
219
+ // Best-effort: a conductor that cannot reach the control plane on the way
220
+ // out must still exit, and the reaper flips it on lease expiry anyway.
221
+ try {
222
+ await remote.setConductorStatus(conductor.id, 'offline');
223
+ }
224
+ catch (err) {
225
+ logger.warn({ conductorId: conductor.id, err: String(err) }, 'could not write offline status on shutdown; the lease reaper will');
226
+ }
236
227
  }
237
228
  }
238
- async function cmdStart(deps, log = {}) {
229
+ async function cmdStart(deps, log = {}, updateCheck = true, body = cmdStartBody) {
239
230
  const current = printVersionLine();
240
- const noticePromise = fetchUpdateNotice(current, deps.fetch ?? globalThis.fetch).catch(() => null);
241
- try {
242
- await cmdStartBody(deps, log);
243
- }
244
- finally {
245
- const notice = await noticePromise;
231
+ if (updateCheck) {
232
+ const notice = await fetchUpdateNotice(current, {
233
+ fetchImpl: deps.fetch ?? globalThis.fetch,
234
+ ...(deps.fetch
235
+ ? {
236
+ cachePath: `/tmp/gaia-update-check-${process.pid}-${Math.random()}.json`,
237
+ }
238
+ : {}),
239
+ });
246
240
  if (notice !== null)
247
241
  console.log(notice);
248
242
  }
243
+ deps.onStartLoop?.();
244
+ await body(deps, log);
249
245
  }
250
246
  async function cmdStartBody(deps, log) {
251
247
  const config = await resolveConfig(deps);
@@ -255,30 +251,25 @@ async function cmdStartBody(deps, log) {
255
251
  return;
256
252
  const remote = await resolveRemote(deps, config);
257
253
  const id = conductorIdOf(config);
258
- const existing = await getRegisteredConductor(id);
259
- if (existing) {
260
- const hubStatus = await remote.getConductorStatus(id);
261
- if (hubStatus !== null && hubStatus !== 'offline') {
262
- console.log(`conductor already running for ${config.project}`);
263
- return;
264
- }
254
+ // Block on the DERIVED status, not the raw one. A crashed conductor keeps
255
+ // `status: online` on its record until the cron reaper notices the lapsed
256
+ // lease, and those are precisely the minutes in which someone wants to
257
+ // restart it. Only a conductor that is actually alive refuses a second start.
258
+ const existing = (await remote.listConductors('me')).find((c) => c.id === id);
259
+ if (existing && classify(existing) === 'running') {
260
+ console.log(`conductor already running for ${config.project}`);
261
+ return;
265
262
  }
263
+ // The conductor registers itself with the control plane on start(); there is
264
+ // no local file to write, and nothing left that a second list could add.
266
265
  const handle = `gaia-conductor:${id}`;
267
- await registerConductor({
268
- id,
269
- path: checkoutRoot,
270
- project: config.project,
271
- label: config.label,
272
- host: 'herdr',
273
- handle,
274
- });
275
266
  const fgFlags = [
276
267
  log.level ? `--log-level ${log.level}` : '',
277
268
  log.sink ? `--log-sink ${log.sink}` : '',
278
269
  ]
279
270
  .filter(Boolean)
280
271
  .join(' ');
281
- const fgCmd = `GAIA_CONDUCTOR_LOG=file gaia conductor ${fgFlags} start --foreground`.replace(/\s+/g, ' ');
272
+ const fgCmd = `GAIA_CONDUCTOR_LOG=file gaia conductor ${fgFlags} start --foreground --no-update-check`.replace(/\s+/g, ' ');
282
273
  try {
283
274
  await herdrHostOf(deps).spawn(handle, checkoutRoot, fgCmd);
284
275
  logger.info({ id, handle }, 'started conductor via herdr');
@@ -288,27 +279,45 @@ async function cmdStartBody(deps, log) {
288
279
  logger.warn({}, 'Hint: run `gaia conductor start --foreground` under a service manager (systemd-user / docker) on hosts without herdr.');
289
280
  }
290
281
  }
282
+ /**
283
+ * Stops this checkout's conductor with a verified signal.
284
+ *
285
+ * Two things this deliberately no longer does. It does not kill a herdr tab:
286
+ * that killed the pane the conductor happened to be shown in, which is neither
287
+ * necessary (a signal reaches the process wherever it lives) nor sufficient (a
288
+ * conductor started by systemd or a bare --foreground has no tab). And it does
289
+ * not write `offline` — a heartbeat sets `online` unconditionally, so a status
290
+ * written from here is erased by the next tick unless it happens to land after
291
+ * the process died. The exiting process writes its own, which is correct by
292
+ * construction rather than by ordering luck.
293
+ *
294
+ * `--now` differs only in the signal. It runs through the same verification
295
+ * gate, because a hard kill aimed at a recycled pid is the most damaging
296
+ * version of the same mistake.
297
+ */
291
298
  async function cmdStop(deps, now) {
292
299
  const config = await resolveConfig(deps);
293
300
  const remote = await resolveRemote(deps, config);
294
301
  const id = conductorIdOf(config);
295
- if (now) {
296
- const entry = await getRegisteredConductor(id);
297
- if (entry && entry.host === 'herdr') {
298
- await herdrHostOf(deps).kill(entry.handle);
299
- console.log(`hard-stopped conductor ${id} (${entry.handle})`);
300
- }
301
- else {
302
- console.log(`no herdr-hosted conductor to hard-stop for ${id}`);
303
- }
302
+ const row = (await remote.listConductors('me')).find((c) => c.id === id);
303
+ if (!row) {
304
+ console.log(`no conductor ${id} on the control plane — nothing to stop (it may never have started)`);
304
305
  return;
305
306
  }
306
- const entry = await getRegisteredConductor(id);
307
- if (entry && entry.host === 'herdr') {
308
- await herdrHostOf(deps).kill(entry.handle);
307
+ const signal = now ? 'SIGKILL' : 'SIGTERM';
308
+ const outcome = signalVerifiedProcess({
309
+ pid: row.lastPid,
310
+ workspaceRoot: row.workspaceRoot,
311
+ signal,
312
+ ...(deps.procProbe ? { probe: deps.procProbe } : {}),
313
+ });
314
+ if (!outcome.ok) {
315
+ console.log(`could not stop conductor ${id}: ${outcome.message}`);
316
+ return;
309
317
  }
310
- await remote.setConductorStatus(id, 'offline');
311
- console.log(`stopped conductor ${id} (offline)`);
318
+ console.log(now
319
+ ? `hard-stopped conductor ${id} (SIGKILL to pid ${outcome.pid}); a killed process writes no offline, so the status follows on lease expiry`
320
+ : `stopped conductor ${id} (SIGTERM to pid ${outcome.pid}); it writes its own offline as it exits`);
312
321
  }
313
322
  async function cmdLs(deps) {
314
323
  const config = deps.config ?? (await tryConfig(deps));
@@ -323,12 +332,6 @@ async function cmdStatus(deps) {
323
332
  const rows = await buildLsRows(remote, config, id);
324
333
  printRows(rows);
325
334
  }
326
- async function cmdRm(deps) {
327
- const config = await resolveConfig(deps);
328
- const id = conductorIdOf(config);
329
- await removeConductor(id);
330
- console.log(`removed conductor ${id} from registry`);
331
- }
332
335
  /** ls may run without a config file; best-effort load. */
333
336
  async function tryConfig(deps) {
334
337
  if (deps.config) {
@@ -402,12 +405,13 @@ Examples:
402
405
  .command('start')
403
406
  .description('start the conductor loop (herdr-hosted by default)')
404
407
  .option('--foreground', 'run the loop in this process', false)
408
+ .option('--no-update-check', 'skip the daily CLI update check')
405
409
  .action(async function (opts) {
406
410
  if (opts.foreground) {
407
- await cmdStartForeground(deps, logOptsOf(this));
411
+ await cmdStart(deps, logOptsOf(this), opts.updateCheck, cmdStartForeground);
408
412
  }
409
413
  else {
410
- await cmdStart(deps, logOptsOf(this));
414
+ await cmdStart(deps, logOptsOf(this), opts.updateCheck);
411
415
  }
412
416
  });
413
417
  conductor
@@ -441,12 +445,10 @@ Examples:
441
445
  .action(async () => {
442
446
  await cmdStatus(deps);
443
447
  });
444
- conductor
445
- .command('rm')
446
- .description('deregister the conductor for this checkout')
447
- .action(async () => {
448
- await cmdRm(deps);
449
- });
448
+ // `gaia conductor rm` is gone (GAIA-232). Its only job was deleting the local
449
+ // registry entry; with no registry there is nothing to remove, and deleting
450
+ // the control-plane record is explicitly not the model — a conductor that
451
+ // stops is offline, not absent.
450
452
  registerInit(conductor);
451
453
  return conductor;
452
454
  }
@@ -1,5 +1,5 @@
1
1
  export { type AgentFootprint, emptyAgentFootprint, type GaiaAgent, } from './plugins/agent.js';
2
- export type { ExecutorCapabilities, GaiaExecutor, HookContext, HookName, HookResult, SpawnedSession, SpawnRunInput, } from './plugins/executor.js';
2
+ export type { ExecutorCapabilities, GaiaExecutor, HookContext, HookName, HookResult, SpawnedSession, SpawnRunInput, WorktreeTeardown, } from './plugins/executor.js';
3
3
  export type { AgentCandidate, AgentPlugin, ExecutorDeps, ExecutorPlugin, RemotePlugin, ResolvedAgent, WorkspacePlugin, } from './plugins/plugins.js';
4
4
  export { selectAgent, selectAgents, selectExecutor, selectRemote, selectWorkspace, } from './plugins/plugins.js';
5
5
  export { type ConductorAddonEntry, type ConductorContributions, narrowConductorContributions, type Preset, } from './plugins/preset.js';
@@ -25,8 +25,35 @@ export declare class Conductor {
25
25
  private uuid;
26
26
  constructor(config: ConductorFileConfig, remote: GaiaRemote, executor: GaiaExecutor, workspace: GaiaWorkspace, agents: ResolvedAgent[], logger: ConductorLogger, checkoutRoot?: string);
27
27
  get id(): string;
28
+ /**
29
+ * Whether this instance is the serve-loop process itself (GAIA-232).
30
+ *
31
+ * Off by default so that only a caller which genuinely owns the process can
32
+ * report a pid — see {@link claimServeProcess}.
33
+ */
34
+ private ownsServeProcess;
35
+ /**
36
+ * Declares that this instance runs *as* the conductor process, so its
37
+ * registrations may carry the process identity a cockpit needs to stop it.
38
+ *
39
+ * Only `cmdStartForeground` calls this. `cmdPoll` and `cmdReap` also build a
40
+ * Conductor and call start() + tick(), then exit; if they reported a pid,
41
+ * every `gaia conductor poll` would overwrite the running conductor's pid
42
+ * with its own short-lived one and the next stop would signal a dead — or
43
+ * recycled — process. Making the report opt-in means those two commands
44
+ * cannot do so even by accident.
45
+ */
46
+ claimServeProcess(): void;
28
47
  /** This conductor's registration payload, built from config. */
29
48
  private registration;
49
+ /**
50
+ * The stem of the config file this conductor was loaded from.
51
+ *
52
+ * Derived through core's own mapping rather than by slicing the filename
53
+ * here, so the CLI and the conductor cannot disagree about which files count
54
+ * as a conductor config.
55
+ */
56
+ private get conductorName();
30
57
  start(): Promise<void>;
31
58
  tick(): Promise<void>;
32
59
  /**
@@ -52,14 +79,21 @@ export declare class Conductor {
52
79
  *
53
80
  * Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
54
81
  * is closed at once as a lifecycle step, independent of whether its worktree
55
- * teardown then succeeds. `cleaned_up` is flagged ONLY on a VERIFIED teardown
56
- * (GAIA-141 RC-2): `removeWorktree` returns `true` when the worktree is gone on
57
- * this host (removed by us, or confirmed already absent), `false` when it could
58
- * not be resolved and may still be on disk (e.g. its `worktree_path` was never
59
- * persisted). A `false` return like a THROW logs loudly and leaves
60
- * `cleaned_up=0`, so the next reconciliation retries; flagging cleaned on an
61
- * unverified teardown was the root cause of the ~28 never-touched leftovers,
62
- * because the ticket then dropped off the (cleaned_up=0) work list forever.
82
+ * teardown then succeeds. `cleaned_up` is withheld unless the teardown is
83
+ * ACCOUNTED FOR (GAIA-141 RC-2), which `removeWorktree` reports as one of
84
+ * three outcomes (GAIA-293):
85
+ * - `removed` gone because this host tore it down flag cleaned;
86
+ * - `already_absent` — nothing was there to tear down. A FINISHED teardown,
87
+ * not a failure: it is recorded in this log AND on the run record, then
88
+ * flagged cleaned. Recording it is the point swallowing the absence and
89
+ * leaving `cleaned_up=0` is what made every later reap repeat the same
90
+ * failing teardown;
91
+ * - `unverified` — it could not be resolved and may still be on disk (e.g.
92
+ * its `worktree_path` was never persisted). Like a THROW, it logs loudly
93
+ * and leaves `cleaned_up=0` so the next reconciliation retries.
94
+ * Flagging cleaned on an unverified teardown was the root cause of the ~28
95
+ * never-touched leftovers, because the ticket then dropped off the
96
+ * (cleaned_up=0) work list forever.
63
97
  * Re-running on an already-cleaned ticket is a no-op — it is off the list
64
98
  * (idempotent). Each ticket is isolated in a try/catch so one failure never
65
99
  * aborts the rest.
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { conductorId } from '@gaia-ai/core';
2
+ import { basename } from 'node:path';
3
+ import { conductorId, stemForConfigFile, } from '@gaia-ai/core';
3
4
  import { selectAgent } from '../plugins/plugins.js';
4
5
  function sleep(ms, signal) {
5
6
  return new Promise((resolve) => {
@@ -104,9 +105,30 @@ export class Conductor {
104
105
  get id() {
105
106
  return this.config.machine_id ?? conductorId(this.checkoutRoot);
106
107
  }
108
+ /**
109
+ * Whether this instance is the serve-loop process itself (GAIA-232).
110
+ *
111
+ * Off by default so that only a caller which genuinely owns the process can
112
+ * report a pid — see {@link claimServeProcess}.
113
+ */
114
+ ownsServeProcess = false;
115
+ /**
116
+ * Declares that this instance runs *as* the conductor process, so its
117
+ * registrations may carry the process identity a cockpit needs to stop it.
118
+ *
119
+ * Only `cmdStartForeground` calls this. `cmdPoll` and `cmdReap` also build a
120
+ * Conductor and call start() + tick(), then exit; if they reported a pid,
121
+ * every `gaia conductor poll` would overwrite the running conductor's pid
122
+ * with its own short-lived one and the next stop would signal a dead — or
123
+ * recycled — process. Making the report opt-in means those two commands
124
+ * cannot do so even by accident.
125
+ */
126
+ claimServeProcess() {
127
+ this.ownsServeProcess = true;
128
+ }
107
129
  /** This conductor's registration payload, built from config. */
108
130
  registration() {
109
- return {
131
+ const base = {
110
132
  id: this.id,
111
133
  project: this.config.project,
112
134
  states: this.config.states,
@@ -114,6 +136,24 @@ export class Conductor {
114
136
  label: this.config.label,
115
137
  max_parallel: this.config.max_parallel,
116
138
  };
139
+ if (!this.ownsServeProcess)
140
+ return base;
141
+ return {
142
+ ...base,
143
+ last_pid: process.pid,
144
+ ...(this.conductorName ? { conductor_name: this.conductorName } : {}),
145
+ };
146
+ }
147
+ /**
148
+ * The stem of the config file this conductor was loaded from.
149
+ *
150
+ * Derived through core's own mapping rather than by slicing the filename
151
+ * here, so the CLI and the conductor cannot disagree about which files count
152
+ * as a conductor config.
153
+ */
154
+ get conductorName() {
155
+ const path = this.config.config_path;
156
+ return path ? stemForConfigFile(basename(path)) : undefined;
117
157
  }
118
158
  async start() {
119
159
  this.uuid = await this.remote.registerConductor(this.registration());
@@ -268,14 +308,21 @@ export class Conductor {
268
308
  *
269
309
  * Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
270
310
  * is closed at once as a lifecycle step, independent of whether its worktree
271
- * teardown then succeeds. `cleaned_up` is flagged ONLY on a VERIFIED teardown
272
- * (GAIA-141 RC-2): `removeWorktree` returns `true` when the worktree is gone on
273
- * this host (removed by us, or confirmed already absent), `false` when it could
274
- * not be resolved and may still be on disk (e.g. its `worktree_path` was never
275
- * persisted). A `false` return like a THROW logs loudly and leaves
276
- * `cleaned_up=0`, so the next reconciliation retries; flagging cleaned on an
277
- * unverified teardown was the root cause of the ~28 never-touched leftovers,
278
- * because the ticket then dropped off the (cleaned_up=0) work list forever.
311
+ * teardown then succeeds. `cleaned_up` is withheld unless the teardown is
312
+ * ACCOUNTED FOR (GAIA-141 RC-2), which `removeWorktree` reports as one of
313
+ * three outcomes (GAIA-293):
314
+ * - `removed` gone because this host tore it down flag cleaned;
315
+ * - `already_absent` — nothing was there to tear down. A FINISHED teardown,
316
+ * not a failure: it is recorded in this log AND on the run record, then
317
+ * flagged cleaned. Recording it is the point swallowing the absence and
318
+ * leaving `cleaned_up=0` is what made every later reap repeat the same
319
+ * failing teardown;
320
+ * - `unverified` — it could not be resolved and may still be on disk (e.g.
321
+ * its `worktree_path` was never persisted). Like a THROW, it logs loudly
322
+ * and leaves `cleaned_up=0` so the next reconciliation retries.
323
+ * Flagging cleaned on an unverified teardown was the root cause of the ~28
324
+ * never-touched leftovers, because the ticket then dropped off the
325
+ * (cleaned_up=0) work list forever.
279
326
  * Re-running on an already-cleaned ticket is a no-op — it is off the list
280
327
  * (idempotent). Each ticket is isolated in a try/catch so one failure never
281
328
  * aborts the rest.
@@ -303,20 +350,56 @@ export class Conductor {
303
350
  // not by the mutable checked-out branch (the coding agent may rename
304
351
  // it). Teardown is idempotent: a worktree herdr no longer tracks is a
305
352
  // no-op / on-disk reclaim, not a failure.
353
+ //
354
+ // The hook needs the DIRECTORY, not just a recorded path (GAIA-293): a
355
+ // hook is a shell spawned with the worktree as its `cwd`, so against an
356
+ // absent one the spawn fails with ENOENT and the shared exec helper logs
357
+ // `ERROR: exec failed` — for the very condition this ticket declares
358
+ // normal. There is no useful work such a hook could do either: a command
359
+ // that must run inside the worktree cannot run anywhere else.
360
+ //
361
+ // The absence is logged rather than passed over in silence, because an
362
+ // `after_done` can reach beyond the directory it runs in — `ddev delete
363
+ // -Oy` also drops a DDEV project registration and its database — and
364
+ // that leftover is then an operator's to clear by hand.
365
+ //
366
+ // But the line states the CONDITION, not a decision about a hook: hook
367
+ // commands are executor-owned config that the core cannot see, so it
368
+ // does not know whether an `after_done` is configured at all. Saying a
369
+ // hook was "skipped" would be false wherever none exists (the generated
370
+ // playground config is exactly such a case). What the core does know is
371
+ // that the directory is gone and that no `after_done` ran — both true in
372
+ // either configuration.
373
+ //
374
+ // `existsSync` here is a plain filesystem question, not worktree
375
+ // knowledge: the core stays executor-agnostic and asks nothing about
376
+ // git. The check is deliberately reap-local — at dispatch a worktree the
377
+ // workspace just created MUST exist, and silencing a hook there would
378
+ // hide a real provisioning failure.
306
379
  if (t.worktreePath) {
307
- await this.executor.runHook('after_done', t.worktreePath, {
308
- ticket: t.ticketUuid,
309
- });
380
+ if (existsSync(t.worktreePath)) {
381
+ await this.executor.runHook('after_done', t.worktreePath, {
382
+ ticket: t.ticketUuid,
383
+ });
384
+ }
385
+ else {
386
+ this.logger.info({
387
+ ticket: t.ticketUuid,
388
+ hook: 'after_done',
389
+ worktreePath: t.worktreePath,
390
+ }, 'worktree directory absent — after_done not run');
391
+ }
310
392
  }
311
393
  if (this.executor.capabilities().persistent) {
312
- let removed;
394
+ let outcome;
313
395
  try {
314
- // removeWorktree returns whether the teardown was VERIFIED on this
315
- // host: `true` = the worktree is gone (removed by us, or confirmed
316
- // already absent); `false` = it could NOT be resolved and may still
317
- // be on disk (GAIA-141 RC-2 — e.g. worktree_path was never persisted
318
- // and herdr lost track of the branch). A THROW is a hard failure.
319
- removed = await this.executor.removeWorktree(t.branchName, t.worktreePath || undefined);
396
+ // removeWorktree reports what the teardown DID (GAIA-293):
397
+ // `removed` = the worktree is gone because this host removed it;
398
+ // `already_absent` = there was nothing to tear down; `unverified` =
399
+ // it could NOT be resolved and may still be on disk (GAIA-141 RC-2
400
+ // e.g. worktree_path was never persisted and herdr lost track of
401
+ // the branch). A THROW is a hard failure.
402
+ outcome = await this.executor.removeWorktree(t.branchName, t.worktreePath || undefined);
320
403
  }
321
404
  catch (err) {
322
405
  this.logger.warn({
@@ -327,7 +410,7 @@ export class Conductor {
327
410
  }, 'worktree teardown failed');
328
411
  continue; // leave cleaned_up=0 → the next reconciliation retries.
329
412
  }
330
- if (!removed) {
413
+ if (outcome === 'unverified') {
331
414
  // Unverified teardown (GAIA-141 RC-2): do NOT flag cleaned_up, or the
332
415
  // ticket drops off fetchUncleanedTickets (which filters cleaned_up=0)
333
416
  // and the orphan is never retried. Leave it on the work list so a
@@ -340,9 +423,33 @@ export class Conductor {
340
423
  }, 'worktree teardown unverified — retrying next reap');
341
424
  continue; // leave cleaned_up=0 → the next reconciliation retries.
342
425
  }
426
+ if (outcome === 'already_absent') {
427
+ // GAIA-293: the worktree was gone before we got here. That is a
428
+ // FINISHED teardown, so it is recorded rather than swallowed and
429
+ // the ticket is flagged cleaned below — the next reap will not
430
+ // retry it.
431
+ this.logger.info({
432
+ ticket: t.ticketUuid,
433
+ branch: t.branchName,
434
+ worktreePath: t.worktreePath,
435
+ }, 'worktree already absent — teardown complete');
436
+ // A ticket with no run has no record to write to — the conductor
437
+ // log above is then the whole account.
438
+ if (t.runUuid) {
439
+ try {
440
+ await this.remote.appendRunNote(t.runUuid, `worktree already absent at teardown: ${t.worktreePath || '(path unknown)'}`);
441
+ }
442
+ catch (err) {
443
+ // Best-effort: an unreachable control plane must not keep a
444
+ // finished teardown from being flagged.
445
+ this.logger.warn({ ticket: t.ticketUuid, run: t.runUuid, err: String(err) }, 'run note write failed');
446
+ }
447
+ }
448
+ }
343
449
  }
344
- // Teardown verified locally (or nothing hosted to tear down): flag it
345
- // cleaned so it drops off the work list.
450
+ // Teardown verified locally, the worktree was already absent, or there
451
+ // is nothing hosted to tear down: flag it cleaned so it drops off the
452
+ // work list.
346
453
  await this.remote.markTicketCleanedUp(t.ticketUuid);
347
454
  this.logger.info({ ticket: t.ticketUuid }, 'ticket cleaned up');
348
455
  }
@@ -1,5 +1,4 @@
1
1
  export type * from '@gaia-ai/core';
2
- export type { ConductorRegistryEntry } from '@gaia-ai/core';
3
2
  export { conductorId } from '@gaia-ai/core';
4
3
  export { GAIA_CONFIG_SCHEMA_VERSION, readConfigSchemaVersion, } from './cli/config-schema.js';
5
4
  export { renderGaiaConfig } from './cli/init.js';
package/dist/src/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // GAIA-224: the conductor registry lives ONLY in `@gaia-ai/core` — the former
2
+ // thin re-export shim under `./cli/` is deleted and importers name core direct.
1
3
  export { conductorId } from '@gaia-ai/core';
2
4
  // GAIA-216: the connection-config schema version + its regex reader. GAIA-230:
3
5
  // the host's `gaia upgrade` intro prints the `from → to` pair from these.
@@ -52,6 +52,23 @@ export interface HookResult {
52
52
  /** The stringified failure; present iff `ok` is false. */
53
53
  error?: string;
54
54
  }
55
+ /**
56
+ * Outcome of a worktree teardown (GAIA-293).
57
+ *
58
+ * - `removed` — the worktree was torn down on this host, or the prunable
59
+ * admin entry it left behind was swept.
60
+ * - `already_absent` — nothing to tear down: neither git nor the filesystem
61
+ * still has it. A FINISHED teardown, not a failure.
62
+ * - `unverified` — it could not be resolved or removed and may still be on
63
+ * disk; the reaper leaves the ticket on its work list.
64
+ *
65
+ * Replaces the former `boolean`, which could not distinguish "gone because we
66
+ * removed it" from "gone before we got here" — the distinction the reaper needs
67
+ * in order to record the absence rather than swallow it. The mapping preserves
68
+ * the old semantics exactly: `removed` is the old `true`, `unverified` the old
69
+ * `false`.
70
+ */
71
+ export type WorktreeTeardown = 'removed' | 'already_absent' | 'unverified';
55
72
  export interface GaiaExecutor {
56
73
  id: string;
57
74
  capabilities(): ExecutorCapabilities;
@@ -112,14 +129,15 @@ export interface GaiaExecutor {
112
129
  * checked-out branch is mutable (the coding agent may rename/switch it), the
113
130
  * path is not.
114
131
  *
115
- * Returns whether the worktree was actually present on THIS host and torn
116
- * down (or reclaimed on disk) i.e. whether the teardown happened locally.
117
- * `false` means nothing matched here: the worktree is either already gone or
118
- * lives on another conductor's host. The reaper uses this to avoid marking a
119
- * ticket `cleaned_up` for a worktree it did not actually tear down (a
120
- * cross-host false-teardown), leaving it for the host that physically holds
121
- * it. A genuine failure still throws (surfaced as a teardown miss); a `false`
122
- * return is a clean "not here", not an error.
132
+ * Reports what the teardown DID as a {@link WorktreeTeardown} (GAIA-293):
133
+ * `removed` when the worktree was present on this host and torn down (or its
134
+ * prunable admin entry swept), `already_absent` when there was nothing to
135
+ * tear down at all, and `unverified` when nothing matched here and the
136
+ * worktree may still be on disk (another conductor's host, or a path that
137
+ * could not be resolved). The reaper flags a ticket `cleaned_up` for the
138
+ * first two and leaves it on the work list for `unverified`, so it never
139
+ * false-teardowns a worktree another host physically holds. A genuine
140
+ * failure still throws (surfaced as a teardown miss).
123
141
  */
124
- removeWorktree(branch: string, worktreePath?: string): Promise<boolean>;
142
+ removeWorktree(branch: string, worktreePath?: string): Promise<WorktreeTeardown>;
125
143
  }
@@ -6,6 +6,22 @@ export interface ConductorRegistration {
6
6
  workspace: string;
7
7
  label: string;
8
8
  max_parallel: number;
9
+ /**
10
+ * The pid of the serve-loop process, when this conductor *is* that process.
11
+ *
12
+ * Absent from a registration sent by `poll` or `reap`, which build a
13
+ * Conductor and exit within seconds — reporting their pid would overwrite a
14
+ * running conductor's with a short-lived one. Absent likewise means "leave
15
+ * whatever is stored alone", never "clear it".
16
+ */
17
+ last_pid?: number;
18
+ /**
19
+ * The stem of the config file this conductor was started from (`conductor`
20
+ * for the default, `shop` for `shop.conductor.config.js`) — what
21
+ * `--conductor <name>` selects. Together with `workspace` it identifies one
22
+ * conductor in a repo that holds several.
23
+ */
24
+ conductor_name?: string;
9
25
  }
10
26
  export interface ClaimOptions {
11
27
  leaseSeconds: number;
@@ -60,6 +76,18 @@ export interface ConductorStatus {
60
76
  status: string;
61
77
  lastSeen: number;
62
78
  load: number;
79
+ /**
80
+ * The `.gaia` dir the conductor last registered from — where its config and
81
+ * `log.txt` live, and the cwd a start must use. Empty when never reported.
82
+ */
83
+ workspaceRoot: string;
84
+ /** Lease expiry (unix seconds); with lastSeen it derives liveness. 0 = unset. */
85
+ leaseExpiresAt: number;
86
+ maxParallel: number;
87
+ /** The last pid the conductor's serve loop reported, if any (GAIA-232). */
88
+ lastPid?: number;
89
+ /** The config stem it was started from, as `--conductor <name>` takes it. */
90
+ conductorName?: string;
63
91
  }
64
92
  export interface FinalizableRun {
65
93
  runUuid: string;
@@ -105,6 +133,13 @@ export interface UncleanTicket {
105
133
  state: string;
106
134
  /** Whether the ticket's lifecycle has already been closed out. */
107
135
  closed: boolean;
136
+ /**
137
+ * UUID of the ticket's latest run — the one {@link worktreePath} came from —
138
+ * or '' when the ticket has no run with a path. The record teardown notes are
139
+ * written to (GAIA-293). Resolved by the same query that resolves the path,
140
+ * so it costs no extra round-trip.
141
+ */
142
+ runUuid: string;
108
143
  }
109
144
  /** Extra conductor-writable gaia_run attributes (besides state/lease). */
110
145
  export interface RunWriteAttributes {
@@ -191,6 +226,14 @@ export interface GaiaRemote {
191
226
  * already-cleaned ticket is a no-op (it is no longer on the list).
192
227
  */
193
228
  markTicketCleanedUp(uuid: string): Promise<void>;
229
+ /**
230
+ * Append one diagnostic line to a run's log (GAIA-293) — how the reaper
231
+ * records what it found on the run record itself, not only in this machine's
232
+ * process log. Touches `log` alone: the run is already closed, and its
233
+ * lifecycle fields are not this call's business. An empty `runUuid` writes
234
+ * nothing.
235
+ */
236
+ appendRunNote(runUuid: string, note: string): Promise<void>;
194
237
  /**
195
238
  * Resolve a ticket identifier (e.g. "GAIA-134") to its uuid+title within a
196
239
  * project, or null when no such ticket exists. Used by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/conductor",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,11 +28,10 @@
28
28
  "directory": "gaia-cli/conductor"
29
29
  },
30
30
  "dependencies": {
31
- "@gaia-ai/core": "^0.7.0",
31
+ "@gaia-ai/core": "^0.8.0",
32
32
  "@dropsh/plugin-oauth2": "^0.5.7",
33
33
  "@dropsh/plugin-jsonapi-schema": "^0.5.8",
34
34
  "commander": "^12.1.0",
35
- "dropsh": "^0.5.8",
36
- "semver": "^7.6.0"
35
+ "dropsh": "^0.5.8"
37
36
  }
38
37
  }