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
7 changes: 7 additions & 0 deletions .agentworkforce/features/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,13 @@ categories:
location: packages/cli/src/cli/commands/fleet.ts
verify_tier: 5

- id: fleet-spawn-status
name: Read Fleet Spawn Dispatch Status
cli: relay fleet spawn-status <invocation-id>
description: Read a recorded fleet spawn dispatch without spawning again, separating confirmed readiness, terminal failure, and an outcome the node has not reported yet
location: packages/cli/src/cli/commands/fleet.ts
verify_tier: 5

- id: fleet-release
name: Release Fleet Agent
cli: relay fleet release <name> [--reason <reason>] [--delete-agent]
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The `agent-relay mcp` server adds `upload_file` (local path or base64) and `download_file`, and `send_group_dm` accepts `attachments`.
- Agents spawned by the broker receive message attachments (up to 25 MiB each) as local files: the injected message ends with an `Attachments:` list giving each file's path under `.agent-relay/attachments/` in the agent's working directory, or an `agent-relay message file download <file_id>` command when a file could not be fetched. Messages that carry only attachments are now delivered too.
- `@agent-relay/sdk` adds `files.upload` / `files.get` / `files.download` on `AgentRelay` and `RelaycastMessagingClient`, plus `uploadRelayFile` / `downloadRelayFile` helpers.
- `agent-relay fleet spawn-status <invocation-id>` reads a recorded spawn dispatch without spawning again, separating confirmed readiness, a node-reported failure, and an outcome the node has not reported yet.

### Changed

Expand All @@ -40,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A confirmed `fleet spawn --task` (the default) succeeds only once the agent's harness has visibly accepted the task (activity or a cleared composer, not merely the task echoed into the composer). If the task cannot be delivered, the worker is released and the spawn fails with `spawn_task_failed`. If delivery cannot be confirmed, the spawn fails with `spawn_task_unconfirmed`, names the agent that is still running, and says not to retry. With `--no-confirm` the spawn reports as soon as the node accepts it, without waiting for the task to arrive.
- `agent-relay message file upload` now uploads and attaches the file; it always failed with "Invalid attachments: file ids must exist in workspace and be complete".
- Broker-managed messages retry failed delivery up to three times, and later sends wait until delivery succeeds.
- `fleet spawn` (and SDK placement confirmation) now waits six minutes for launch confirmation instead of two, so agents that register minutes after dispatch are confirmed rather than reported as unconfirmed; the timeout error names the `fleet spawn-status` poll for its invocation id instead of suggesting a redispatch.

## [13.2.0] - 2026-10-08

Expand Down
17 changes: 17 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,23 @@ set `RELAY_AGENT_TOKEN` to the token returned by
login (`agent-relay cloud login`) but does not need an agent token: when one is
absent, it creates and removes a short-lived launcher identity automatically.

A targeted spawn waits for the node to confirm the launch, for six minutes by
default (`--confirm-timeout <ms>`, minimum 95000). Agents that take minutes to
register are confirmed inside that window rather than reported as unconfirmed.
If the window does expire, the error carries the invocation id: read that
dispatch instead of spawning again, because a retry under the same name
collides with a worker that may still be starting.

```bash
agent-relay fleet spawn-status inv_01J...
```

The reply's `placement.state` separates the outcomes: `ready` (launched and
confirmed), `failed` (the node reported a terminal failure), `accepted`
(launched, readiness unverified), and `unconfirmed_may_be_running` (the node
still has not reported — not evidence that nothing started). Polling never
dispatches another agent.

