@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.
- package/dist/src/commands/conductor.d.ts +8 -1
- package/dist/src/commands/conductor.js +101 -99
- package/dist/src/contract.d.ts +1 -1
- package/dist/src/core/conductor.d.ts +42 -8
- package/dist/src/core/conductor.js +130 -23
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +2 -0
- package/dist/src/plugins/executor.d.ts +27 -9
- package/dist/src/plugins/remote.d.ts +43 -0
- package/package.json +3 -4
|
@@ -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,
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
}
|
package/dist/src/contract.d.ts
CHANGED
|
@@ -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
|
|
56
|
-
* (GAIA-141 RC-2)
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
272
|
-
* (GAIA-141 RC-2)
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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
|
-
|
|
308
|
-
|
|
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
|
|
394
|
+
let outcome;
|
|
313
395
|
try {
|
|
314
|
-
// removeWorktree
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// be on disk (GAIA-141 RC-2
|
|
318
|
-
//
|
|
319
|
-
|
|
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 (
|
|
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
|
|
345
|
-
// cleaned so it drops off the
|
|
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
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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<
|
|
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.
|
|
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.
|
|
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
|
}
|