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
12 changes: 11 additions & 1 deletion src/fleet/internal-fleet-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { FleetSpawnNotCreatedError } from '../ports/fleet'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
Expand Down Expand Up @@ -288,6 +289,15 @@ describe('InternalFleetClient', () => {
)
})

it('preserves uncertainty when the broker rejects a spawn without placement evidence', async () => {
const client = new FakeHarnessDriverClient()
const error = new Error('spawn acknowledgement unavailable')
vi.spyOn(client, 'spawnPty').mockRejectedValueOnce(error)
const fleet = new InternalFleetClient({ client, resolveAgentRelayMcpCommand: () => undefined })
await expect(fleet.spawn({ name: 'ar-1-impl', capability: 'spawn:codex' })).rejects.toBe(error)
await fleet.dispose()
})

it('fails closed when an identity proof cannot be installed', async () => {
const fleet = new InternalFleetClient({
client: new FakeHarnessDriverClient(),
Expand All @@ -298,7 +308,7 @@ describe('InternalFleetClient', () => {
name: 'ar-1-impl',
capability: 'spawn:codex',
identityKey: 'factory:dispatch:v1:github:agentworkforce/factory#1:implementer',
})).rejects.toThrow(/identity proof cannot be installed/)
})).rejects.toBeInstanceOf(FleetSpawnNotCreatedError)
})

it('resolves the Factory-extended Agent Relay MCP command from the packed entrypoint', async () => {
Expand Down
13 changes: 12 additions & 1 deletion src/fleet/internal-fleet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { BrokerEvent, ListAgent, SendMessageInput, SpawnPtyInput } from '@a

import type { PreviewConfig } from '../config/schema'
import type { AgentMessage, AgentPidResolution, AgentUsage, Capability, FleetClient, FleetTrackedAgent, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult, TeammateAgent, TeammateQuery } from '../ports/fleet'
import { FleetDeliveryRejectedError } from '../ports/fleet'
import { FleetDeliveryRejectedError, FleetSpawnNotCreatedError } from '../ports/fleet'
import type { Logger } from '../ports/system'
import { normalizeLogger } from '../logging'
import { TailscalePreviewManager, type PreviewManager } from '../node/tailscale-preview'
Expand Down Expand Up @@ -265,6 +265,16 @@ export class InternalFleetClient implements FleetClient {
}

async spawn(input: SpawnInput): Promise<SpawnResult> {
let placementAttempted = false
try {
return await this.#spawn(input, () => { placementAttempted = true })
} catch (error) {
if (!placementAttempted) throw new FleetSpawnNotCreatedError(error)
throw error
}
}

async #spawn(input: SpawnInput, onPlacementAttempt: () => void): Promise<SpawnResult> {
assertSelfNode(input.node)
// The broker has no delivery-target registration event. Subscribe before
// spawn so worker_ready can act as a bounded re-send trigger if the child's
Expand All @@ -288,6 +298,7 @@ export class InternalFleetClient implements FleetClient {
this.#tracked.set(input.name, { invocationId: input.invocationId })
let handle: SpawnedHandleLike
try {
onPlacementAttempt()
handle = await this.#callBroker('spawnPty', (client) => client.spawnPty(spawnInput))
} catch (error) {
this.#trackAgentExit(input.name)
Expand Down
100 changes: 98 additions & 2 deletions src/fleet/relay-fleet-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { FleetSpawnNotCreatedError } from '../ports/fleet'

import { describeControlPlaneError } from './control-plane-circuit'
import { FactoryAgentRegistrationError, MAX_REGISTRATION_ATTEMPTS, ReadOnlyFleetIdentityError, RelayFleetClient, type RelayClientFactoryOptions, type RelayClientLike } from './relay-fleet-client'
import { FactoryAgentRegistrationError, MAX_REGISTRATION_ATTEMPTS, ReadOnlyFleetIdentityError, RelayFleetClient, RelaySpawnAckTimeoutError, type RelayClientFactoryOptions, type RelayClientLike } from './relay-fleet-client'
import { runFleetCli } from '../cli/fleet'
import { telemetryErrorClass } from '../observability/error-class'
import { factoryDispatchFailureReasonCodeForErrorClass } from '../orchestrator/dispatch-failure-reason'

import type {
RelayActionInvocation,
Expand Down Expand Up @@ -332,7 +335,7 @@ describe('RelayFleetClient', () => {
name: 'ar-6-impl',
capability: 'spawn:codex',
repo: 'AgentWorkforce/factory',
})).rejects.toThrow(/provisionSandbox returned no nodeName/)
})).rejects.toBeInstanceOf(FleetSpawnNotCreatedError)

expect(messaging.placements).toHaveLength(0)
})
Expand Down Expand Up @@ -2520,3 +2523,96 @@ describe('registration failures survive redaction with their cause', () => {
expect(rendered).not.toContain('https://')
})
})