Without placement options, `fleet spawn` connects to the local project's broker
and passes the caller's exact directory, including a nested package, to the
worker. Start the local broker with `agent-relay node up` if it is not running;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const expectedLeafCommands = [
'fleet nodes list',
'fleet release',
'fleet spawn',
'fleet spawn-status',
'fleet status',
// cloud
'cloud login',
Expand Down
118 changes: 117 additions & 1 deletion packages/cli/src/cli/commands/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,7 @@ describe('fleet command support', () => {
// #1430: acceptance by the node is not evidence of a launch, so a
// targeted spawn asks the node to confirm unless told not to.
confirm: true,
confirmTimeoutMs: 120_000,
confirmTimeoutMs: 360_000,
input: {
name: 'api-worker',
cli: 'codex',
Expand Down Expand Up @@ -5767,3 +5767,119 @@ describe('fleet command support', () => {
expect(errors.join('\n')).toMatch(/relay cloud enroll/);
});
});

describe('fleet spawn-status', () => {
it.each([
['invoked', undefined, 'unconfirmed_may_be_running'],
['completed', { spawned: true, ready: true }, 'ready'],
['failed', undefined, 'failed'],
['completed', { spawned: true, ready: false }, 'accepted'],
])('reports %s without redispatching or exposing raw output', async (status, output, state) => {
const getInvocation = vi.fn(async () => ({
invocationId: 'inv_late',
status,
output: { ...output, secret: 'raw-secret' },
error: 'raw-secret',
input: { task: 'private-task' },
}));
const spawn = vi.fn();
const logs: string[] = [];
const program = new Command();
program.exitOverride();
registerFleetCommands(program, {
sdk: {
createAgentRelay: vi.fn(() => ({
messaging: { commands: { getInvocation }, placement: { spawn } },
})) as never,
log: (message: unknown) => logs.push(String(message)),
error: vi.fn(),
exit: vi.fn() as never,
},
});
await program.parseAsync(['fleet', 'spawn-status', 'inv_late', '--token', 'at_test'], { from: 'user' });
expect(getInvocation).toHaveBeenCalledWith('spawn', 'inv_late');
expect(spawn).not.toHaveBeenCalled();
expect(JSON.parse(logs[0]!).invocation.placement).toMatchObject({ invocationId: 'inv_late', state });
expect(logs.join('')).not.toContain('raw-secret');
expect(logs.join('')).not.toContain('private-task');
});

// #1430 follow-up: the reported failure was a caller unable to tell a worker
// that launched late from one that never started. The state alone is not that
// evidence — the launch proof and the node that answered have to survive the
// read, and a silent dispatch must not acquire a launch claim it never made.
it.each([
[
'a node that has not reported yet',
{ invocationId: 'inv_late', status: 'invoked', dispatched_node_id: 'node_sf_mini' },
{ state: 'unconfirmed_may_be_running', dispatchState: 'dispatched', dispatchedNodeId: 'node_sf_mini' },
undefined,
],
[
'a launch confirmed after the spawn gave up',
{
invocationId: 'inv_late',
status: 'completed',
handler_node_id: 'node_sf_mini',
output: { spawned: true, ready: true },
},
{ state: 'ready', dispatchState: 'dispatched', handlerNodeId: 'node_sf_mini' },
{ spawned: true, ready: true },
],
])('keeps the launch evidence for %s', async (_label, invocation, placement, output) => {
const logs: string[] = [];
const program = new Command();
program.exitOverride();
registerFleetCommands(program, {
sdk: {
createAgentRelay: vi.fn(() => ({
messaging: { commands: { getInvocation: vi.fn(async () => invocation) } },
})) as never,
log: (message: unknown) => logs.push(String(message)),
error: vi.fn(),
exit: vi.fn() as never,
},
});
await program.parseAsync(['fleet', 'spawn-status', 'inv_late', '--token', 'at_test'], { from: 'user' });

const reported = JSON.parse(logs[0]!).invocation;
expect(reported.placement).toMatchObject(placement);
expect(reported.output).toEqual(output);
});

it('mints and releases a temporary reader for spawn-status with workspace credentials', async () => {
vi.stubEnv('RELAY_AGENT_TOKEN', '');
vi.stubEnv('RELAY_BASE_URL', 'https://relay.isolated.test');
const register = vi.fn(async () => ({ token: 'at_reader' }));
const release = vi.fn(async () => undefined);
const getInvocation = vi.fn(async () => ({ invocationId: 'inv_late', status: 'failed' }));
const createAgentRelay = vi.fn(() => ({ messaging: { commands: { getInvocation } } }));
const program = new Command();
program.exitOverride();
registerFleetCommands(program, {
sdk: {
createWorkspaceRelay: vi.fn(() => ({ workspace: { register, release } })) as never,
createAgentRelay: createAgentRelay as never,
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as never,
},
});
await program.parseAsync(['fleet', 'spawn-status', 'inv_late', '--workspace-key', 'rk_test'], {
from: 'user',
});
expect(register).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { purpose: 'fleet-spawn-status' } }),
{ strict: true }
);
// The minted token reads alone, from the gateway that minted it: passing
// the workspace key too is rejected by createAgentRelay.
expect(createAgentRelay).toHaveBeenCalledWith({
token: 'at_reader',
baseUrl: 'https://relay.isolated.test',
});
expect(release).toHaveBeenCalledWith(
expect.objectContaining({ name: register.mock.calls[0]![0].name, deleteAgent: true })
);
});
});
66 changes: 63 additions & 3 deletions packages/cli/src/cli/commands/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { redactSecrets } from '../lib/redact.js';
import { attributableReleaseReason } from '../lib/release-reason.js';
import { retireOwnedIntegrationBindings } from './integration.js';
import { resolveSandboxRepository, type SandboxRepositorySelection } from '../lib/sandbox-repo.js';
import { spawnPlacementReceipt } from '../lib/spawn-lifecycle.js';
import { sanitizedSpawnReceipt, spawnPlacementReceipt } from '../lib/spawn-lifecycle.js';
import {
resolveAgentToken,
resolveWorkspaceSelection,
Expand Down Expand Up @@ -451,6 +451,66 @@ export function registerFleetCommands(
await runFleetAgentList(deps, options);
});

