@gaia-ai/conductor 0.4.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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/cli/gaia.d.ts +19 -0
- package/dist/src/cli/gaia.js +644 -0
- package/dist/src/cli/init.d.ts +82 -0
- package/dist/src/cli/init.js +232 -0
- package/dist/src/cli/local-registry.d.ts +14 -0
- package/dist/src/cli/local-registry.js +56 -0
- package/dist/src/config.d.ts +23 -0
- package/dist/src/config.js +217 -0
- package/dist/src/core/conductor.d.ts +71 -0
- package/dist/src/core/conductor.js +410 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +5 -0
- package/package.json +34 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ConductorFileConfig, type ConductorLogger, type GaiaAgent, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace } from '@gaia-ai/core';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the environment a run executes in (GAIA-99): parse the ticket's
|
|
4
|
+
* effective env_vars, drop any reserved key (loud warn — key NAME only, never
|
|
5
|
+
* the value), then overlay the conductor's core vars so they always win.
|
|
6
|
+
*
|
|
7
|
+
* Values are potentially secret (e.g. a DB DSN): this function logs key names
|
|
8
|
+
* only, never a value. The returned map is injected into BOTH the agent run env
|
|
9
|
+
* and every workspace hook.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveRunEnv(effectiveEnvVars: string | undefined, core: Record<string, string>, logger: ConductorLogger): Record<string, string>;
|
|
12
|
+
export declare class Conductor {
|
|
13
|
+
private readonly config;
|
|
14
|
+
private readonly remote;
|
|
15
|
+
private readonly executor;
|
|
16
|
+
private readonly workspace;
|
|
17
|
+
private readonly agent;
|
|
18
|
+
private readonly logger;
|
|
19
|
+
private readonly checkoutRoot;
|
|
20
|
+
private uuid;
|
|
21
|
+
constructor(config: ConductorFileConfig, remote: GaiaRemote, executor: GaiaExecutor, workspace: GaiaWorkspace, agent: GaiaAgent, logger: ConductorLogger, checkoutRoot?: string);
|
|
22
|
+
get id(): string;
|
|
23
|
+
/** This conductor's registration payload, built from config. */
|
|
24
|
+
private registration;
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
tick(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* One-shot finalisation: for each of this conductor's runs that is
|
|
29
|
+
* state=done but not yet closed, read the agent transcript from its worktree
|
|
30
|
+
* and write it as the final log, then mark the run closed. Keyed off the
|
|
31
|
+
* durable `state=done AND closed=false` query — no in-memory state, so a
|
|
32
|
+
* restarted conductor still finalises any unclosed done run on its next tick.
|
|
33
|
+
*/
|
|
34
|
+
private finalizeDoneRuns;
|
|
35
|
+
/**
|
|
36
|
+
* Reconcile finished tickets against their herdr worktrees and tear down any
|
|
37
|
+
* whose workspace still lingers (GAIA-89). Driven by the durable `cleaned_up`
|
|
38
|
+
* flag via {@link GaiaRemote.fetchUncleanedTickets}: every ticket that is
|
|
39
|
+
* finished (state=done OR closed) but not yet cleaned. The SAME reconciliation
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
|
|
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.
|
|
62
|
+
*
|
|
63
|
+
* The `after_done` hook runs through the executor (GAIA-84), which owns the
|
|
64
|
+
* best-effort policy — it logs+swallows a hook failure and never throws, so
|
|
65
|
+
* the core needs no per-call-site try/catch around it.
|
|
66
|
+
*/
|
|
67
|
+
reap(): Promise<void>;
|
|
68
|
+
private dispatch;
|
|
69
|
+
serve(signal?: AbortSignal): Promise<void>;
|
|
70
|
+
private pollLoop;
|
|
71
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { conductorId, } from '@gaia-ai/core';
|
|
2
|
+
function sleep(ms, signal) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
if (signal?.aborted) {
|
|
5
|
+
resolve();
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
signal?.removeEventListener('abort', onAbort);
|
|
10
|
+
resolve();
|
|
11
|
+
}, ms);
|
|
12
|
+
const onAbort = () => {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
resolve();
|
|
15
|
+
};
|
|
16
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Formats a ticket's comments into the `{comments}` prompt block, oldest first.
|
|
21
|
+
* The conductor injects this so a review→coding bounce reaches the coder with the
|
|
22
|
+
* review summary in hand, independent of whether the agent remembers to fetch it.
|
|
23
|
+
*
|
|
24
|
+
* The body is the raw gaia_rich markup — the agent reads HTML fine, so we don't
|
|
25
|
+
* strip it (a tag/entity stripper is a maintenance footgun that buys nothing for
|
|
26
|
+
* an LLM reader).
|
|
27
|
+
*/
|
|
28
|
+
function renderComments(comments) {
|
|
29
|
+
if (comments.length === 0) {
|
|
30
|
+
return '(no comments on this ticket yet)';
|
|
31
|
+
}
|
|
32
|
+
return comments
|
|
33
|
+
.map((c, i) => {
|
|
34
|
+
const when = c.created ? ` (${c.created})` : '';
|
|
35
|
+
return `[#${i + 1}] type=${c.type}${when}\n${c.body}`;
|
|
36
|
+
})
|
|
37
|
+
.join('\n\n---\n\n');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A run-env key is reserved when it names a conductor-provided core variable:
|
|
41
|
+
* any `GAIA_*`, `WORKSPACE_ROOT`, or `TICKET_URL`. User `env_vars` may not
|
|
42
|
+
* clobber these — the core vars always win (GAIA-99).
|
|
43
|
+
*/
|
|
44
|
+
function isReservedEnvKey(key) {
|
|
45
|
+
return (key.startsWith('GAIA_') || key === 'WORKSPACE_ROOT' || key === 'TICKET_URL');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse the server-canonicalised effective env_vars (`KEY=value` lines, one per
|
|
49
|
+
* line) into a map. The server already stripped comments/blank/malformed lines,
|
|
50
|
+
* so this is deliberately minimal: split on the first `=`, keep a valid key.
|
|
51
|
+
* Defensive against stray blank lines only — never throws.
|
|
52
|
+
*/
|
|
53
|
+
function parseEnvLines(text) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const raw of text.split('\n')) {
|
|
56
|
+
const line = raw.trim();
|
|
57
|
+
if (line === '') {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const eq = line.indexOf('=');
|
|
61
|
+
if (eq <= 0) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const key = line.slice(0, eq);
|
|
65
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out[key] = line.slice(eq + 1);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the environment a run executes in (GAIA-99): parse the ticket's
|
|
74
|
+
* effective env_vars, drop any reserved key (loud warn — key NAME only, never
|
|
75
|
+
* the value), then overlay the conductor's core vars so they always win.
|
|
76
|
+
*
|
|
77
|
+
* Values are potentially secret (e.g. a DB DSN): this function logs key names
|
|
78
|
+
* only, never a value. The returned map is injected into BOTH the agent run env
|
|
79
|
+
* and every workspace hook.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveRunEnv(effectiveEnvVars, core, logger) {
|
|
82
|
+
const user = effectiveEnvVars ? parseEnvLines(effectiveEnvVars) : {};
|
|
83
|
+
const clobbered = [];
|
|
84
|
+
const safe = {};
|
|
85
|
+
for (const [key, value] of Object.entries(user)) {
|
|
86
|
+
if (isReservedEnvKey(key)) {
|
|
87
|
+
clobbered.push(key);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
safe[key] = value;
|
|
91
|
+
}
|
|
92
|
+
if (clobbered.length > 0) {
|
|
93
|
+
logger.warn({ keys: clobbered }, 'ignored reserved keys in ticket env_vars (core vars win)');
|
|
94
|
+
}
|
|
95
|
+
// Core vars overlay last — they can never be overridden.
|
|
96
|
+
return { ...safe, ...core };
|
|
97
|
+
}
|
|
98
|
+
function renderPrompt(template, values) {
|
|
99
|
+
return template
|
|
100
|
+
.replaceAll('{identifier}', values.identifier)
|
|
101
|
+
.replaceAll('{state}', values.state)
|
|
102
|
+
.replaceAll('{runUuid}', values.runUuid)
|
|
103
|
+
.replaceAll('{comments}', renderComments(values.comments));
|
|
104
|
+
}
|
|
105
|
+
export class Conductor {
|
|
106
|
+
config;
|
|
107
|
+
remote;
|
|
108
|
+
executor;
|
|
109
|
+
workspace;
|
|
110
|
+
agent;
|
|
111
|
+
logger;
|
|
112
|
+
checkoutRoot;
|
|
113
|
+
uuid = null;
|
|
114
|
+
constructor(config, remote, executor, workspace, agent, logger, checkoutRoot = process.cwd()) {
|
|
115
|
+
this.config = config;
|
|
116
|
+
this.remote = remote;
|
|
117
|
+
this.executor = executor;
|
|
118
|
+
this.workspace = workspace;
|
|
119
|
+
this.agent = agent;
|
|
120
|
+
this.logger = logger;
|
|
121
|
+
this.checkoutRoot = checkoutRoot;
|
|
122
|
+
}
|
|
123
|
+
get id() {
|
|
124
|
+
return this.config.machine_id ?? conductorId(this.checkoutRoot);
|
|
125
|
+
}
|
|
126
|
+
/** This conductor's registration payload, built from config. */
|
|
127
|
+
registration() {
|
|
128
|
+
return {
|
|
129
|
+
id: this.id,
|
|
130
|
+
project: this.config.project,
|
|
131
|
+
states: this.config.states,
|
|
132
|
+
workspace: this.checkoutRoot,
|
|
133
|
+
label: this.config.label,
|
|
134
|
+
max_parallel: this.config.max_parallel,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async start() {
|
|
138
|
+
this.uuid = await this.remote.registerConductor(this.registration());
|
|
139
|
+
this.logger.info({ conductorId: this.id, uuid: this.uuid, project: this.config.project }, 'conductor started');
|
|
140
|
+
}
|
|
141
|
+
async tick() {
|
|
142
|
+
if (!this.uuid)
|
|
143
|
+
throw new Error('not started');
|
|
144
|
+
let count = await this.remote.activeRunCount(this.id);
|
|
145
|
+
// A heartbeat is the server-side self-heal: it upserts by machine_id (so it
|
|
146
|
+
// never 404s and recreates a vanished registration) and refreshes the lease
|
|
147
|
+
// — no client-side re-register, no separate status read.
|
|
148
|
+
await this.remote.heartbeat(this.registration(), count, this.config.lease_seconds);
|
|
149
|
+
this.logger.info({ conductorId: this.id, active: count }, 'conductor tick');
|
|
150
|
+
await this.finalizeDoneRuns();
|
|
151
|
+
await this.reap();
|
|
152
|
+
let claimed = 0;
|
|
153
|
+
while (count < this.config.max_parallel) {
|
|
154
|
+
const run = await this.remote.claimNext({
|
|
155
|
+
leaseSeconds: this.config.lease_seconds,
|
|
156
|
+
conductorId: this.id,
|
|
157
|
+
});
|
|
158
|
+
if (!run)
|
|
159
|
+
break;
|
|
160
|
+
this.logger.info({ conductorId: this.id, run: run.runUuid, ticket: run.ticketUuid }, 'claimed run');
|
|
161
|
+
try {
|
|
162
|
+
await this.dispatch(run);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
// A failed dispatch must NOT terminalise the run. The run lifecycle is
|
|
166
|
+
// server-owned; a client-side releaseRun('done') here fabricated a
|
|
167
|
+
// terminal state on failure, which reopened the ticket for claiming and
|
|
168
|
+
// re-dispatched it every tick — the GAIA-41 runaway. Leaving the run
|
|
169
|
+
// `claimed` lets the server's same-state claim conflict
|
|
170
|
+
// (TicketClaimService::retireSupersededRuns) block a re-claim of the
|
|
171
|
+
// same ticket, so we just log and move on. A run stuck `claimed` on a
|
|
172
|
+
// persistently-failing dispatch is freed by the lease-expiry reaper
|
|
173
|
+
// (separate follow-up), not here.
|
|
174
|
+
this.logger.error({ run: run.runUuid, err: String(err) }, 'dispatch failed');
|
|
175
|
+
}
|
|
176
|
+
count += 1;
|
|
177
|
+
claimed += 1;
|
|
178
|
+
}
|
|
179
|
+
if (claimed === 0) {
|
|
180
|
+
this.logger.info({
|
|
181
|
+
conductorId: this.id,
|
|
182
|
+
active: count,
|
|
183
|
+
capacity: this.config.max_parallel,
|
|
184
|
+
}, count >= this.config.max_parallel
|
|
185
|
+
? 'at capacity, nothing claimed'
|
|
186
|
+
: 'idle, nothing to claim');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* One-shot finalisation: for each of this conductor's runs that is
|
|
191
|
+
* state=done but not yet closed, read the agent transcript from its worktree
|
|
192
|
+
* and write it as the final log, then mark the run closed. Keyed off the
|
|
193
|
+
* durable `state=done AND closed=false` query — no in-memory state, so a
|
|
194
|
+
* restarted conductor still finalises any unclosed done run on its next tick.
|
|
195
|
+
*/
|
|
196
|
+
async finalizeDoneRuns() {
|
|
197
|
+
const runs = await this.remote.fetchFinalizableRuns(this.id);
|
|
198
|
+
for (const r of runs) {
|
|
199
|
+
try {
|
|
200
|
+
// Ask the agent to exit gracefully (/exit) before capturing its log.
|
|
201
|
+
// Best-effort and gated on the persistent capability (only hosted
|
|
202
|
+
// executors have a live agent pane) — mirrors removeWorktree's gate.
|
|
203
|
+
// A stop failure must never block finalisation.
|
|
204
|
+
if (this.executor.capabilities().persistent) {
|
|
205
|
+
const branch = await this.remote.getRunTicketBranchName(r.runUuid);
|
|
206
|
+
if (branch) {
|
|
207
|
+
try {
|
|
208
|
+
await this.executor.stopAgent(branch);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
this.logger.warn({ run: r.runUuid, err: String(err) }, 'agent stop failed');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const log = r.worktreePath
|
|
216
|
+
? await this.agent.getRunLog(r.worktreePath)
|
|
217
|
+
: '';
|
|
218
|
+
await this.remote.finalizeRun(r.runUuid, log);
|
|
219
|
+
this.logger.info({ run: r.runUuid }, 'run finalised');
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
this.logger.warn({ run: r.runUuid, err: String(err) }, 'run finalise failed');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Reconcile finished tickets against their herdr worktrees and tear down any
|
|
228
|
+
* whose workspace still lingers (GAIA-89). Driven by the durable `cleaned_up`
|
|
229
|
+
* flag via {@link GaiaRemote.fetchUncleanedTickets}: every ticket that is
|
|
230
|
+
* finished (state=done OR closed) but not yet cleaned. The SAME reconciliation
|
|
231
|
+
* runs on every tick AND standalone as `gaia conductor reap`, and the query is
|
|
232
|
+
* NOT machine-scoped — so it catches the orphans a live-tick-only path
|
|
233
|
+
* structurally cannot (RC2/RC3): the conductor was down when the ticket hit
|
|
234
|
+
* `done`, crashed mid-cleanup, or the ticket's `conductor_id` was reassigned.
|
|
235
|
+
* A foreign (non-gaia) worktree has no gaia ticket, so it is never on the work
|
|
236
|
+
* list and never touched — foreign-safety falls out of the DB-driven model
|
|
237
|
+
* with no host enumeration.
|
|
238
|
+
*
|
|
239
|
+
* Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
|
|
240
|
+
* is closed at once as a lifecycle step, independent of whether its worktree
|
|
241
|
+
* teardown then succeeds. Teardown is best-effort + verified — `cleaned_up` is
|
|
242
|
+
* set only when the worktree was actually torn down ON THIS HOST. Since the
|
|
243
|
+
* query is not machine-scoped, a conductor may see a finished ticket whose
|
|
244
|
+
* worktree lives on ANOTHER host; `removeWorktree` returns `false` there (a
|
|
245
|
+
* clean "not here") and the reaper DEFERS — it does not flag the ticket
|
|
246
|
+
* cleaned, leaving the teardown to the host that physically holds the
|
|
247
|
+
* worktree. That gate is what keeps the not-machine-scoped query from
|
|
248
|
+
* cross-host-false-teardowning a still-live workspace. A teardown that throws
|
|
249
|
+
* logs loudly and leaves `cleaned_up=0`, so the next reconciliation retries;
|
|
250
|
+
* one miss never orphans the workspace (RC1). Re-running on an already-cleaned
|
|
251
|
+
* ticket is a no-op — it is off the list (idempotent). Each ticket is isolated
|
|
252
|
+
* in a try/catch so one failure never aborts the rest.
|
|
253
|
+
*
|
|
254
|
+
* The `after_done` hook runs through the executor (GAIA-84), which owns the
|
|
255
|
+
* best-effort policy — it logs+swallows a hook failure and never throws, so
|
|
256
|
+
* the core needs no per-call-site try/catch around it.
|
|
257
|
+
*/
|
|
258
|
+
async reap() {
|
|
259
|
+
const tickets = await this.remote.fetchUncleanedTickets();
|
|
260
|
+
for (const t of tickets) {
|
|
261
|
+
try {
|
|
262
|
+
// Lifecycle: close a done+unclosed ticket at once, decoupled from the
|
|
263
|
+
// infra teardown below — a done ticket whose conductor was down was
|
|
264
|
+
// never closed, and withholding the close on a teardown hiccup would
|
|
265
|
+
// make a finished ticket look unfinished (the pre-GAIA-89 defect).
|
|
266
|
+
if (t.state === 'done' && !t.closed) {
|
|
267
|
+
await this.remote.closeTicket(t.ticketUuid);
|
|
268
|
+
}
|
|
269
|
+
// Infra teardown. The `after_done` hook runs in the worktree regardless
|
|
270
|
+
// of executor kind (a non-persistent gitWorkspace still has an on-disk
|
|
271
|
+
// worktree); only the workspace removal is gated on the persistent
|
|
272
|
+
// capability (mirrors the executor-agnostic release path). Pass the
|
|
273
|
+
// stable worktreePath so the executor resolves the worktree by path,
|
|
274
|
+
// not by the mutable checked-out branch (the coding agent may rename
|
|
275
|
+
// it). Teardown is idempotent: a worktree herdr no longer tracks is a
|
|
276
|
+
// no-op / on-disk reclaim, not a failure.
|
|
277
|
+
if (t.worktreePath) {
|
|
278
|
+
await this.executor.runHook('after_done', t.worktreePath, {
|
|
279
|
+
ticket: t.ticketUuid,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (this.executor.capabilities().persistent) {
|
|
283
|
+
let removed;
|
|
284
|
+
try {
|
|
285
|
+
removed = await this.executor.removeWorktree(t.branchName, t.worktreePath || undefined);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
this.logger.warn({
|
|
289
|
+
ticket: t.ticketUuid,
|
|
290
|
+
branch: t.branchName,
|
|
291
|
+
worktreePath: t.worktreePath,
|
|
292
|
+
err: String(err),
|
|
293
|
+
}, 'worktree teardown failed');
|
|
294
|
+
continue; // leave cleaned_up=0 → the next reconciliation retries.
|
|
295
|
+
}
|
|
296
|
+
// A recorded worktree path that was NOT present on this host belongs
|
|
297
|
+
// to another conductor's host (the query is not machine-scoped, so we
|
|
298
|
+
// see every finished ticket). Do NOT flag it cleaned — that would be a
|
|
299
|
+
// cross-host false-teardown, orphaning a still-live workspace. Defer:
|
|
300
|
+
// the host that physically holds it reaps + flags it. A ticket with no
|
|
301
|
+
// recorded path (nothing hosted to locate) falls through to cleaned.
|
|
302
|
+
if (t.worktreePath && !removed) {
|
|
303
|
+
this.logger.info({
|
|
304
|
+
ticket: t.ticketUuid,
|
|
305
|
+
branch: t.branchName,
|
|
306
|
+
worktreePath: t.worktreePath,
|
|
307
|
+
}, 'worktree not on this host — deferring teardown to its host');
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Teardown verified locally (or nothing hosted to tear down): flag it
|
|
312
|
+
// cleaned so it drops off the work list.
|
|
313
|
+
await this.remote.markTicketCleanedUp(t.ticketUuid);
|
|
314
|
+
this.logger.info({ ticket: t.ticketUuid }, 'ticket cleaned up');
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
this.logger.warn({ ticket: t.ticketUuid, err: String(err) }, 'ticket cleanup failed');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async dispatch(run) {
|
|
322
|
+
const t = await this.remote.getTicket(run.ticketUuid);
|
|
323
|
+
const baseRef = t.baseBranch ? `origin/${t.baseBranch}` : undefined;
|
|
324
|
+
// Resolve the run env once (per-ticket env_vars + core GAIA_* vars) and
|
|
325
|
+
// inject it into BOTH the executor-owned lifecycle hooks AND the agent run
|
|
326
|
+
// env (GAIA-99). Reserved core keys always win; values may be sensitive so
|
|
327
|
+
// only key names are ever logged.
|
|
328
|
+
const core = {
|
|
329
|
+
GAIA_URL: this.config.site.base_url,
|
|
330
|
+
GAIA_ID: run.ticketUuid,
|
|
331
|
+
GAIA_RUN_UUID: run.runUuid,
|
|
332
|
+
WORKSPACE_ROOT: this.checkoutRoot,
|
|
333
|
+
...(t.issueUrl ? { TICKET_URL: t.issueUrl } : {}),
|
|
334
|
+
};
|
|
335
|
+
const env = resolveRunEnv(t.effectiveEnvVars, core, this.logger);
|
|
336
|
+
const ws = await this.workspace.ensure(t.identifier, t.branchName, baseRef);
|
|
337
|
+
// A claimable state has no deterministic policy without a WORKFLOW.md
|
|
338
|
+
// (SKILL.md dispatch reads its `## State: <state>` sections). The human
|
|
339
|
+
// steered this to WARN-and-proceed (not a hard fail): the agent runs on
|
|
340
|
+
// engine defaults, and the gap is observable in the conductor log rather
|
|
341
|
+
// than wedging the run. `ws.instructions` IS the loaded WORKFLOW.md (null
|
|
342
|
+
// when absent), so no extra stat.
|
|
343
|
+
if (!ws.instructions) {
|
|
344
|
+
this.logger.warn({ ticket: t.identifier, workspace: ws.path }, 'WORKFLOW.md missing — dispatching on engine defaults');
|
|
345
|
+
}
|
|
346
|
+
// Lifecycle hooks are executor-owned + best-effort (GAIA-84): runHook never
|
|
347
|
+
// throws, so neither call can abort dispatch or wedge a run in `claimed`.
|
|
348
|
+
// after_create only on a freshly-created worktree; before_run always. Both
|
|
349
|
+
// get the resolved run env so a setup/pre hook sees the same vars (e.g. a DB
|
|
350
|
+
// DSN) the agent does (GAIA-99).
|
|
351
|
+
if (ws.created) {
|
|
352
|
+
await this.executor.runHook('after_create', ws.path, { ticket: t.identifier }, env);
|
|
353
|
+
}
|
|
354
|
+
await this.executor.runHook('before_run', ws.path, { ticket: t.identifier }, env);
|
|
355
|
+
await this.executor.retire(t.branchName);
|
|
356
|
+
const prompt = ws.instructions
|
|
357
|
+
? renderPrompt(this.config.prompt, {
|
|
358
|
+
identifier: t.identifier,
|
|
359
|
+
state: t.state || 'triage',
|
|
360
|
+
runUuid: run.runUuid,
|
|
361
|
+
comments: t.comments,
|
|
362
|
+
})
|
|
363
|
+
: '';
|
|
364
|
+
await this.executor.startRun({
|
|
365
|
+
ticket: {
|
|
366
|
+
uuid: t.uuid,
|
|
367
|
+
identifier: t.identifier,
|
|
368
|
+
title: t.title,
|
|
369
|
+
branchName: t.branchName,
|
|
370
|
+
state: t.state,
|
|
371
|
+
...(t.issueUrl ? { url: t.issueUrl } : {}),
|
|
372
|
+
},
|
|
373
|
+
run: { uuid: run.runUuid, id: run.runId, handler: run.handler },
|
|
374
|
+
workspacePath: ws.path,
|
|
375
|
+
instructions: ws.instructions,
|
|
376
|
+
command: this.agent.launchCommand(prompt),
|
|
377
|
+
env,
|
|
378
|
+
});
|
|
379
|
+
this.logger.info({
|
|
380
|
+
ticket: t.identifier,
|
|
381
|
+
run: run.runUuid,
|
|
382
|
+
state: t.state,
|
|
383
|
+
workspace: ws.path,
|
|
384
|
+
// Key names only — env values may be sensitive (GAIA-99).
|
|
385
|
+
envKeys: Object.keys(env),
|
|
386
|
+
}, 'dispatched run');
|
|
387
|
+
// The conductor — not the agent — knows the per-run git worktree path, so
|
|
388
|
+
// it persists `worktree_path` to gaia_run here alongside the running state.
|
|
389
|
+
await this.remote.markRunning(run.runUuid, { worktree_path: ws.path });
|
|
390
|
+
}
|
|
391
|
+
async serve(signal) {
|
|
392
|
+
await this.pollLoop(signal);
|
|
393
|
+
}
|
|
394
|
+
async pollLoop(signal) {
|
|
395
|
+
while (!signal?.aborted) {
|
|
396
|
+
try {
|
|
397
|
+
await this.tick();
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
this.logger.error({ conductorId: this.id, err: String(err) }, 'tick failed');
|
|
401
|
+
}
|
|
402
|
+
await sleep(this.config.poll_interval_ms, signal);
|
|
403
|
+
}
|
|
404
|
+
// NB: the poll loop never writes status=offline itself. A graceful stop
|
|
405
|
+
// (`gaia conductor stop`) writes offline directly server-side; a crash or
|
|
406
|
+
// hard-kill leaves a stale lease that the Drupal cron reaper flips to
|
|
407
|
+
// offline (gaia_core cron → ConductorReaper). Liveness is thus always
|
|
408
|
+
// derived from status + last_seen/lease_expires_at, never from the loop.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type * from '@gaia-ai/core';
|
|
2
|
+
export { conductorId } from '@gaia-ai/core';
|
|
3
|
+
export { DrupalGaiaRemote, drupalRemote, FakeGaiaRemote, FakeWorkspace, fakeRemote, fakeWorkspace, GitWorkspace, gitWorkspace, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
|
|
4
|
+
export type { GaiaCliDeps } from './cli/gaia.js';
|
|
5
|
+
export { main, runGaiaCli } from './cli/gaia.js';
|
|
6
|
+
export type { ConductorRegistryEntry } from './cli/local-registry.js';
|
|
7
|
+
export { DEFAULT_AGENT_PROMPT, loadConductorConfig } from './config.js';
|
|
8
|
+
export { Conductor } from './core/conductor.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { conductorId } from '@gaia-ai/core';
|
|
2
|
+
export { DrupalGaiaRemote, drupalRemote, FakeGaiaRemote, FakeWorkspace, fakeRemote, fakeWorkspace, GitWorkspace, gitWorkspace, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
|
|
3
|
+
export { main, runGaiaCli } from './cli/gaia.js';
|
|
4
|
+
export { DEFAULT_AGENT_PROMPT, loadConductorConfig } from './config.js';
|
|
5
|
+
export { Conductor } from './core/conductor.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gaia-ai/conductor",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20.19"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/src/index.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://git.key-tec.de/keytec/gaia.git",
|
|
25
|
+
"directory": "conductor/packages/conductor"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@gaia-ai/core": "^0.4.0",
|
|
29
|
+
"@dropsh/plugin-oauth2": "^0.4.1",
|
|
30
|
+
"@dropsh/plugin-jsonapi-schema": "^0.5.1",
|
|
31
|
+
"commander": "^12.1.0",
|
|
32
|
+
"dropsh": "^0.5.1"
|
|
33
|
+
}
|
|
34
|
+
}
|