@gaia-ai/conductor 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -192,7 +192,7 @@ async function cmdPoll(deps, log = {}) {
192
192
  return;
193
193
  const remote = await resolveRemote(deps, config);
194
194
  const executor = deps.executor ?? (await selectExecutor(config, logger));
195
- const workspace = deps.workspace ?? (await selectWorkspace(config));
195
+ const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
196
196
  const agents = deps.agents ?? (await selectAgents(config));
197
197
  const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
198
198
  await conductor.start();
@@ -206,7 +206,7 @@ async function cmdReap(deps, log = {}) {
206
206
  return;
207
207
  const remote = await resolveRemote(deps, config);
208
208
  const executor = deps.executor ?? (await selectExecutor(config, logger));
209
- const workspace = deps.workspace ?? (await selectWorkspace(config));
209
+ const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
210
210
  const agents = deps.agents ?? (await selectAgents(config));
211
211
  const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
212
212
  await conductor.reap();
@@ -219,7 +219,7 @@ async function cmdStartForeground(deps, log = {}) {
219
219
  return;
220
220
  const remote = await resolveRemote(deps, config);
221
221
  const executor = deps.executor ?? (await selectExecutor(config, logger));
222
- const workspace = deps.workspace ?? (await selectWorkspace(config));
222
+ const workspace = deps.workspace ?? (await selectWorkspace(config, logger));
223
223
  const agents = deps.agents ?? (await selectAgents(config));
224
224
  const conductor = new Conductor(config, remote, executor, workspace, agents, logger, checkoutRoot);
225
225
  await conductor.start();
@@ -383,13 +383,14 @@ export function createConductorCommand(deps) {
383
383
  const conductor = new Command('conductor')
384
384
  .description('node-agent lifecycle + local registry')
385
385
  .option('--log-level <level>', 'log verbosity: debug | info | warn | error (overrides GAIA_LOG_LEVEL)')
386
- .option('--log-sink <sink>', 'log sink: stdout | file (overrides GAIA_CONDUCTOR_LOG)')
386
+ .option('--log-sink <sink>', 'log sink: stdout | file | both (overrides GAIA_CONDUCTOR_LOG)')
387
387
  .addHelpText('after', `
388
388
  Logging (precedence: CLI flag > env var > default):
389
389
  --log-level <level> debug | info (default) | warn | error.
390
390
  Set debug to see per-poll ticks, claims, and idle cycles.
391
- --log-sink <sink> stdout | file (writes <checkoutRoot>/log.txt).
392
- Default: stdout on a TTY, file otherwise.
391
+ --log-sink <sink> stdout | file | both (file/both write <checkoutRoot>/log.txt).
392
+ Default: both on a TTY, file otherwise (GAIA-237 — a run in a
393
+ herdr pane is on a TTY, and its diagnostics must outlive the pane).
393
394
  GAIA_LOG_LEVEL env fallback for --log-level.
394
395
  GAIA_CONDUCTOR_LOG env fallback for --log-sink.
395
396
 
@@ -1,5 +1,5 @@
1
1
  export { type AgentFootprint, emptyAgentFootprint, type GaiaAgent, } from './plugins/agent.js';
2
- export type { ExecutorCapabilities, GaiaExecutor, HookContext, HookName, SpawnedSession, SpawnRunInput, } from './plugins/executor.js';
2
+ export type { ExecutorCapabilities, GaiaExecutor, HookContext, HookName, HookResult, SpawnedSession, SpawnRunInput, } 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';
@@ -380,11 +380,53 @@ export class Conductor {
380
380
  }
381
381
  // Lifecycle hooks are executor-owned + best-effort (GAIA-84): runHook never
382
382
  // throws, so neither call can abort dispatch or wedge a run in `claimed`.
383
- // after_create only on a freshly-created worktree; before_run always. Both
384
- // get the resolved run env so a setup/pre hook sees the same vars (e.g. a DB
385
- // DSN) the agent does (GAIA-99).
383
+ // Both get the resolved run env so a setup/pre hook sees the same vars (e.g.
384
+ // a DB DSN) the agent does (GAIA-99).
385
+ //
386
+ // GAIA-237: `after_create` runs on EVERY dispatch, not only when
387
+ // `ws.created`. `created` answers "was this worktree born just now?", which
388
+ // is not the question dispatch needs answered — a worktree whose bootstrap
389
+ // aborted, an interrupted earlier run, or a hand-made `git worktree add` all
390
+ // exist WITHOUT being provisioned, and under the creation gate they never got
391
+ // another chance. Provisioning is therefore convergent: run the hook and let
392
+ // the hook decide. Keeping the repeat cheap is the HOOK's job (gaia's
393
+ // `.ddev/worktree-post.sh` short-circuits on its own sentinel) — the
394
+ // conductor deliberately learns no project-specific "is it provisioned?"
395
+ // marker, which is exactly why the gate cannot live here.
396
+ const provisioning = await this.executor.runHook('after_create', ws.path, { ticket: t.identifier }, env);
397
+ // AC-6: an agent is never dispatched into a worktree whose bootstrap failed
398
+ // without that being visible. Dispatch is NOT withheld — runHook's
399
+ // never-throw contract exists so a hook cannot wedge a run, and withholding
400
+ // would re-introduce that wedge — so the failure is recorded against the run
401
+ // instead (ticket + run + hook + workspace + err) and the run proceeds.
402
+ // Boundedness stays with the existing circuit breaker.
403
+ if (!provisioning.ok) {
404
+ this.logger.error({
405
+ hook: provisioning.hook,
406
+ ticket: t.identifier,
407
+ run: run.runUuid,
408
+ workspace: ws.path,
409
+ err: provisioning.error,
410
+ }, 'lifecycle hook failed');
411
+ }
412
+ // GAIA-237: apply the workspace's open layout HERE — after `after_create`,
413
+ // before the run starts. The layout's startup command names the gaia CLI
414
+ // that `after_create` builds; applied inside `workspace.ensure()` (where it
415
+ // used to live) it necessarily fired first, so a self-hosting checkout had
416
+ // to paper over the ordering with a sentinel wait loop inside the pane
417
+ // command. Ordering the two correctly removes the need for any handshake.
418
+ //
419
+ // Gated on `ws.created` — DELIBERATELY narrower than the `after_create` gate
420
+ // right above it, which is convergent (a worktree can exist without ever
421
+ // having been provisioned). Only a fresh create has an initial pane to lay
422
+ // out; re-applying on reuse would start a duplicate editor on reattach.
423
+ // Optional on the contract, so a workspace with no layout concept is a
424
+ // no-op. Best-effort by contract: implementations swallow their own errors.
386
425
  if (ws.created) {
387
- await this.executor.runHook('after_create', ws.path, { ticket: t.identifier }, env);
426
+ await this.workspace.applyOpenLayout?.(ws.path, {
427
+ identifier: t.identifier,
428
+ branch: t.branchName,
429
+ });
388
430
  }
389
431
  await this.executor.runHook('before_run', ws.path, { ticket: t.identifier }, env);
390
432
  // Clear a leftover tab for THIS run id in a reused workspace before
@@ -34,6 +34,24 @@ export interface HookContext {
34
34
  /** The ticket identifier/uuid the hook is running for (log context). */
35
35
  ticket: string;
36
36
  }
37
+ /**
38
+ * What a lifecycle hook invocation DID (GAIA-237). `runHook` still never throws,
39
+ * but it no longer swallows the outcome silently: it reports it, so a caller can
40
+ * record a failed provisioning hook against the run it is about to dispatch
41
+ * instead of relying on a log line to survive (AC-6). Reporting — rather than
42
+ * throwing — keeps the never-wedge property that the never-throw contract exists
43
+ * for; boundedness stays with the conductor's circuit breaker.
44
+ */
45
+ export interface HookResult {
46
+ /** The hook slot that was invoked. */
47
+ hook: HookName;
48
+ /** false when no command is configured for the slot — nothing ran. */
49
+ ran: boolean;
50
+ /** false ONLY when a configured command actually failed. */
51
+ ok: boolean;
52
+ /** The stringified failure; present iff `ok` is false. */
53
+ error?: string;
54
+ }
37
55
  export interface GaiaExecutor {
38
56
  id: string;
39
57
  capabilities(): ExecutorCapabilities;
@@ -47,13 +65,16 @@ export interface GaiaExecutor {
47
65
  * `claimed`.
48
66
  * - The underlying shell command still fails honestly (a non-zero exit
49
67
  * rejects); only the executor catches + logs + continues.
68
+ * - MUST report the outcome as a {@link HookResult} (GAIA-237): never throwing
69
+ * is not the same as never telling. The caller decides what a failure means
70
+ * for the run it is dispatching; the executor only refuses to abort.
50
71
  *
51
72
  * `env`, when given, is the run's resolved environment (per-ticket env_vars
52
73
  * merged with the core GAIA_* vars, GAIA-99), merged over the hook process's
53
74
  * inherited env. Values may be sensitive — implementations must never log
54
75
  * them (log key names only).
55
76
  */
56
- runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<void>;
77
+ runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<HookResult>;
57
78
  startRun(input: SpawnRunInput): Promise<SpawnedSession>;
58
79
  /**
59
80
  * Signal the run's agent to stop. Called by the conductor's run-finalise pass
@@ -26,7 +26,15 @@ export interface WorkspacePlugin extends DropSHPlugin {
26
26
  readonly kind: 'workspace';
27
27
  readonly id: string;
28
28
  readonly requiredModules: string[];
29
- createWorkspace(config: ConductorFileConfig): Promise<GaiaWorkspace>;
29
+ /**
30
+ * GAIA-234: `deps` is **optional** so every existing workspace plugin
31
+ * (`addon-workspace-git`, `addon-fake`) keeps compiling untouched — a
32
+ * workspace that logs nothing simply ignores it. It reuses {@link ExecutorDeps}
33
+ * rather than minting a `WorkspaceDeps` alias: that interface is already the
34
+ * contract's name for "runtime deps handed to a plugin factory", and a second
35
+ * identical shape would be two names for one thing.
36
+ */
37
+ createWorkspace(config: ConductorFileConfig, deps?: ExecutorDeps): Promise<GaiaWorkspace>;
30
38
  }
31
39
  export interface AgentPlugin extends DropSHPlugin {
32
40
  readonly kind: 'agent';
@@ -38,8 +46,13 @@ export interface AgentPlugin extends DropSHPlugin {
38
46
  export declare function selectRemote(config: ConductorFileConfig): Promise<GaiaRemote>;
39
47
  /** Resolves the executor from its named config slot, injecting the logger. */
40
48
  export declare function selectExecutor(config: ConductorFileConfig, logger: ConductorLogger): Promise<GaiaExecutor>;
41
- /** Resolves the workspace from its named config slot. */
42
- export declare function selectWorkspace(config: ConductorFileConfig): Promise<GaiaWorkspace>;
49
+ /**
50
+ * Resolves the workspace from its named config slot, injecting the logger when
51
+ * the caller has one — mirroring {@link selectExecutor}. Without it the herdr
52
+ * workspace's best-effort open-layout warn would land on a noop logger
53
+ * (GAIA-234).
54
+ */
55
+ export declare function selectWorkspace(config: ConductorFileConfig, logger?: ConductorLogger): Promise<GaiaWorkspace>;
43
56
  /** A config-side agent choice: an agent plugin + an optional static priority over the ticket. */
44
57
  export interface AgentCandidate {
45
58
  agent: AgentPlugin;
@@ -6,9 +6,14 @@ export async function selectRemote(config) {
6
6
  export async function selectExecutor(config, logger) {
7
7
  return config.executor.createExecutor(config, { logger });
8
8
  }
9
- /** Resolves the workspace from its named config slot. */
10
- export async function selectWorkspace(config) {
11
- return config.workspace.createWorkspace(config);
9
+ /**
10
+ * Resolves the workspace from its named config slot, injecting the logger when
11
+ * the caller has one — mirroring {@link selectExecutor}. Without it the herdr
12
+ * workspace's best-effort open-layout warn would land on a noop logger
13
+ * (GAIA-234).
14
+ */
15
+ export async function selectWorkspace(config, logger) {
16
+ return config.workspace.createWorkspace(config, logger ? { logger } : undefined);
12
17
  }
13
18
  /** A missing `priority` scores strictly below any real number (AC-4). */
14
19
  const DEFAULT_PRIORITY = Number.NEGATIVE_INFINITY;
@@ -32,4 +32,34 @@ export interface GaiaWorkspace {
32
32
  * anymore — the executor owns all hooks (GAIA-84).
33
33
  */
34
34
  ensure(identifier: string, branch?: string, baseRef?: string): Promise<EnsuredWorkspace>;
35
+ /**
36
+ * Apply the workspace's "open" layout — for a hosted workspace, the startup
37
+ * command(s) of the freshly created workspace's initial pane.
38
+ *
39
+ * OPTIONAL: a workspace with no layout concept (git, fake) simply omits it and
40
+ * the conductor calls it with `?.`.
41
+ *
42
+ * GAIA-237 — why this is a SEPARATE call and not part of `ensure()`. The
43
+ * layout's startup command typically names the gaia CLI, which the
44
+ * `after_create` lifecycle hook builds. Applied inside `ensure()` it
45
+ * necessarily ran BEFORE that hook, so in a self-hosting checkout the command
46
+ * fired minutes before the binary it names existed. The only remedy available
47
+ * to a config author was a cross-process handshake — a sentinel wait loop
48
+ * inside the pane command itself. Splitting the call lets the conductor order
49
+ * the two correctly (`after_create` → `applyOpenLayout`), which removes the
50
+ * need for any handshake.
51
+ *
52
+ * The conductor calls this ONLY on a freshly created workspace
53
+ * ({@link EnsuredWorkspace.created}), because only a fresh create has an
54
+ * initial pane to lay out; re-applying on reuse would start a duplicate editor
55
+ * process on every reattach. Deliberately a NARROWER gate than the one on
56
+ * `after_create`, which is convergent and runs on every dispatch.
57
+ *
58
+ * Best-effort by contract: implementations log and swallow a failing layout so
59
+ * it can never wedge dispatch.
60
+ */
61
+ applyOpenLayout?(path: string, ctx: {
62
+ identifier: string;
63
+ branch: string;
64
+ }): Promise<void>;
35
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/conductor",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "directory": "gaia-cli/conductor"
29
29
  },
30
30
  "dependencies": {
31
- "@gaia-ai/core": "^0.6.2",
31
+ "@gaia-ai/core": "^0.6.4",
32
32
  "@dropsh/plugin-oauth2": "^0.5.7",
33
33
  "@dropsh/plugin-jsonapi-schema": "^0.5.8",
34
34
  "commander": "^12.1.0",