addSdkOptions(
group
.command('spawn-status')
.description('Read an existing spawn dispatch without launching another agent')
.argument('<invocation-id>', 'Invocation ID returned by fleet spawn')
).action(async (invocationId: string, options: Record<string, unknown>) => {
await runSdk(deps.sdk, async () => {
const clientOptions = sdkOptionsFromOpts(options);
let token = resolveAgentToken(clientOptions);
let launcherName: string | undefined;
let workspace: ReturnType<FleetCommandDependencies['sdk']['createWorkspaceRelay']> | undefined;
try {
if (!token) {
workspace = deps.sdk.createWorkspaceRelay(clientOptions);
const name = `fleet-status-launcher-${randomUUID().slice(0, 8)}`;
const launcher = await workspace.workspace.register(
{ name, metadata: { purpose: 'fleet-spawn-status' } },
{ strict: true }
);
launcherName = name;
token = launcher.token;
if (!token) throw new Error('The temporary fleet status launcher did not receive an agent token.');
}
// A minted launcher token must read from the gateway that minted it, and
// never alongside the workspace key that minted it (createAgentRelay
// rejects that pair), matching the spawn path's minted-token client.
const relay = deps.sdk.createAgentRelay(
launcherName
? { token, baseUrl: resolveWorkspaceTransport(clientOptions).baseUrl }
: { ...clientOptions, token }
);
const invocation = await relay.messaging.commands.getInvocation('spawn', invocationId);
// Keep this read-only surface on the existing safe receipt projection.
const receipt = sanitizedSpawnReceipt(invocation as unknown as Record<string, unknown>);
const result = spawnInvocationWithPlacement(receipt);
Comment thread
khaliqgant marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
const output = receipt.output as Record<string, unknown> | undefined;
// --no-confirm dispatches may legitimately report launch without readiness.
// Preserve that evidence instead of claiming the launched worker failed.
if (
['completed', 'success', 'succeeded'].includes(String(receipt.status).toLowerCase()) &&
output?.spawned === true &&
output.ready === false
) {
result.placement = { ...spawnPlacementReceipt(receipt), state: 'accepted' };
}
printJson(deps.sdk, { invocation: result });
} finally {
if (launcherName && workspace) {
await workspace.workspace
.release({
name: launcherName,
reason: 'Temporary fleet spawn status launcher completed',
deleteAgent: true,
})
.catch((error) => deps.warn(redactSecrets(String(error))));
}
}
});
});

