Skip to content
10 changes: 10 additions & 0 deletions .agentworkforce/agents/factory-feature-guardian/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
"intent": "relay-orchestrator",
"tags": ["factory", "verification", "proactive", "health", "release-safety", "durability"],
"description": "Traverses the Factory feature catalog hourly to exercise the manifest/model/state chain with log-only healthy evidence, without claiming per-feature E2E verification; genuine run failures surface through deployment monitoring, while scoped Slack response events retain exact-revision incident handling.",
"skills": [
{
"id": "factory-feature-verification",
"source": ".agentworkforce/features/verify/procedures.md",
"description": "Verify Factory features against their named end-to-end procedures and supported safety boundaries."
}
],
"relay": {
"agentName": "factory-feature-guardian"
},
"cloud": true,
"harness": "opencode",
"model": "deepseek-v4-flash-free",
Expand Down
48 changes: 42 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@
"@agent-relay/integration-prompts": "^11.6.9",
"@agent-relay/sdk": "^11.6.9",
"@agentworkforce/delivery": "^4.1.23",
"@agentworkforce/persona-kit": "^4.1.43",
"@agentworkforce/review-kit": "^4.1.34",
"@agentworkforce/runtime": "^4.1.23",
"@relaycast/a2a": "^6.2.0",
"@relayfile/relay-helpers": "^0.4.6",
"@relayfile/sdk": "0.10.34",
"@relayflows/core": "^1.0.3",
Expand All @@ -118,7 +120,6 @@
"listr2": "9.0.5"
},
"devDependencies": {
"@agentworkforce/persona-kit": "^4.1.23",
"@types/node": "^22.10.2",
"@types/proper-lockfile": "^4.1.4",
"esbuild": "^0.28.1",
Expand Down
25 changes: 24 additions & 1 deletion src/fleet/internal-fleet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { dirname, join } from 'node:path'
import type { BrokerEvent, ListAgent, SendMessageInput, SpawnPtyInput } from '@agent-relay/harness-driver'

import type { PreviewConfig } from '../config/schema'
import type { AgentMessage, AgentPidResolution, AgentUsage, Capability, FleetClient, FleetTrackedAgent, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult } from '../ports/fleet'
import type { AgentMessage, AgentPidResolution, AgentUsage, Capability, FleetClient, FleetTrackedAgent, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult, TeammateAgent, TeammateQuery } from '../ports/fleet'
import type { Logger } from '../ports/system'
import { normalizeLogger } from '../logging'
import { TailscalePreviewManager, type PreviewManager } from '../node/tailscale-preview'
import { resolveRelayWorkspaceKey } from './relay-workspace-key'
import { RelaycastTeammateDirectory, type TeammateDirectory } from './teammates'

const requireForResolve = createRequire(import.meta.url)

Expand Down Expand Up @@ -64,6 +65,11 @@ export interface InternalFleetClientOptions {
*/
connect?: (options: { cwd?: string; connectionPath?: string }) => HarnessDriverClientLike
workspaceKey?: string
/** Card-aware directory seam; useful with a local broker plus mock directory. */
teammateDirectory?: TeammateDirectory
directoryBaseUrl?: string
directoryFetch?: typeof globalThis.fetch
directoryTimeoutMs?: number
/** Canonical cloud liveness lookup. Injected by tests; derived from workspaceKey in production. */
listCanonicalOnlineAgentNames?: () => Promise<readonly string[]>
/** Local process-liveness probe used to preserve workers that intentionally run without Relay MCP presence. */
Expand Down Expand Up @@ -129,6 +135,7 @@ export class InternalFleetClient implements FleetClient {
readonly #cwd?: string
readonly #connectionPath?: string
readonly #workspaceKey?: string
readonly #teammateDirectory?: TeammateDirectory
readonly #listCanonicalOnlineAgentNames?: () => Promise<readonly string[]>
readonly #isProcessAlive: (pid: number) => boolean
readonly #now: () => number
Expand Down Expand Up @@ -168,6 +175,15 @@ export class InternalFleetClient implements FleetClient {
this.#cwd = options.cwd
this.#connectionPath = options.connectionPath
this.#workspaceKey = options.workspaceKey
const directoryToken = resolveRelayWorkspaceKey({ workspaceKey: options.workspaceKey })
this.#teammateDirectory = options.teammateDirectory ?? (directoryToken
? new RelaycastTeammateDirectory({
baseUrl: options.directoryBaseUrl ?? process.env.RELAY_BASE_URL,
token: directoryToken,
fetch: options.directoryFetch,
timeoutMs: options.directoryTimeoutMs,
})
: undefined)
if (options.listCanonicalOnlineAgentNames) {
this.#listCanonicalOnlineAgentNames = options.listCanonicalOnlineAgentNames
} else if (options.workspaceKey) {
Expand Down Expand Up @@ -389,6 +405,13 @@ export class InternalFleetClient implements FleetClient {
}
}

async discoverTeammates(query: TeammateQuery): Promise<TeammateAgent[]> {
if (!this.#teammateDirectory) {
throw new Error('InternalFleetClient teammate discovery requires a directory or Relay workspace key')
}
return await this.#teammateDirectory.discover(query)
}

async createPreview(input: PreviewStartInput): Promise<PreviewReference> {
assertSelfNode(input.node)
if (!this.#previewManager) throw new Error('Tailscale preview provider is not configured')
Expand Down
72 changes: 70 additions & 2 deletions src/fleet/relay-fleet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { AgentRelay } from '@agent-relay/sdk'

import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key'

import type { AgentLifecycleSignal, AgentMessage, AgentUsage, Capability, FleetClient, NodeCapability, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult } from '../ports/fleet'
import type { AgentLifecycleSignal, AgentMessage, AgentUsage, Capability, FleetClient, NodeCapability, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult, TeammateAgent, TeammateQuery } from '../ports/fleet'
import { RelaycastTeammateDirectory, type TeammateDirectory } from './teammates'
import type {
RelayActionInvocation,
RelayActionInvocationAck,
Expand Down Expand Up @@ -52,6 +53,10 @@ export interface RelayFleetClientOptions {
lifecycleActionName?: string
/** Engine base URL override. Absent means the SDK default (cast.agentrelay.com). */
baseUrl?: string
/** Card-aware directory seam. Defaults to Relaycast GET /v1/a2a/directory. */
teammateDirectory?: TeammateDirectory
directoryFetch?: typeof globalThis.fetch
directoryTimeoutMs?: number
/** Timeout for a spawn/release invocation to reach a terminal ack status. */
spawnAckTimeoutMs?: number
pollIntervalMs?: number
Expand Down Expand Up @@ -174,10 +179,12 @@ export class RelayFleetClient implements FleetClient {
// therefore createFleet({ backend: 'relay' })) never throws merely because no
// token is configured.
#messaging: RelayMessaging | undefined
#teammateDirectory: TeammateDirectory | undefined
#messagingReady: Promise<RelayMessaging> | undefined
#lifecycleActionReady: Promise<void> | undefined
#authenticatedAgentName: string
#eventsStarted = false
#eventSubscriptionReady?: Promise<void>
#disposed = false
#watchTimer: ReturnType<typeof setInterval> | undefined
#reconciling: Promise<void> | undefined
Expand Down Expand Up @@ -209,6 +216,7 @@ export class RelayFleetClient implements FleetClient {
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
this.#log = options.log ?? (() => {})
this.#messaging = options.messaging
this.#teammateDirectory = options.teammateDirectory
}

/** Agents spawned through this client that have not exited or been released. */
Expand Down Expand Up @@ -503,8 +511,31 @@ export class RelayFleetClient implements FleetClient {
}
}

async discoverTeammates(query: TeammateQuery): Promise<TeammateAgent[]> {
this.#teammateDirectory ??= this.#createTeammateDirectory()
return await this.#teammateDirectory.discover(query)
}

// `from`/`data` are not representable on the agent-scoped messaging surface:
// every send is authored by the factory's own agent identity.
//
// That identity is also the `target` stamped on every inbound message (see
// `#emitAgentMessage`), so a reply waiter must match against it rather than
// against the `from` its caller asked to send as.
//
// `#authenticatedAgentName` starts life as the CONFIGURED `agentName` and is
// only replaced with the server's answer once something has called
// `agents.me()`. Returning it synchronously would hand out that pre-auth
// guess, which is wrong whenever an injected `messaging` or an existing
// `agentToken` authenticates as a different name. Resolve it for real.
async effectiveSender(): Promise<string | undefined> {
const messaging = await this.#ensureMessaging()
const identity = await messaging.agents.me()
const resolved = identity.name?.trim()
if (resolved) this.#authenticatedAgentName = resolved
return this.#authenticatedAgentName
}

async sendMessage(input: SendInput): Promise<void> {
await this.#send(input)
}
Expand Down Expand Up @@ -625,6 +656,26 @@ export class RelayFleetClient implements FleetClient {
}
}

#createTeammateDirectory(): TeammateDirectory {
const env = this.#options.env
const token = resolveRelayWorkspaceKey({
workspaceKey: this.#options.workspaceKey,
...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
}) ?? resolveRelayAgentToken({
agentToken: this.#options.agentToken,
...(env ? { env } : {}),
})
if (!token) {
throw new Error('RelayFleetClient teammate discovery requires a workspace key or agent token')
}
return new RelaycastTeammateDirectory({
baseUrl: this.#options.baseUrl,
token,
fetch: this.#options.directoryFetch,
timeoutMs: this.#options.directoryTimeoutMs,
})
}

#ensureMessaging(): Promise<RelayMessaging> {
if (this.#messaging) return Promise.resolve(this.#messaging)
this.#messagingReady ??= this.#bootstrapMessaging().catch((error) => {
Expand Down Expand Up @@ -1112,10 +1163,27 @@ export class RelayFleetClient implements FleetClient {
#ensureEventSubscription(): void {
if (this.#eventsStarted) return
this.#eventsStarted = true
void this.#subscribeEvents().catch((error) => {
this.#eventSubscriptionReady = this.#subscribeEvents().catch((error) => {
this.#eventsStarted = false
this.#eventSubscriptionReady = undefined
this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`)
throw error
})
// Nothing here awaits it -- callers that must not miss an inbound message
// use `whenMessagesObservable()`. The rejection is re-thrown for them and
// swallowed here so a background subscription failure stays non-fatal.
void this.#eventSubscriptionReady.catch(() => {})
}

// `onAgentMessage` returns as soon as the listener is in the local set, but
// the SDK handler behind it is installed by an async chain (messaging
// bootstrap, then lifecycle registration, then `events.connect()`). A caller
// that registers a listener and immediately sends can therefore lose a fast
// reply that lands before the transport is actually listening. Await this
// between the two.
async whenMessagesObservable(): Promise<void> {
this.#ensureEventSubscription()
await this.#eventSubscriptionReady
}

async #subscribeEvents(): Promise<void> {
Expand Down
Loading