/**
* A pre-placement wrapper must not erase the class that names the failure.
*
* `FleetSpawnNotCreatedError` exists to carry one extra bit — "no worker was
* created" — to the one reader that needs it (`placement.status`, decided by
* `instanceof`). Every other reader of a dispatch failure reads the OUTERMOST
* error's class name: `perItemDispatchSkipReason` renders
* `dispatch failed (<class>)`, the hosted orchestrator publishes
* `errorClass: telemetryErrorClass(error)`, and the #355 vocabulary maps five
* allowlisted class names onto failure codes.
*
* Collapsing every pre-placement failure to one class name would make an
* enrolment failure indistinguishable from a sandbox-provision refusal on
* exactly the surfaces an operator has during an outage. So the wrapper takes
* the cause's class name as its own — guarded by the very allowlist that
* publishes it, so a dependency-controlled `name` still cannot choose what
* crosses the boundary.
*/
describe('FleetSpawnNotCreatedError class preservation', () => {
const preserved = [
{
cause: () => new FactoryAgentRegistrationError(
'ar-350-impl-factory',
'MAX_ATTEMPTS',
'Remote agent did not register with the fleet before the startup deadline',
),
name: 'FactoryAgentRegistrationError',
code: 'agent-registration-failed',
},
{
cause: () => new ReadOnlyFleetIdentityError('factory-cloud'),
name: 'ReadOnlyFleetIdentityError',
code: 'fleet-identity-read-only',
},
{
cause: () => new RelaySpawnAckTimeoutError('spawn invocation ar-350-impl-factory', 300_000),
name: 'RelaySpawnAckTimeoutError',
code: 'spawn-ack-timeout',
},
] as const

it.each(preserved)('keeps $name readable on the outermost error', ({ cause, name, code }) => {
const wrapped = new FleetSpawnNotCreatedError(cause())

// The not-created bit still reaches its only reader.
expect(wrapped).toBeInstanceOf(FleetSpawnNotCreatedError)
// And every class-name reader still sees the failure that actually happened.
expect(wrapped.name).toBe(name)
expect(wrapped.causeClass).toBe(name)
expect(telemetryErrorClass(wrapped)).toBe(name)
expect(factoryDispatchFailureReasonCodeForErrorClass(telemetryErrorClass(wrapped))).toBe(code)
})

it('distinguishes the three pre-placement classes from one another', () => {
const names = preserved.map(({ cause }) => telemetryErrorClass(new FleetSpawnNotCreatedError(cause())))
expect(new Set(names).size).toBe(names.length)
})

it('falls back to its own class name when the cause names no admissible class', () => {
const hostile = new Error('placement refused')
hostile.name = 'at_live_abcdef0123456789'

for (const cause of [hostile, 'not an error', undefined]) {
const wrapped = new FleetSpawnNotCreatedError(cause)
expect(wrapped.name).toBe('FleetSpawnNotCreatedError')
expect(wrapped.causeClass).toBe('FleetSpawnNotCreatedError')
expect(telemetryErrorClass(wrapped)).toBe('FleetSpawnNotCreatedError')
}
})

it('surfaces a read-only identity refusal from spawn under its own class name', async () => {
const messaging = new FakeMessaging()
const fleet = new RelayFleetClient({
workspaceKey: 'rk_live_test',
readOnly: true,
env: {},
sleep: immediateSleep,
pollIntervalMs: 0,
createRelay: () => ({ messaging: messaging.asMessaging() }),
})

const outcome = await fleet.spawn({
name: 'ar-350-impl-factory',
capability: 'spawn:codex',
repo: 'AgentWorkforce/factory',
}).then(() => 'resolved' as const, (error: unknown) => error)

expect(messaging.placements).toEqual([])
expect(outcome).toBeInstanceOf(FleetSpawnNotCreatedError)
expect(telemetryErrorClass(outcome)).toBe('ReadOnlyFleetIdentityError')
})
})
54 changes: 34 additions & 20 deletions src/fleet/relay-fleet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AgentRelay } from '@agent-relay/sdk'
import { describeControlPlaneError } from './control-plane-circuit'
import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key'