addSdkOptions(
group
.command('spawn')
Expand Down Expand Up @@ -505,7 +565,7 @@ export function registerFleetCommands(
.option(
'--confirm-timeout <ms>',
`How long a targeted spawn waits for harness readiness (minimum ${MIN_VERIFIED_CONFIRM_TIMEOUT_MS}ms)`,
'120000'
'360000'
)
).action(async (cli: string, options: Record<string, unknown>) => {
await runSdk(deps.sdk, async () => {
Expand Down Expand Up @@ -592,7 +652,7 @@ export function registerFleetCommands(
{ organization, project, workstream, role, objective },
task
);
const confirmTimeoutText = optionalText(options.confirmTimeout, 'Confirm timeout') ?? '120000';
const confirmTimeoutText = optionalText(options.confirmTimeout, 'Confirm timeout') ?? '360000';
const confirmTimeoutMs = Number(confirmTimeoutText);
if (!Number.isFinite(confirmTimeoutMs) || confirmTimeoutMs <= 0) {
throw new Error('--confirm-timeout must be a positive number of milliseconds.');
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/cli/lib/fleet-spawn-confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,39 @@ describe('targeted spawn readiness contract', () => {
}
});
});

it('confirms a five-minute launch using the default budget without dispatching again', async () => {
vi.useFakeTimers();
try {
const started = Date.now();
const { client, invoke } = createClient(async (name, invocationId) => ({
invocation_id: invocationId,
action_name: name,
status: Date.now() - started >= 300_000 ? 'completed' : 'invoked',
output: { spawned: true, ready: true },
}));
const pending = client.placement.spawn(spawnInput({ confirm: true }));
await vi.advanceTimersByTimeAsync(300_500);
expect((await pending).placement.confirmed).toBe(true);
expect(invoke).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

it('returns a pollable invocation ID when the extended default budget expires', async () => {
vi.useFakeTimers();
try {
const { client, invoke } = createClient(async () => ({ status: 'invoked' }));
const pending = client.placement.spawn(spawnInput({ confirm: true })).catch((error) => error);
await vi.advanceTimersByTimeAsync(360_500);
const error = await pending;
expect(error.code).toBe('spawn_unconfirmed');
expect(error.state).toBe('unconfirmed_may_be_running');
expect(error.invocationId).toBe('inv-1430');
expect(error.message).toContain('fleet spawn-status inv-1430');
expect(invoke).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
25 changes: 24 additions & 1 deletion packages/sdk/src/__tests__/relaycast-translate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';

import { normalizeWebhookSubscription, toRelayNode } from '../messaging/relaycast-translate.js';
import {
normalizeActionInvocation,
normalizeWebhookSubscription,
toRelayNode,
} from '../messaging/relaycast-translate.js';

describe('toRelayNode fleet liveness', () => {
it('omits stale active-agent load for an offline node', () => {
Expand All @@ -19,6 +23,25 @@ describe('toRelayNode fleet liveness', () => {
});
});

describe('normalizeActionInvocation dispatch evidence', () => {
it('keeps the node that handled or was dispatched the invocation', () => {
const invocation = normalizeActionInvocation({
invocation_id: 'inv_late',
action_name: 'spawn',
status: 'failed',
handler_node_id: 'node_a',
dispatched_node_id: 'node_b',
});
expect(invocation).toMatchObject({ handlerNodeId: 'node_a', dispatchedNodeId: 'node_b' });
});

it('omits node ids the server did not report', () => {
const invocation = normalizeActionInvocation({ invocation_id: 'inv', status: 'invoked' });
expect(invocation).not.toHaveProperty('handlerNodeId');
expect(invocation).not.toHaveProperty('dispatchedNodeId');
});
});

describe('subscription event normalization', () => {
it.each([
{ events: ['message.created', 'thread.reply'] },
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/src/messaging/relaycast-translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ export function normalizeActionInvocation(raw: unknown): RelayActionInvocation {
actionName: readStr(record, 'actionName', 'action_name') ?? '',
callerId: (readStr(record, 'callerId', 'caller_id') ?? null) as string | null,
callerName: (readStr(record, 'callerName', 'caller_name') ?? null) as string | null,
...(readStr(record, 'handlerNodeId', 'handler_node_id')
? { handlerNodeId: readStr(record, 'handlerNodeId', 'handler_node_id') }
: {}),
...(readStr(record, 'dispatchedNodeId', 'dispatched_node_id')
? { dispatchedNodeId: readStr(record, 'dispatchedNodeId', 'dispatched_node_id') }
: {}),
input: readRecord(record, 'input') ?? {},
output: readRecord(record, 'output') ?? null,
status: readStr(record, 'status') ?? 'invoked',
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/messaging/relaycast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export { RelayPlacementError } from './relaycast-placement.js';
export type { RelaySpawnDispatchState, RelaySpawnPlacementState } from './relaycast-placement.js';
export type { RelaycastMessagingOptions } from './relaycast-client.js';

const DEFAULT_CONFIRM_TIMEOUT_MS = 120_000;
const DEFAULT_CONFIRM_TIMEOUT_MS = 360_000;
const DEFAULT_CONFIRM_POLL_MS = 500;
/** `setTimeout` clamps anything larger, firing immediately instead of waiting. */
const MAX_CONFIRM_TIMEOUT_MS = 2_147_483_647;
Expand Down Expand Up @@ -1114,7 +1114,7 @@ export class RelaycastMessagingClient implements RelayMessagingClient {
`node '${context.node}' accepted ${actionName} (invocation ${invocationId}) but never reported a result within ${budgetMs}ms. ` +
`The node advertised capacity and acknowledged the dispatch; nothing confirmed that it launched. ` +
`The invocation may still be running, so do not retry blindly. ` +
`Check that node's broker version, or re-run without confirmation to accept an unconfirmed dispatch.` +
`Poll this dispatch with agent-relay fleet spawn-status ${invocationId}; an unknown outcome is not proof that no process started.` +
(lastReadError ? ` Last read error: ${lastReadError}` : ''),
{
...errorContext,
Expand Down
Loading
Loading