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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add `--sandbox-relayfile` to retain the previous required live repository mirror. Combine it with `--checkout` when both a Git checkout and live context must be ready before dispatch.

### Fixed
Comment thread
khaliqgant marked this conversation as resolved.

- `fleet spawn` and SDK placement no longer report `dispatchState: "dispatched"` for a spawn that was placed but never sent. Only `dispatched_node_id`, or a status the node moved, counts as dispatch evidence, so `fleet spawn` and `fleet spawn-status` agree.

## [13.4.1] - 2026-10-12

### Fixed
Expand Down
34 changes: 33 additions & 1 deletion packages/cli/src/cli/lib/spawn-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { sanitizedSpawnReceipt, spawnLifecycleState } from './spawn-lifecycle.js';
import { sanitizedSpawnReceipt, spawnLifecycleState, spawnPlacementReceipt } from './spawn-lifecycle.js';

describe('spawn lifecycle readiness', () => {
it('rejects a terminal success without launch and readiness proof', () => {
Expand Down Expand Up @@ -28,3 +28,35 @@ describe('spawn lifecycle readiness', () => {
).toEqual({ status: 'completed', output: { spawned: true, ready: false } });
});
});

describe('spawn dispatch evidence', () => {
// The engine's 201 for a placed spawn always names the chosen node in
// handler_node_id, before it sends anything. When the send fails the row stays
// pending with dispatched_node_id null; that is not a dispatch.
it('does not treat the placed handler node as dispatch evidence', () => {
expect(
spawnPlacementReceipt({
invocation_id: 'inv_unsent',
status: 'pending',
handler_node_id: 'node_sf_frame',
dispatched_node_id: null,
})
).toEqual({
state: 'accepted',
dispatchState: 'not_dispatched',
invocationId: 'inv_unsent',
handlerNodeId: 'node_sf_frame',
});
});

it('reports dispatched once the engine records the dispatched node', () => {
expect(
spawnPlacementReceipt({
invocation_id: 'inv_sent',
status: 'pending',
handler_node_id: 'node_sf_frame',
dispatched_node_id: 'node_sf_frame',
})
).toMatchObject({ dispatchState: 'dispatched', dispatchedNodeId: 'node_sf_frame' });
});
});
5 changes: 3 additions & 2 deletions packages/cli/src/cli/lib/spawn-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ function text(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined;
}

// `handler_node_id` is the node placement chose, snapshotted before any send,
// so it is present even when the send failed; only `dispatched_node_id` (or a
// status the node itself moved) shows the node received the invocation.
function dispatchEvidence(value: Record<string, unknown>): SpawnDispatchState {
const status = text(value.status)?.toLowerCase();
if (
text(value.dispatchedNodeId) ||
text(value.dispatched_node_id) ||
text(value.handlerNodeId) ||
text(value.handler_node_id) ||
(status !== undefined && (DISPATCHED.has(status) || SUCCESS.has(status)))
) {
return 'dispatched';
Expand Down
85 changes: 85 additions & 0 deletions packages/sdk/src/messaging/placement.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,91 @@ describe('RelaycastMessagingClient placement', () => {
expect((error as RelayPlacementError).dispatchState).toBe('not_dispatched');
});

// The engine's 201 for a placed spawn snapshots the chosen node in
// handler_node_id before it sends. A send that fails leaves
// dispatched_node_id null, so the placed node alone is not a route.
it('reports dispatchState not_dispatched on timeout when the ack names only the placed handler node', async () => {
const { client, invoke } = createClient([LIVE_NODE_A], {
getInvocation: async (name, invocationId) => ({
invocation_id: invocationId,
action_name: name,
status: 'pending',
dispatched_node_id: null,
}),
});
invoke.mockResolvedValueOnce({
invocation_id: 'inv-placed-unsent',
action_name: 'spawn',
handler_node_id: 'node_a',
dispatched_node_id: null,
status: 'pending',
});

const error = await client.placement
.spawn({
capability: 'spawn:claude',
node: 'node-a',
repo: 'relay',
confirm: true,
confirmTimeoutMs: 60,
confirmPollIntervalMs: 10,
input: { name: 'worker-placed-unsent' },
})
.catch((caught: unknown) => caught);

expect(error).toBeInstanceOf(RelayPlacementError);
expect((error as RelayPlacementError).code).toBe('spawn_unconfirmed');
expect((error as RelayPlacementError).dispatchState).toBe('not_dispatched');
});

// The engine can dispatch a pending invocation after acknowledging it
// (a node that reconnects drains it). A read that shows the route is
// evidence the ack lacked.
it('reports dispatchState dispatched when a confirmation read shows a route the pending ack lacked', async () => {
let reads = 0;
const { client, invoke } = createClient([LIVE_NODE_A], {
getInvocation: async (name, invocationId) => {
reads += 1;
return reads === 1
? {
invocation_id: invocationId,
action_name: name,
status: 'dispatched',
dispatched_node_id: 'node_a',
}
: {
invocation_id: invocationId,
action_name: name,
status: 'failed',
error: 'harness exited before readiness',
};
},
});
invoke.mockResolvedValueOnce({
invocation_id: 'inv-pending-then-dispatched',
action_name: 'spawn',
handler_node_id: 'node_a',
dispatched_node_id: null,
status: 'pending',
});

const error = await client.placement
.spawn({
capability: 'spawn:claude',
node: 'node-a',
repo: 'relay',
confirm: true,
confirmTimeoutMs: 1_000,
confirmPollIntervalMs: 10,
input: { name: 'worker-pending-then-dispatched' },
})
.catch((caught: unknown) => caught);

expect(error).toBeInstanceOf(RelayPlacementError);
expect((error as RelayPlacementError).code).toBe('spawn_failed');
expect((error as RelayPlacementError).dispatchState).toBe('dispatched');
});

it('reports dispatchState not_dispatched on a terminal failure when the ack never routed to a node', async () => {
const { client, invoke } = createClient([LIVE_NODE_A], {
getInvocation: async (name, invocationId) => ({
Expand Down
13 changes: 8 additions & 5 deletions packages/sdk/src/messaging/relaycast-placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,21 @@ const NOT_DISPATCHED_STATUSES = new Set(['pending', 'queued']);
/**
* Classify dispatch evidence from an invocation ack (or its terminal read
* back), matching the CLI's `spawnLifecycleState`/`dispatchEvidence`
* semantics: a `dispatchedNodeId`/`handlerNodeId` is real evidence a node
* received the dispatch. Its absence, with a `pending`/`queued` status, means
* the invocation never actually routed — even if the caller later observes a
* semantics: a `dispatchedNodeId` is real evidence a node received the
* dispatch. Its absence, with a `pending`/`queued` status, means the
* invocation never actually routed — even if the caller later observes a
* terminal (timeout or error) outcome for it. A timeout or terminal error is
* a fact about the *caller's* wait, not evidence that dispatch happened.
*
* `handlerNodeId` is deliberately not evidence: the engine snapshots the
* node placement *chose* there before any send, so an ack whose send failed
* still carries it alongside `dispatched_node_id: null`.
*/
export function resolveDispatchState(ack: {
dispatchedNodeId?: string | null;
handlerNodeId?: string | null;
status?: string;
}): RelaySpawnDispatchState {
if (ack.dispatchedNodeId || ack.handlerNodeId) return 'dispatched';
if (ack.dispatchedNodeId) return 'dispatched';
Comment thread
khaliqgant marked this conversation as resolved.
const status = ack.status?.toLowerCase();
if (status && DISPATCH_EVIDENT_STATUSES.has(status)) return 'dispatched';
if (status && NOT_DISPATCHED_STATUSES.has(status)) return 'not_dispatched';
Comment thread
khaliqgant marked this conversation as resolved.
Expand Down
19 changes: 11 additions & 8 deletions packages/sdk/src/messaging/relaycast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,14 +1006,14 @@ export class RelaycastMessagingClient implements RelayMessagingClient {
): Promise<RelayActionInvocation> {
const { timeoutMs, pollIntervalMs, requireReadiness, ...errorContext } = context;
const invocationId = ack.invocationId;
// Dispatch evidence is fixed at ack time: a node id here means the engine
// already routed the invocation to a node before this method starts
// polling. A `pending`/`queued` ack with no node id has not routed yet,
// and confirmation timing out or reading a terminal status later does not
// retroactively manufacture that evidence — see `resolveDispatchState`.
// Computed before either early exit below so every `spawn_unconfirmed`
// path, not just the timeout/failure paths, carries this evidence.
const dispatchState = resolveDispatchState(ack);
// Dispatch evidence starts from the ack: a `dispatchedNodeId` means the
// engine already routed the invocation to a node. A `pending`/`queued` ack
// without one has not routed yet. A read below that carries a
// `dispatchedNodeId` adds evidence (the engine may dispatch after
// acknowledging); a timeout or a terminal status never manufactures it — see
// `resolveDispatchState`. Computed before either early exit below so every
// `spawn_unconfirmed` path, not just the timeout/failure paths, carries it.
let dispatchState = resolveDispatchState(ack);
if (!invocationId) {
throw new RelayPlacementError(
'spawn_unconfirmed',
Expand Down Expand Up @@ -1061,6 +1061,9 @@ export class RelaycastMessagingClient implements RelayMessagingClient {
// A later success must not report an earlier transient failure.
lastReadError = undefined;
const invocation = outcome.value;
// Only a node id counts here: the read normalizer defaults a
// missing status to `invoked`, which is not evidence.
if (invocation?.dispatchedNodeId) dispatchState = 'dispatched';
const status = invocation?.status?.toLowerCase();
if (status && CONFIRM_SUCCESS_STATUSES.has(status)) {
if (
Expand Down
3 changes: 3 additions & 0 deletions tests/relayflows/cases/1563-spawn-lifecycle-truth/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,13 @@ try {
// Confirm:false is also the CLI's --no-confirm path. A terminal denial in
// the ack is already a known failure, and its invocation/receipt correlation
// must survive without entering the confirmation poll.
// node_a received the invocation and denied it, so the ack names it as the
// dispatched node; handler_node_id alone is only placement (relay#1972).
const immediateDenied = createSdkClient(async () => undefined, {
invocation_id: 'inv_immediate_denied',
action_name: 'spawn:claude',
handler_node_id: 'node_a',
dispatched_node_id: 'node_a',
status: 'denied',
error: 'node denied the spawn',
});
Expand Down
Loading