Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,43 @@ describe('fleet CLI runtime', () => {
}
})

// The plumbing that was missing: without it a cloud deployment can configure
// `relay.agentName` and still register as the default, colliding with itself.
it.each([
['forwards a configured relay agent name to fleet construction', { relay: { agentName: 'factory-cloud' } }, 'factory-cloud'],
['leaves the relay agent name unset when the config omits it', {}, undefined],
])('%s', async (_label, overrides, expected) => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-relay-identity-'))
try {
const configPath = await writeConfig(root, overrides)
const createFleetCalls: Array<{ relayAgentName?: string }> = []
const output = buffer()

const code = await runFleetCli([
'--backend',
'relay',
'run-once',
'--dry-run',
'--config',
configPath,
], {
createFleet: (opts) => {
createFleetCalls.push(opts as { relayAgentName?: string })
return new FakeFleetClient()
},
cloudMountFromConfig: async () => new FakeMountClient({ [issuePath]: issueFile }),
stdout: output,
stderr: buffer(),
})

expect(code).toBe(0)
expect(createFleetCalls).toHaveLength(1)
expect(createFleetCalls[0]?.relayAgentName).toBe(expected)
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('keeps explicit fixtureFiles configs on Fake fleet and mount for harness runs', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-fixture-opt-in-'))
try {
Expand Down
19 changes: 17 additions & 2 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,7 +1790,13 @@ async function buildFleet(
// real broker bootstrap.
if (deps.createFleet) {
return deps.createFleet(
{ backend: globals.backend, cwd, connectionPath, previewConfig: loaded?.config.preview },
{
backend: globals.backend,
cwd,
connectionPath,
previewConfig: loaded?.config.preview,
relayAgentName: loaded?.config.relay.agentName,
},
{ ownedBrokerAgentExitTimeoutMs: globals.agentExitTimeoutMs },
)
}
Expand Down Expand Up @@ -1827,7 +1833,16 @@ async function buildFleet(
)
}

return createFleet({ backend: globals.backend, cwd, connectionPath, previewConfig: loaded?.config.preview }, { env: deps.env })
return createFleet(
{
backend: globals.backend,
cwd,
connectionPath,
previewConfig: loaded?.config.preview,
relayAgentName: loaded?.config.relay.agentName,
},
{ env: deps.env },
)
}

function streamLogger(stream: Pick<NodeJS.WriteStream, 'write'>): Logger {
Expand Down
123 changes: 123 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,126 @@ import { join } from 'node:path'
import { FactoryConfigSchema, NodeConfigSchema, loadFactoryConfig } from './schema'
import { routedPrRepos } from '../github/routed-pr-babysitter'

describe('relay.agentName', () => {
it('keeps an explicit relay agent name', () => {
const parsed = FactoryConfigSchema.parse({
repos: { byLabel: { pear: 'AgentWorkforce/pear' } },
relay: { agentName: 'factory-cloud' },
})

expect(parsed.relay.agentName).toBe('factory-cloud')
})

it('trims surrounding whitespace from an otherwise valid name', () => {
const parsed = FactoryConfigSchema.parse({
repos: { byLabel: { pear: 'AgentWorkforce/pear' } },
relay: { agentName: ' factory-cloud ' },
})

expect(parsed.relay.agentName).toBe('factory-cloud')
})

// An identity that silently falls back to the default is how a workspace
// registration collision hides: the operator believes they pinned a name.
it.each([
['empty', ''],
['whitespace-only', ' '],
['a tab', '\t'],
])('rejects %s relay.agentName at config load instead of defaulting it', (_label, agentName) => {
const result = FactoryConfigSchema.safeParse({
repos: { byLabel: { pear: 'AgentWorkforce/pear' } },
relay: { agentName },
})

expect(result.success).toBe(false)
// Asserted by path and code, not by message text: the point is that the
// trimmed value failed the length check under `relay.agentName`, rather
// than some unrelated issue happening to mention the field.
const issue = result.success ? undefined : result.error.issues.find(
(candidate) => candidate.path.join('.') === 'relay.agentName',
)
expect(issue?.code).toBe('too_small')
})

// Split configs are how a cloud deployment and another deployment share one
// workspace. If the identity could only live on the shared half, every
// deployment would register the same name -- the collision this prevents.
it('takes the relay agent name from the node-local half', () => {
const loaded = loadFactoryConfig({
workspaceConfig: { repos: { default: 'AgentWorkforce/factory' } },
nodeConfig: { relay: { agentName: 'factory-cloud' } },
})

expect(loaded.factoryConfig.relay.agentName).toBe('factory-cloud')
// The node-local half must carry it back too, or a per-host identity is
// reflected as workspace-shared configuration.
expect(loaded.nodeConfig.relay.agentName).toBe('factory-cloud')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
})

it('lets the node-local relay agent name override the workspace default', () => {
const loaded = loadFactoryConfig({
workspaceConfig: {
repos: { default: 'AgentWorkforce/factory' },
relay: { agentName: 'factory-shared' },
},
nodeConfig: { relay: { agentName: 'factory-cloud' } },
})

expect(loaded.factoryConfig.relay.agentName).toBe('factory-cloud')
})

it('still applies a workspace-half relay agent name when the node half sets none', () => {
const loaded = loadFactoryConfig({
workspaceConfig: {
repos: { default: 'AgentWorkforce/factory' },
relay: { agentName: 'factory-shared' },
},
nodeConfig: {},
})

expect(loaded.factoryConfig.relay.agentName).toBe('factory-shared')
})

// Both halves are validated before the merge, so a node override cannot
// launder an invalid shared value into a clean load on the hosts that
// override it.
it.each([
['workspaceConfig', { relay: { agentName: ' ' } }, { relay: { agentName: 'factory-cloud' } }],
['nodeConfig', { relay: { agentName: 'factory-shared' } }, { relay: { agentName: ' ' } }],
])('rejects an invalid relay agent name in the %s half even when the other half is valid', (field, workspaceRelay, nodeRelay) => {
expect(() => loadFactoryConfig({
workspaceConfig: { repos: { default: 'AgentWorkforce/factory' }, ...workspaceRelay },
nodeConfig: nodeRelay,
})).toThrow(new RegExp(`${field} has an invalid relay config`))
})

// Pins existing `normalizeLoadedConfig` semantics rather than asserting an
// intent: both views are projected from the *merged* config, so the
// workspace-shaped view reports the effective identity, exactly as it already
// does for preview, cloneRoot, and clonePaths. Nothing serializes
// `workspaceConfig` back to a shared file today; this test fails loudly if
// that projection ever changes underneath a caller who starts to.
it('reports the effective identity on the workspace-shaped view after a node override', () => {
const loaded = loadFactoryConfig({
workspaceConfig: {
repos: { default: 'AgentWorkforce/factory' },
relay: { agentName: 'factory-shared' },
},
nodeConfig: { relay: { agentName: 'factory-cloud' } },
})

expect(loaded.workspaceConfig.relay.agentName).toBe('factory-cloud')
expect(loaded.nodeConfig.relay.agentName).toBe('factory-cloud')
})

it('rejects an unknown key under relay rather than ignoring a typo', () => {
expect(() => FactoryConfigSchema.parse({
repos: { byLabel: { pear: 'AgentWorkforce/pear' } },
relay: { agentname: 'factory-cloud' },
})).toThrow(/unrecognized key/i)
})
})

describe('FactoryConfigSchema', () => {
it('parses a valid config and applies defaults', () => {
const parsed = FactoryConfigSchema.parse({
Expand Down Expand Up @@ -44,6 +164,9 @@ describe('FactoryConfigSchema', () => {
resetTimeoutMs: 60_000,
requireDedicatedBroker: false,
})
// Absent, not defaulted in the schema: RelayFleetClient owns the
// `factory` fallback, so there is exactly one place the default lives.
expect(parsed.relay).toEqual({})
expect(parsed.models).toEqual({ babysitter: 'sonnet' })
// Agent CLI per role defaults to today's behavior: codex implements, claude
// reviews/babysits — so existing configs are unaffected unless set.
Expand Down
57 changes: 57 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,33 @@ const fleetHealthSchema = z.object({
requireDedicatedBroker: z.boolean().default(false),
}).default({})

/**
* Relay-workspace identity settings for the `relay` fleet backend.
*
* `agentName` is the workspace agent Factory registers *as itself* — not an
* agent it spawns. Two Factory deployments sharing one workspace must not share
* it: the second registration collides with the first
* (`Agent "<name>" already exists in this workspace`), the fleet control plane
* never initialises, and nothing is ever dispatched. Hosts with ephemeral disk
* cannot prove they own a previously-registered name after a restart, so a
* deployment that needs its own identity pins one here.
*
* Left unset, `RelayFleetClient` keeps its own `DEFAULT_AGENT_NAME`, so every
* existing deployment registers exactly the name it registers today.
*
* Declared on both config halves, like `preview`: the workspace half carries a
* shared default, and the node half overrides it per host. An identity that
* only ever lived on the workspace half would be handed to every deployment in
* the workspace -- the collision this exists to prevent.
*/
const relaySchema = z.object({
// Deliberately not `.default(...)`: an empty or whitespace-only value is a
// misconfiguration, and coercing it to the default identity is precisely how
// an unconfigurable identity stayed invisible. `.trim().min(1)` rejects it at
// config load instead.
agentName: z.string().trim().min(1).optional(),
}).strict().default({})

const loopSchema = z.object({
maxIterations: z.number().int().min(1).max(5).default(3),
maxConsecutiveFailures: z.number().int().min(1).max(5).default(3),
Expand Down Expand Up @@ -381,6 +408,7 @@ const WorkspaceConfigObjectSchema = z.object({
liveSubscription: liveSubscriptionSchema,
dispatch: dispatchSchema,
fleetHealth: fleetHealthSchema,
relay: relaySchema,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the relay identity in node-local config

In split-config/cloud deployments, WorkspaceConfig is the durable configuration shared by the workspace, while NodeConfig is the per-host half. Defining relay.agentName only on WorkspaceConfigObjectSchema therefore gives every Factory deployment in that workspace the same identity, and NodeConfigSchema strips the key if a deployment tries to set its own value there. With two deployments, the second still encounters the registration collision described above and its fleet control plane cannot initialize; this identity needs to be accepted as node/deployment-local configuration and merged from that half.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9373e35 — but the diagnosis needed one correction, so recording what I verified.

Half right. I probed loadFactoryConfig directly rather than reasoning from the schema. combineSplitConfigInput merges the two raw halves before FactoryConfigSchema.parse runs, so a nodeConfig half setting relay.agentName already reached factoryConfig.relay.agentName and already took precedence over the workspace half. The stated consequence — "the second deployment still encounters the registration collision" — was therefore not reachable: a per-deployment identity was settable from the node half as written.

The real gap, which is worth fixing. NodeConfigSchema does strip the key, so the node-half view returned by loadFactoryConfig carried no relay, while workspaceConfig did. A per-host identity was reflected back as workspace-shared configuration, and anything round-tripping the split halves would migrate one deployment's identity onto every other deployment in the workspace — the collision this setting exists to prevent, arriving by a different route.

So relay is now declared on both halves, mirroring how preview already is: workspace half = shared default, node half = per-host override. The two relay objects are merged rather than replaced, so a node half pinning only agentName cannot drop other workspace-half settings.

Covered by three new tests in src/config/schema.test.ts. Only one of them fails against the previous commit (TypeError on loaded.nodeConfig.relay) — the other two passed already, which is precisely the evidence that the runtime path was not broken. Full suite green apart from one pre-existing flake reproduced on unmodified 2e52791.

loop: loopSchema,
triage: triageSchema,
repos: workspaceReposSchema,
Expand Down Expand Up @@ -438,6 +466,9 @@ const WorkspaceConfigObjectSchema = z.object({

const NodeConfigObjectSchema = z.object({
workspaceId: z.string().optional(),
// Node-local, so two deployments sharing a workspace can register distinct
// relay identities. Overrides the workspace half when both set it.
relay: relaySchema,
capabilities: z.array(z.string()).default([]),
cloneRoot: z.string().optional(),
clonePaths: z.record(z.string(), z.string()).default({}),
Expand Down Expand Up @@ -681,6 +712,7 @@ function normalizeLoadedConfig(input: unknown): LoadedFactoryConfig {
factoryLoopHeartbeatPath: factoryConfig.loop.heartbeatPath,
factoryLoopRegistryPath: factoryConfig.loop.registryPath,
preview: factoryConfig.preview,
relay: factoryConfig.relay,
})

return { workspaceConfig, nodeConfig, factoryConfig }
Expand All @@ -701,6 +733,16 @@ function combineSplitConfigInput(workspaceInput: unknown, nodeInput: unknown): R
const nodePreview = asOptionalConfigRecord(node.preview)
const hasPreview = workspace.preview !== undefined || node.preview !== undefined

// Each half is validated *before* the merge. A node half that overrides the
// identity otherwise discards an invalid workspace-half value without it ever
// reaching `relaySchema`, so a broken shared config would load clean on every
// host that happens to override it -- the silent acceptance this key exists
// to prevent. Mirrors validateClonePathSyntax, which validates both halves
// before node-local values take precedence.
const workspaceRelay = validateRelayHalf(workspace.relay, 'workspaceConfig')
const nodeRelay = validateRelayHalf(node.relay, 'nodeConfig')
const hasRelay = workspace.relay !== undefined || node.relay !== undefined

return {
...workspace,
...node,
Expand All @@ -714,6 +756,9 @@ function combineSplitConfigInput(workspaceInput: unknown, nodeInput: unknown): R
},
},
} : {}),
// Merged rather than replaced, so a node half that pins only `agentName`
// does not drop whatever else the workspace half configured.
...(hasRelay ? { relay: { ...workspaceRelay, ...nodeRelay } } : {}),
repos: {
...workspaceRepos,
cloneRoot: node.cloneRoot ?? workspaceRepos.cloneRoot,
Expand All @@ -722,6 +767,18 @@ function combineSplitConfigInput(workspaceInput: unknown, nodeInput: unknown): R
}
}

function validateRelayHalf(value: unknown, field: string): Record<string, unknown> {
const record = asOptionalConfigRecord(value)
const parsed = relaySchema.safeParse(record)
if (!parsed.success) {
const detail = parsed.error.issues
.map((issue) => `${['relay', ...issue.path].join('.')}: ${issue.message}`)
.join('; ')
throw new Error(`${field} has an invalid relay config -- ${detail}`)
}
return record
}

function normalizePreviewConfig<T extends z.infer<typeof previewSchema>>(preview: T): T {
if (!preview) return preview
return {
Expand Down
34 changes: 34 additions & 0 deletions src/fleet/create-fleet.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { createFleet } from './create-fleet'
import { FactoryConfigSchema } from '../config/schema'
import { InternalFleetClient } from './internal-fleet-client'
import { RelayFleetClient } from './relay-fleet-client'
import type { HarnessDriverClientLike } from './internal-fleet-client'
Expand All @@ -21,6 +22,11 @@ const fakeHarness: HarnessDriverClientLike = {
async sendInput() {},
}

const baseConfig = {
workspaceId: 'ws_create_fleet_test',
repos: { byLabel: { pear: 'AgentWorkforce/pear' } },
}

describe('createFleet', () => {
afterEach(() => {
vi.unstubAllEnvs()
Expand Down Expand Up @@ -97,4 +103,32 @@ describe('createFleet', () => {

await expect(fleet.roster()).rejects.toThrow(/requires a workspace key \(rk_live_…\) or agent token \(at_live_…\)/)
})

// The identity the factory registers as, threaded config -> createFleet ->
// RelayFleetClient. Both directions are asserted from a really-parsed config
// so the schema, the option, and the client default stay in one story.
describe('relay workspace identity', () => {
const relayFleetFor = (raw: Record<string, unknown>): RelayFleetClient => {
const config = FactoryConfigSchema.parse({ ...baseConfig, ...raw })
return createFleet(
{ backend: 'relay', relayAgentName: config.relay.agentName },
{ env: {} },
) as RelayFleetClient
}

it('registers under the agent name the config supplies', () => {
expect(relayFleetFor({ relay: { agentName: 'factory-cloud' } }).agentName).toBe('factory-cloud')
})

// Guards the upgrade path, not the feature: a deployment that never sets
// `relay.agentName` must keep registering the exact name it registers
// today. A silent identity change here strands a live deployment the same
// way an unconfigurable identity did.
it('keeps the built-in `factory` identity when the config omits an agent name', () => {
const config = FactoryConfigSchema.parse(baseConfig)

expect(config.relay.agentName).toBeUndefined()
expect(relayFleetFor({}).agentName).toBe('factory')
})
})
})
7 changes: 7 additions & 0 deletions src/fleet/create-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface CreateFleetOptions {
cwd?: string
connectionPath?: string
previewConfig?: PreviewConfig
/**
* Workspace identity the relay backend registers as (config `relay.agentName`).
* Relay-only; the internal backend has no workspace registration. Undefined
* leaves `RelayFleetClient`'s own default in place.
*/
relayAgentName?: string
}

export interface CreateFleetDeps {
Expand Down Expand Up @@ -53,6 +59,7 @@ export function createFleet(options: CreateFleetOptions = {}, deps: CreateFleetD
if (backend === 'relay') {
return new RelayFleetClient({
workspaceKey: deps.workspaceKey,
agentName: options.relayAgentName,
env: deps.env,
log: deps.logger ? (message) => deps.logger?.info?.(message) : undefined,
})
Expand Down
Loading