import { FleetSpawnNotCreatedError } from '../ports/fleet'
import type { AgentLifecycleSignal, AgentMessage, AgentUsage, Capability, FleetClient, FleetConnectStatus, NodeCapability, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult, TeammateAgent, TeammateQuery } from '../ports/fleet'
import { RelaycastTeammateDirectory, type TeammateDirectory } from './teammates'
import type {
Expand Down Expand Up @@ -368,6 +369,16 @@ export class RelayFleetClient implements FleetClient {
}

async spawn(input: SpawnInput): Promise<SpawnResult> {
let placementAttempted = false
try {
return await this.#spawn(input, () => { placementAttempted = true })
} catch (error) {
if (!placementAttempted) throw new FleetSpawnNotCreatedError(error)
Comment thread
kjgbot marked this conversation as resolved.
throw error
}
}

async #spawn(input: SpawnInput, onPlacementAttempt: () => void): Promise<SpawnResult> {
// One budget for the whole placement: bootstrap, lifecycle registration,
// the placement call and every poll after it share it (#306). Anchoring it
// here rather than inside `#awaitInvocation` is what stops the time already
Expand Down Expand Up @@ -432,26 +443,29 @@ export class RelayFleetClient implements FleetClient {
// means "no placement preference".
const resolvedNode = sandboxTargetNode
?? (input.node && input.node !== 'self' ? input.node : undefined)
const ack = await this.#withinDeadline('placement.spawn', deadlineAtMs, () => messaging.placement.spawn({
capability: input.capability,
...(resolvedNode ? { node: resolvedNode } : {}),
...(input.repo ? { repo: input.repo } : {}),
input: spawnActionInput(input),
...(this.#options.placementTtlMs !== undefined ? { ttlMs: this.#options.placementTtlMs } : {}),
// An ack proves the engine accepted the dispatch, not that the node
// launched anything: a node advertising `spawn:<harness>` on an obsolete
// broker acks and launches nothing, and that is indistinguishable from a
// real spawn until someone reads the invocation back. `confirm` makes the
// SDK do that read, bounded, and fail as `spawn_unconfirmed` instead of
// handing us an ack we would wait on forever (#306).
confirm: true,
confirmTimeoutMs: Math.max(1, deadlineAtMs - this.#now()),
confirmPollIntervalMs: this.#pollIntervalMs,
log: this.#log,
// Giving up on the wait does not cancel the placement. If Relay accepts
// it after we have already reported failure, a worker is live that
// nothing is tracking — so release it (#307 review, cubic).
}), (inFlight) => this.#releaseAbandonedPlacement(input.name, inFlight))
const ack = await this.#withinDeadline('placement.spawn', deadlineAtMs, () => {
onPlacementAttempt()
return messaging.placement.spawn({
capability: input.capability,
...(resolvedNode ? { node: resolvedNode } : {}),
...(input.repo ? { repo: input.repo } : {}),
input: spawnActionInput(input),
...(this.#options.placementTtlMs !== undefined ? { ttlMs: this.#options.placementTtlMs } : {}),
// An ack proves the engine accepted the dispatch, not that the node
// launched anything: a node advertising `spawn:<harness>` on an obsolete
// broker acks and launches nothing, and that is indistinguishable from a
// real spawn until someone reads the invocation back. `confirm` makes the
// SDK do that read, bounded, and fail as `spawn_unconfirmed` instead of
// handing us an ack we would wait on forever (#306).
confirm: true,
confirmTimeoutMs: Math.max(1, deadlineAtMs - this.#now()),
confirmPollIntervalMs: this.#pollIntervalMs,
log: this.#log,
// Giving up on the wait does not cancel the placement. If Relay accepts
// it after we have already reported failure, a worker is live that
// nothing is tracking — so release it (#307 review, cubic).
})
}, (inFlight) => this.#releaseAbandonedPlacement(input.name, inFlight))
// A confirmed placement already carries the terminal invocation. Polling
// for it again would spend the same budget twice over on a spawn that has
// already proven it launched.
Expand Down
Loading